diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 1e50c64121cbe..630b4d5923d15 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -67,7 +67,7 @@ jobs: include: - scalaProfile: "scala-2.12" sparkProfile: "spark3.5" - flinkProfile: "flink1.18" + flinkProfile: "flink2.1" steps: - uses: actions/checkout@v5 @@ -126,7 +126,7 @@ jobs: include: - scalaProfile: "scala-2.12" sparkProfile: "spark3.5" - flinkProfile: "flink1.18" + flinkProfile: "flink2.1" steps: - uses: actions/checkout@v5 @@ -178,7 +178,7 @@ jobs: include: - scalaProfile: "scala-2.12" sparkProfile: "spark3.5" - flinkProfile: "flink1.18" + flinkProfile: "flink2.1" env: UT_MODULES: >- @@ -563,7 +563,7 @@ jobs: include: - scalaProfile: "scala-2.12" sparkProfile: "spark3.5" - flinkProfile: "flink1.20" + flinkProfile: "flink2.1" steps: - uses: actions/checkout@v5 @@ -580,14 +580,14 @@ jobs: SPARK_PROFILE: ${{ matrix.sparkProfile }} FLINK_PROFILE: ${{ matrix.flinkProfile }} run: - mvn clean install -T 2 -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"FLINK_PROFILE" -DskipTests=true -Phudi-platform-service $MVN_ARGS -am -pl hudi-hadoop-mr,hudi-client/hudi-java-client + mvn clean install -T 2 -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" -DskipTests=true -Phudi-platform-service $MVN_ARGS -am -pl hudi-hadoop-mr,hudi-client/hudi-java-client - name: UT - hudi-hadoop-mr and hudi-client/hudi-java-client env: SCALA_PROFILE: ${{ matrix.scalaProfile }} SPARK_PROFILE: ${{ matrix.sparkProfile }} FLINK_PROFILE: ${{ matrix.flinkProfile }} run: - mvn test -Punit-tests -fae -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"FLINK_PROFILE" -pl hudi-hadoop-mr,hudi-client/hudi-java-client $MVN_ARGS -Djacoco.skip=false + mvn test -Punit-tests -fae -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" -pl hudi-hadoop-mr,hudi-client/hudi-java-client $MVN_ARGS -Djacoco.skip=false - name: Generate merged coverage report if: always() run: ./scripts/jacoco/generate_merged_coverage_report.sh $GITHUB_WORKSPACE @@ -938,19 +938,30 @@ jobs: FLINK_PROFILE: ${{ matrix.flinkProfile }} FLINK_AVRO_VERSION: ${{ matrix.flinkAvroVersion }} FLINK_PARQUET_VERSION: ${{ matrix.flinkParquetVersion }} - if: ${{ endsWith(env.FLINK_PROFILE, '1.20') }} + if: ${{ endsWith(env.FLINK_PROFILE, '2.1') }} run: | mvn clean install -T 2 -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -pl hudi-flink-datasource/hudi-flink -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -DskipTests=true $MVN_ARGS - mvn verify -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" $FLINK_IT_FILTER1 -pl hudi-flink-datasource/hudi-flink $MVN_ARGS + mvn verify -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" $FLINK_IT_FILTER1 -pl hudi-flink-datasource/hudi-flink $MVN_ARGS -Djacoco.skip=false + - name: Generate merged coverage report + if: always() && endsWith(matrix.flinkProfile, '2.1') + run: ./scripts/jacoco/generate_merged_coverage_report.sh $GITHUB_WORKSPACE + - name: Upload coverage to Codecov + if: always() && endsWith(matrix.flinkProfile, '2.1') + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: ./jacoco-report.xml + disable_search: true + flags: flink-integration-tests + token: ${{ secrets.CODECOV_TOKEN }} test-flink-2: runs-on: ubuntu-latest strategy: matrix: include: - - flinkProfile: "flink1.20" + - flinkProfile: "flink2.1" flinkAvroVersion: "1.11.4" - flinkParquetVersion: '1.13.1' + flinkParquetVersion: '1.15.2' steps: - uses: actions/checkout@v5 - name: Set up JDK 11 @@ -974,10 +985,21 @@ jobs: FLINK_PROFILE: ${{ matrix.flinkProfile }} FLINK_AVRO_VERSION: ${{ matrix.flinkAvroVersion }} FLINK_PARQUET_VERSION: ${{ matrix.flinkParquetVersion }} - if: ${{ endsWith(env.FLINK_PROFILE, '1.20') }} + if: ${{ endsWith(env.FLINK_PROFILE, '2.1') }} run: | mvn clean install -T 2 -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -pl hudi-flink-datasource/hudi-flink -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -DskipTests=true $MVN_ARGS - mvn verify -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" $FLINK_IT_FILTER2 -pl hudi-flink-datasource/hudi-flink $MVN_ARGS + mvn verify -Pintegration-tests -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" $FLINK_IT_FILTER2 -pl hudi-flink-datasource/hudi-flink $MVN_ARGS -Djacoco.skip=false + - name: Generate merged coverage report + if: always() && endsWith(matrix.flinkProfile, '2.1') + run: ./scripts/jacoco/generate_merged_coverage_report.sh $GITHUB_WORKSPACE + - name: Upload coverage to Codecov + if: always() && endsWith(matrix.flinkProfile, '2.1') + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: ./jacoco-report.xml + disable_search: true + flags: flink-integration-tests + token: ${{ secrets.CODECOV_TOKEN }} docker-java17-test: runs-on: ubuntu-latest @@ -985,19 +1007,19 @@ jobs: matrix: include: - scalaProfile: 'scala-2.13' - flinkProfile: 'flink1.20' + flinkProfile: 'flink2.1' sparkProfile: 'spark3.5' sparkRuntime: 'spark3.5.0' - scalaProfile: 'scala-2.12' - flinkProfile: 'flink1.20' + flinkProfile: 'flink2.1' sparkProfile: 'spark3.5' sparkRuntime: 'spark3.5.0' - scalaProfile: 'scala-2.13' - flinkProfile: 'flink1.20' + flinkProfile: 'flink2.1' sparkProfile: 'spark4.0' sparkRuntime: 'spark4.0.0' - scalaProfile: 'scala-2.13' - flinkProfile: 'flink1.20' + flinkProfile: 'flink2.1' sparkProfile: 'spark4.1' sparkRuntime: 'spark4.1.1' @@ -1074,7 +1096,7 @@ jobs: mvn clean package -T 2 -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" -DdeployArtifacts=true -DskipTests=true $MVN_ARGS # TODO remove the sudo below. It's a needed workaround as detailed in HUDI-5708. sudo chown -R "$USER:$(id -g -n)" hudi-platform-service/hudi-metaserver/target/generated-sources - mvn package -T 2 -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -DdeployArtifacts=true -DskipTests=true $MVN_ARGS -pl packaging/hudi-flink-bundle -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" + mvn package -T 2 -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -DdeployArtifacts=true -DskipTests=true $MVN_ARGS -pl packaging/hudi-flink-bundle -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" fi - name: IT - Bundle Validation - OpenJDK 11 env: @@ -1201,7 +1223,7 @@ jobs: matrix: include: - sparkProfile: 'spark3.5' - flinkProfile: 'flink1.20' + flinkProfile: 'flink2.1' sparkArchive: 'spark-3.5.3/spark-3.5.3-bin-hadoop3.tgz' steps: - uses: actions/checkout@v5 @@ -1251,6 +1273,81 @@ jobs: rm -f $GITHUB_WORKSPACE/$SPARK_ARCHIVE mvn verify $SCALA_PROFILE -D"$SPARK_PROFILE" -Pintegration-tests -pl !hudi-flink-datasource/hudi-flink $MVN_ARGS + integration-tests-hive-sync: + # Testcontainers-based E2E hive sync coverage for Hudi's custom logical types + # (VECTOR, BLOB) and the Spark 4.0+ VARIANT type. Runs the integ2 testcontainers + # suite (ITTestCustomTypeHiveSync) against a real Hive metastore on Spark 3.5.3, + # 4.0.2, and 4.1.1 stacks. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - sparkProfile: 'spark3.5' + scalaProfile: '-Dscala-2.12 -Dscala.binary.version=2.12' + flinkProfile: 'flink1.20' + jdkVersion: '11' + composePrefix: 'docker-compose_hadoop284_hive2310_spark353' + sparkAdhocImage: 'apachehudi/hudi-hadoop_2.8.4-hive_2.3.10-sparkadhoc_3.5.3:latest' + - sparkProfile: 'spark4.0' + scalaProfile: '-Dscala-2.13 -Dscala.binary.version=2.13' + flinkProfile: 'flink1.20' + jdkVersion: '17' + composePrefix: 'docker-compose_hadoop340_hive2310_spark402' + sparkAdhocImage: 'apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.0.2:latest' + - sparkProfile: 'spark4.1' + scalaProfile: '-Dscala-2.13 -Dscala.binary.version=2.13' + flinkProfile: 'flink1.20' + jdkVersion: '17' + composePrefix: 'docker-compose_hadoop340_hive2310_spark411' + sparkAdhocImage: 'apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.1.1:latest' + steps: + - uses: actions/checkout@v5 + - name: Set up JDK ${{ matrix.jdkVersion }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.jdkVersion }} + distribution: 'temurin' + architecture: x64 + cache: maven + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + docker system prune --all --force --volumes + - name: Pre-pull compose images (fails fast if not published) + env: + SPARK_ADHOC_IMAGE: ${{ matrix.sparkAdhocImage }} + run: | + # Surface missing Spark 4.0.2 images before the 15-minute Maven install. + # The remaining images in the compose stack are pulled by docker-compose at + # test time. + docker pull "$SPARK_ADHOC_IMAGE" + - name: Build and install Hudi artifacts + env: + SPARK_PROFILE: ${{ matrix.sparkProfile }} + FLINK_PROFILE: ${{ matrix.flinkProfile }} + SCALA_PROFILE: ${{ matrix.scalaProfile }} + run: + mvn clean install -T 2 $SCALA_PROFILE -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" -Pintegration-tests -DskipTests=true -Ddocker.compose.skip=true $MVN_ARGS + - name: Run integ2 testcontainers suite + env: + SPARK_PROFILE: ${{ matrix.sparkProfile }} + SCALA_PROFILE: ${{ matrix.scalaProfile }} + COMPOSE_PREFIX: ${{ matrix.composePrefix }} + run: | + # -DskipITs=false overrides the spark4.0 profile's skipITs=true default + # (see root pom.xml). Without it, failsafe skips all ITs on the spark4.0 matrix row. + mvn verify $SCALA_PROFILE -D"$SPARK_PROFILE" -Pintegration-tests \ + -pl hudi-integ-test \ + -DskipITs=false \ + -Ddocker.compose.skip=true \ + -Dit.test='ITTestCustomTypeHiveSync' \ + -Dspark.docker.compose.prefix=$COMPOSE_PREFIX \ + $MVN_ARGS + build-spark-java17: runs-on: ubuntu-latest strategy: @@ -1297,9 +1394,9 @@ jobs: matrix: include: - scalaProfile: "scala-2.12" - flinkProfile: "flink1.20" + flinkProfile: "flink2.1" flinkAvroVersion: '1.11.4' - flinkParquetVersion: '1.13.1' + flinkParquetVersion: '1.15.2' steps: - uses: actions/checkout@v5 - name: Set up JDK 17 @@ -1316,7 +1413,7 @@ jobs: FLINK_AVRO_VERSION: ${{ matrix.flinkAvroVersion }} FLINK_PARQUET_VERSION: ${{ matrix.flinkParquetVersion }} run: - mvn clean install -T 2 -Djava17 -Djava.version=17 -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -pl hudi-examples/hudi-examples-flink -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -DskipTests=true $MVN_ARGS + mvn clean install -T 2 -Djava17 -Djava.version=17 -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -pl hudi-examples/hudi-examples-flink -am -Davro.version="$FLINK_AVRO_VERSION" -Dparquet.version="$FLINK_PARQUET_VERSION" -DskipTests=true $MVN_ARGS - name: Quickstart Test env: SCALA_PROFILE: ${{ matrix.scalaProfile }} @@ -1324,25 +1421,3 @@ jobs: run: mvn test -Punit-tests -Djava17 -Djava.version=17 -D"$SCALA_PROFILE" -D"$FLINK_PROFILE" -pl hudi-examples/hudi-examples-flink $MVN_ARGS - test-hudi-trino-plugin: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - name: Set up JDK 23 - uses: actions/setup-java@v5 - with: - # Note: We are not caching here again, as we want to use the .m2 repository populated by - # the previous step - java-version: '23' - distribution: 'temurin' - architecture: x64 - cache: maven - - name: Build hudi-trino-plugin with JDK 23 - working-directory: ./hudi-trino-plugin - run: - mvn clean install -DskipTests - - name: Test hudi-trino-plugin with JDK 23 - working-directory: ./hudi-trino-plugin - run: - mvn test -Dapi.version=1.44 diff --git a/.github/workflows/hudi_trino_ci.yml b/.github/workflows/hudi_trino_ci.yml new file mode 100644 index 0000000000000..cbd7b851f42cb --- /dev/null +++ b/.github/workflows/hudi_trino_ci.yml @@ -0,0 +1,153 @@ +name: Hudi Trino Connector CI + +on: + push: + branches: + - master + - 'release-*' + paths: + - 'hudi-trino/**' + - '.github/workflows/hudi_trino_ci.yml' + # Upstream modules the connector build installs (the -am closure of the JDK 17 + # install step) plus the poms that own trino.version and the dependency pins. + # Keep in sync with the case patterns in detect-trino-changes below. + - 'pom.xml' + - 'hudi-tests-common/**' + - 'hudi-io/**' + - 'hudi-common/**' + - 'hudi-hadoop-common/**' + - 'hudi-timeline-service/**' + - 'hudi-hadoop-mr/**' + - 'hudi-client/pom.xml' + - 'hudi-client/hudi-client-common/**' + - 'hudi-client/hudi-java-client/**' + - 'hudi-sync/hudi-sync-common/**' + - 'hudi-sync/hudi-hive-sync/**' + # No `paths:` filter here on purpose. test-hudi-trino-plugin is a required status check + # in .asf.yaml, and a path-filtered workflow is never instantiated on PRs that miss the + # filter, leaving the required context permanently pending. Run on every PR instead and + # skip the expensive steps via the detect-trino-changes job below. + pull_request: + branches: + - master + - 'release-*' + workflow_dispatch: + +concurrency: + group: hudi-trino-ci-${{ github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'master') && !contains(github.ref, 'release-') }} + +env: + MVN_ARGS: -e -ntp -B -V -Dgpg.skip -Djacoco.skip -Pwarn-log + +jobs: + changes: + name: detect-trino-changes + runs-on: ubuntu-latest + outputs: + trino: ${{ steps.filter.outputs.trino }} + steps: + - name: Detect hudi-trino changes + id: filter + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + run: | + set -euo pipefail + TRINO=false + if [ "$EVENT" = "pull_request" ]; then + FILES=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') + elif [ "$EVENT" = "push" ]; then + FILES=$(gh api "repos/$REPO/compare/$BEFORE_SHA...$AFTER_SHA" --jq '.files[].filename') + else + # workflow_dispatch and anything else: always run the full build. + FILES="" + TRINO=true + fi + echo "Changed files:" + printf '%s\n' "$FILES" + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + hudi-trino/*) TRINO=true ;; + .github/workflows/hudi_trino_ci.yml) TRINO=true ;; + # Upstream modules the connector build installs (the -am closure of the JDK 17 + # install step) plus the poms that own trino.version and the dependency pins. + # Keep in sync with the push paths above. + pom.xml) TRINO=true ;; + hudi-tests-common/*) TRINO=true ;; + hudi-io/*) TRINO=true ;; + hudi-common/*) TRINO=true ;; + hudi-hadoop-common/*) TRINO=true ;; + hudi-timeline-service/*) TRINO=true ;; + hudi-hadoop-mr/*) TRINO=true ;; + hudi-client/pom.xml) TRINO=true ;; + hudi-client/hudi-client-common/*) TRINO=true ;; + hudi-client/hudi-java-client/*) TRINO=true ;; + hudi-sync/hudi-sync-common/*) TRINO=true ;; + hudi-sync/hudi-hive-sync/*) TRINO=true ;; + esac + done <<< "$FILES" + echo "trino=$TRINO" + echo "trino=$TRINO" >> "$GITHUB_OUTPUT" + + build-and-test: + name: test-hudi-trino-plugin + runs-on: ubuntu-latest + needs: changes + steps: + - name: Checkout repository + if: needs.changes.outputs.trino == 'true' + uses: actions/checkout@v5 + # Hudi targets Java 11 and uses Lombok 1.18.36, which does not run on JDK 25. + # Build the upstream modules hudi-trino depends on under JDK 17 first, + # install them into the local m2, then build the connector itself under JDK 25. + - name: Set up JDK 17 + if: needs.changes.outputs.trino == 'true' + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Install upstream Hudi modules (JDK 17) + if: needs.changes.outputs.trino == 'true' + # hudi-client-common and hudi-java-client are pulled in here for the test stage; + # they live behind the hudi-trino-tests profile but the test step needs them. + run: mvn $MVN_ARGS install -pl :hudi-common,:hudi-hive-sync,:hudi-io,:hudi-sync-common,:hudi-client-common,:hudi-java-client -am -Dmaven.test.skip=true -Drat.skip -Dcheckstyle.skip + - name: Set up JDK 25 + if: needs.changes.outputs.trino == 'true' + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + # Trino does not publish trino-spi / trino-filesystem / trino-hive test-jars to + # Maven Central. Check out the matching release tag and install just the modules + # whose test classifiers we need into the local m2. + - name: Checkout trinodb/trino at 481 + if: needs.changes.outputs.trino == 'true' + uses: actions/checkout@v5 + with: + repository: trinodb/trino + ref: '481' + path: trino-src + - name: Install Trino test-jars (JDK 25) + if: needs.changes.outputs.trino == 'true' + working-directory: trino-src + run: mvn $MVN_ARGS install -pl :trino-spi,:trino-filesystem,:trino-hive,:trino-main -am -DskipTests -Dair.check.skip-all=true + - name: Build connector (JDK 25) + if: needs.changes.outputs.trino == 'true' + run: mvn $MVN_ARGS -Phudi-trino -pl hudi-trino install -Dmaven.test.skip=true + # The release deploy (-DdeployArtifacts=true) runs javadoc:jar with doclint=none, + # and the module pom keeps failOnError=true. Reproduce that exact configuration here + # so a javadoc break fails the PR instead of surfacing during the staging deploy. + - name: Javadoc check (JDK 25) + if: needs.changes.outputs.trino == 'true' + run: mvn $MVN_ARGS -Phudi-trino -pl hudi-trino javadoc:jar -Ddoclint=none + - name: Test connector (JDK 25) + if: needs.changes.outputs.trino == 'true' + run: mvn $MVN_ARGS -Phudi-trino,hudi-trino-tests -pl hudi-trino test diff --git a/.github/workflows/hudi_trino_compat.yml b/.github/workflows/hudi_trino_compat.yml new file mode 100644 index 0000000000000..701f721155109 --- /dev/null +++ b/.github/workflows/hudi_trino_compat.yml @@ -0,0 +1,105 @@ +name: Hudi Trino SPI Compatibility + +on: + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +# The failure handler files/updates a drift report issue. +permissions: + contents: read + issues: write + +env: + MVN_ARGS: -e -ntp -B -V -Dgpg.skip -Djacoco.skip + +jobs: + compile-against-trino-master: + name: Compile hudi-trino against trinodb/trino master + runs-on: ubuntu-latest + steps: + - name: Checkout Hudi + uses: actions/checkout@v5 + with: + path: hudi + - name: Checkout trinodb/trino master + uses: actions/checkout@v5 + with: + repository: trinodb/trino + ref: master + path: trino + # Hudi targets Java 11 and uses Lombok 1.18.36, which does not run on JDK 25. + # Install the upstream Hudi modules under JDK 17 first, then compile the connector + # under JDK 25. + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Install upstream Hudi modules (JDK 17) + working-directory: hudi + run: mvn $MVN_ARGS install -pl :hudi-common,:hudi-hive-sync,:hudi-io,:hudi-sync-common -am -Dmaven.test.skip=true -Drat.skip -Dcheckstyle.skip + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + - name: Read Trino version + id: trino-version + working-directory: trino + run: | + set -euo pipefail + # Ask Maven for the project version rather than grepping the pom: the first in + # trinodb/trino's root pom belongs to the (io.airlift:airbase), not to Trino. + # Keep the -SNAPSHOT suffix -- master's version is unreleased, so it only resolves against + # the artifacts installed from source in the next step. + VERSION=$(mvn -q -N help:evaluate -Dexpression=project.version -DforceStdout) + echo "trino_version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Detected Trino version: $VERSION" + - name: Install Trino modules from master (JDK 25) + working-directory: trino + # hudi-trino compiles against these plus their transitive modules (spi, cache, metastore, + # hive-formats, memory-context). They must come from the master checkout -- resolving from + # Maven Central would defeat the point of the drift check. + run: mvn $MVN_ARGS install -pl :trino-hive,:trino-filesystem-manager,:trino-parquet,:trino-plugin-toolkit -am -DskipTests -Dair.check.skip-all=true + - name: Compile hudi-trino against current Trino SPI (JDK 25) + id: compile + working-directory: hudi + run: | + mvn $MVN_ARGS -Phudi-trino \ + -Dtrino.version=${{ steps.trino-version.outputs.trino_version }} \ + -pl hudi-trino compile + - name: Open issue on failure + # Only a connector compile failure is SPI drift. A bare failure() would also file + # the issue for a failed checkout or a broken trinodb/trino master build, with an + # empty version in the title when the failure is before Read Trino version. + if: failure() && steps.compile.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const version = '${{ steps.trino-version.outputs.trino_version }}'; + // Drift usually persists for days until someone fixes it. Comment on the existing report + // instead of filing a fresh issue every night. + const existing = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:body "${marker}"`, + }); + if (existing.data.total_count > 0) { + const number = existing.data.items[0].number; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: `Still failing against Trino ${version}. See ${runUrl}.`, + }); + return; + } + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `hudi-trino SPI drift detected against Trino ${version}`, + body: `${marker}\nNightly compatibility build failed against Trino ${version}. See ${runUrl}.`, + }); diff --git a/.github/workflows/hudi_trino_e2e.yml b/.github/workflows/hudi_trino_e2e.yml new file mode 100644 index 0000000000000..df2f945e36d34 --- /dev/null +++ b/.github/workflows/hudi_trino_e2e.yml @@ -0,0 +1,186 @@ +name: Hudi Trino E2E + +on: + push: + branches: + - master + - 'release-*' + paths: + # Several entries are deliberately broad -- keep them that way: + # docker/demo/** the ITs drive demo fixture scripts + # (sparksql-*.commands, setup_demo_container.sh). + # docker/compose/hadoop.env + # copied into the generated compose dir and loaded + # by every service in the stack. + # hudi-integ-test/** the IT step runs `mvn verify -pl hudi-integ-test`, + # which compiles the whole module, so a break + # anywhere in it fails this pipeline -- not just + # under integ2/. + # pom.xml owns trino.version, which the shim pom's parent, + # the Dockerfile TRINO_VERSION arg and the + # hardcoded 481 paths below all track by hand. + - 'hudi-trino/**' + - 'docker/trino/**' + - 'docker/compose/docker-compose_hadoop340_hive2310_spark402*' + - 'docker/compose/hadoop.env' + - 'docker/demo/**' + - 'hudi-integ-test/**' + - 'pom.xml' + - '.github/workflows/hudi_trino_e2e.yml' + pull_request: + branches: + - master + - 'release-*' + # Keep in sync with the push paths above; see the rationale there. + paths: + - 'hudi-trino/**' + - 'docker/trino/**' + - 'docker/compose/docker-compose_hadoop340_hive2310_spark402*' + - 'docker/compose/hadoop.env' + - 'docker/demo/**' + - 'hudi-integ-test/**' + - 'pom.xml' + - '.github/workflows/hudi_trino_e2e.yml' + workflow_dispatch: + +concurrency: + group: hudi-trino-e2e-${{ github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'master') && !contains(github.ref, 'release-') }} + +env: + # The aether.connector.http.* retry/timeout knobs are copied verbatim from bot.yml's + # MVN_ARGS: the JDK 17 step below is a cold-cache full-reactor build, exactly what + # those were added for. They are maven-resolver properties -- Maven 3.9 resolves through + # the native resolver HTTP transport, which ignores the older maven.wagon.* names. + MVN_ARGS: -e -ntp -B -V -Dgpg.skip -Djacoco.skip -Pwarn-log -Daether.connector.http.retryHandler.count=5 -Daether.connector.http.retryHandler.interval=3000 -Daether.connector.http.retryHandler.intervalMax=30000 -Daether.connector.http.retryHandler.serviceUnavailable=429,500,502,503,504 -Daether.connector.http.connectionMaxTtl=25 + SCALA_PROFILE: -Dscala-2.13 -Dscala.binary.version=2.13 + COMPOSE_PREFIX: docker-compose_hadoop340_hive2310_spark402 + +jobs: + trino-e2e: + # Testcontainers E2E for the RFC-105 native trino-hudi connector: builds + # hudi-trino at HEAD, assembles the plugin dir via the in-repo shim + # (docker/trino/shim, standing in for the not-yet-released upstream + # trinodb/trino plugin/trino-hudi shim), bakes it into a local + # apachehudi/hudi-trino_481 image, and runs ITTestTrino* against the + # spark402 compose stack (the only pair with the trinocoordinator service). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + docker system prune --all --force --volumes + - name: Pre-pull compose images (fails fast if not published) + run: | + # Surface a missing sparkadhoc image before the long Maven install. The + # remaining stack images are pulled by docker-compose at test time; the + # trino image is built locally below, never pulled. + docker pull apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.0.2:latest + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + architecture: x64 + cache: maven + - name: Build and install Hudi artifacts (JDK 17) + # Full reactor: the compose containers mount the workspace and the tests + # use bundles staged by the -Pintegration-tests build (e.g. + # docker/hoodie/hadoop/hive_base/target/hoodie-spark-bundle.jar). + run: + mvn clean install -T 2 $SCALA_PROFILE -Dspark4.0 -Dflink1.20 -Pintegration-tests -DskipTests=true -Ddocker.compose.skip=true $MVN_ARGS + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + - name: Build hudi-trino connector (JDK 25) + # No trinodb/trino checkout needed: the unpublished Trino test-jars sit + # behind the off-by-default hudi-trino-tests profile and packaging + # resolves entirely from Maven Central. + run: + mvn $MVN_ARGS -Phudi-trino -pl hudi-trino install -Dmaven.test.skip=true + - name: Assemble trino-hudi plugin dir via in-repo shim (JDK 25) + # package, NOT install: installing would shadow the real + # io.trino:trino-hudi release coordinates in the local m2 (the shim pom + # also hard-disables install via maven.install.skip). + # dep.hudi.version is derived from the reactor pom because the shim sits + # outside the reactor: cut_release_branch.sh's `mvn versions:set` cannot + # bump its literal default, and a stale value could resolve silently + # from the actions maven cache instead of failing loudly. + run: | + HUDI_VERSION=$(mvn -q -ntp help:evaluate -Dexpression=project.version -DforceStdout) + echo "Building shim against hudi version: $HUDI_VERSION" + mvn $MVN_ARGS -f docker/trino/shim/pom.xml clean package -DskipTests -Ddep.hudi.version="$HUDI_VERSION" + - name: Build apachehudi/hudi-trino_481 image + run: | + docker/trino/build_image.sh --plugin-dir docker/trino/shim/target/trino-hudi-481 + # Sanity: the shim must have produced a populated plugin dir with a + # service descriptor jar, or Trino cannot load the plugin at boot. + echo "plugin dir jar count: $(ls docker/trino/shim/target/trino-hudi-481 | wc -l)" + ls docker/trino/shim/target/trino-hudi-481/*services*.jar + - name: Smoke-boot the Trino image standalone + # Catches image-level boot failures (plugin load errors, bad etc/ config) + # ~30 min before the IT step would, with the full boot log on screen. + # --hostname trinocoordinator makes the baked discovery.uri self-resolve. + run: | + docker run -d --name trino-smoke --hostname trinocoordinator \ + apachehudi/hudi-trino_481:latest + ok="" + for i in $(seq 1 18); do + if [ "$(docker inspect -f '{{.State.Running}}' trino-smoke)" != "true" ]; then + echo "trino-smoke container died during startup" >&2 + break + fi + if docker exec trino-smoke trino --server localhost:8080 \ + --execute "SELECT 1" >/dev/null 2>&1; then + ok=1; echo "Trino answered SELECT 1 (attempt $i)"; break + fi + sleep 10 + done + if [ -z "$ok" ]; then + echo "==== trino-smoke boot log ====" + docker logs trino-smoke 2>&1 | tail -200 + docker rm -f trino-smoke >/dev/null 2>&1 || true + exit 1 + fi + docker rm -f trino-smoke + - name: Set up JDK 17 (restore for the IT run) + # setup-java resets JAVA_HOME on each call; hudi-integ-test needs 17. + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + architecture: x64 + - name: Run Trino E2E ITs (JDK 17) + run: | + # -DskipITs=false overrides the spark4.0 profile's skipITs=true default + # (see root pom.xml). -Dcompose.profiles=trino starts the profile-gated + # trinocoordinator service; without it every ITTestTrino* class skips. + # redirectTestOutputToFile makes failsafe write per-class *-output.txt + # files, which the on-failure dump step below relies on. + mvn verify $SCALA_PROFILE -Dspark4.0 -Pintegration-tests \ + -pl hudi-integ-test \ + -DskipITs=false \ + -Ddocker.compose.skip=true \ + -Dit.test='ITTestTrino*' \ + -Dcompose.profiles=trino \ + -Dspark.docker.compose.prefix=$COMPOSE_PREFIX \ + -Dmaven.test.redirectTestOutputToFile=true \ + $MVN_ARGS + - name: Dump failsafe test outputs on failure + # The IT step redirects test stdout (incl. the streamed trinocoordinator + # boot/query logs) into per-class output files; print their tails so + # server-side failures are readable straight from the workflow log. + if: failure() + run: | + for f in hudi-integ-test/target/failsafe-reports/*-output.txt; do + [ -f "$f" ] || continue + echo "===== $f (last 400 lines) =====" + tail -n 400 "$f" + done diff --git a/.github/workflows/release_candidate_validation.yml b/.github/workflows/release_candidate_validation.yml index cde2891ee3a2b..ff4605c024751 100644 --- a/.github/workflows/release_candidate_validation.yml +++ b/.github/workflows/release_candidate_validation.yml @@ -77,6 +77,10 @@ jobs: flinkProfile: 'flink1.20' sparkProfile: 'spark4.0' sparkRuntime: 'spark4.0.0' + - scalaProfile: 'scala-2.13' + flinkProfile: 'flink1.20' + sparkProfile: 'spark4.1' + sparkRuntime: 'spark4.1.1' steps: - uses: actions/checkout@v5 - name: Set up JDK 17 @@ -108,6 +112,10 @@ jobs: flinkProfile: 'flink2.0' sparkProfile: 'spark3.5' sparkRuntime: 'spark3.5.1' + - scalaProfile: 'scala-2.12' + flinkProfile: 'flink2.1' + sparkProfile: 'spark3.5' + sparkRuntime: 'spark3.5.1' steps: - uses: actions/checkout@v5 - name: Set up JDK 11 diff --git a/LICENSE b/LICENSE index 301ea869628ba..f0d0759bd1012 100644 --- a/LICENSE +++ b/LICENSE @@ -349,6 +349,18 @@ Copyright (c) 2005, European Commission project OneLab under contract 034819 (ht ------------------------------------------------------------------------------- + This product includes code from the Google Firebase Android SDK + + * org.apache.hudi.common.util.StringUtils#compareUtf8Bytes ported from + com.google.firebase.firestore.util.Util#compareUtf8Strings + + Copyright 2018 Google LLC + + Home page: https://github.com/firebase/firebase-android-sdk + License: https://www.apache.org/licenses/LICENSE-2.0 + + ------------------------------------------------------------------------------- + This product includes code from StreamSets Data Collector * com.streamsets.pipeline.lib.util.avroorc.AvroToOrcRecordConverter copied and modified to org.apache.hudi.common.util.AvroOrcUtils diff --git a/azure-pipelines-20230430.yml b/azure-pipelines-20230430.yml index 10fab9f349924..3a00589a83db7 100644 --- a/azure-pipelines-20230430.yml +++ b/azure-pipelines-20230430.yml @@ -14,7 +14,7 @@ # limitations under the License. # NOTE: -# This config file defines how Azure CI runs tests with Spark 3.5 and Flink 1.18 profiles. +# This config file defines how Azure CI runs tests with Spark 3.5 and Flink 2.1 profiles. # PRs will need to keep in sync with master's version to trigger the CI runs. # See scripts/jacoco/README.md for how aggregated code coverage report works # across multiple modules. @@ -97,11 +97,10 @@ parameters: - '!packaging/hudi-presto-bundle' - '!packaging/hudi-spark-bundle' - '!packaging/hudi-timeline-server-bundle' - - '!packaging/hudi-trino-bundle' - '!packaging/hudi-utilities-slim-bundle' variables: - BUILD_PROFILES: '-Dscala-2.12 -Dspark3.5 -Dflink1.18' + BUILD_PROFILES: '-Dscala-2.12 -Dspark3.5 -Dflink2.1' PLUGIN_OPTS: '-Dcheckstyle.skip=true -Drat.skip=true -ntp -B -V -Pwarn-log -Dorg.slf4j.simpleLogger.log.org.apache.maven.plugins.shade=warn -Dorg.slf4j.simpleLogger.log.org.apache.maven.plugins.dependency=warn' MVN_OPTS_INSTALL: '-T 3 -Phudi-platform-service -DskipTests $(BUILD_PROFILES) $(PLUGIN_OPTS) -Dmaven.wagon.httpconnectionManager.ttlSeconds=25 -Dmaven.wagon.http.retryHandler.count=5 -Dorg.eclipse.jetty.LEVEL=WARN' MVN_OPTS_TEST: '-fae -Pwarn-log $(BUILD_PROFILES) $(PLUGIN_OPTS)' @@ -515,11 +514,17 @@ stages: containerRegistry: 'apachehudi-docker-hub' repository: 'apachehudi/hudi-ci-bundle-validation-base' command: 'run' + # UT_FT_10 builds the full reactor with `clean install`. Under the + # shared MVN_OPTS_INSTALL `-T 3`, concurrent module builds (notably + # parallel maven-shade of large bundles) can spike heap past the 8g + # ceiling and flakily OOM. Prepend `-T 2` below so Maven (first -T + # wins) caps concurrency for THIS job's install only; other jobs + # keep the shared -T 3. arguments: > -v $(Build.SourcesDirectory):/hudi -v /var/run/docker.sock:/var/run/docker.sock -i docker.io/apachehudi/hudi-ci-bundle-validation-base:$(Build.BuildId) - /bin/bash -c "MAVEN_OPTS='-Xmx8g' mvn clean install $(MVN_OPTS_INSTALL) -Phudi-platform-service -Pthrift-gen-source + /bin/bash -c "MAVEN_OPTS='-Xmx8g' mvn clean install -T 2 $(MVN_OPTS_INSTALL) -Phudi-platform-service -Pthrift-gen-source && mvn test $(MVN_OPTS_TEST) -Punit-tests -Dsurefire.failIfNoSpecifiedTests=false $(JACOCO_AGENT_DESTFILE1_ARG) -pl $(JOB10_UT_MODULES) && mvn test $(MVN_OPTS_TEST) -Punit-tests $(JACOCO_AGENT_DESTFILE2_ARG) -Dtest="!TestHoodie*" -Dsurefire.failIfNoSpecifiedTests=false -pl hudi-utilities && mvn test $(MVN_OPTS_TEST) -Pfunctional-tests -Dsurefire.failIfNoSpecifiedTests=false $(JACOCO_AGENT_DESTFILE3_ARG) -pl $(JOB10_FT_MODULES)" diff --git a/doap_HUDI.rdf b/doap_HUDI.rdf index e76272529d32e..89ca27d04c36b 100644 --- a/doap_HUDI.rdf +++ b/doap_HUDI.rdf @@ -223,12 +223,35 @@ 2025-11-17 1.1.0 + + Apache Hudi 1.1.1 2025-12-18 1.1.1 + + + Apache Hudi 0.15.1 + 2026-05-19 + 0.15.1 + + + + + Apache Hudi 1.2.0 + 2026-05-23 + 1.2.0 + + + + + Apache Hudi 0.14.2 + 2026-06-06 + 0.14.2 + + diff --git a/docker/README.md b/docker/README.md index f655f42dca8b5..8892399fb33ff 100644 --- a/docker/README.md +++ b/docker/README.md @@ -25,7 +25,7 @@ docker demo environment. ### Configs for assembling docker images - `/hoodie` The `/hoodie` folder contains all the configs for assembling necessary docker images. The name and repository of each -docker image, e.g., `apachehudi/hudi-hadoop_2.8.4-trinobase_368`, is defined in the maven configuration file `pom.xml`. +docker image, e.g., `apachehudi/hudi-hadoop_2.8.4-prestobase_0.232`, is defined in the maven configuration file `pom.xml`. ### Base images by Java version @@ -39,9 +39,9 @@ docker image, e.g., `apachehudi/hudi-hadoop_2.8.4-trinobase_368`, is defined in The legacy Java 8 `base` module under `/hoodie/hadoop/base` is retained for historical reference only; Spark 2.x is no longer supported and `build_docker_images.sh` never selects it. -Downstream Dockerfiles (`datanode`, `historyserver`, `hive_base`, `namenode`, `prestobase`, `trinobase`) pick the base -via the `BASE_IMAGE_TAG` build arg (default `java11`). `build_docker_images.sh` sets it automatically; bare `docker -build` invocations targeting the Java 17 base must pass `--build-arg BASE_IMAGE_TAG=java17`. +Downstream Dockerfiles (`datanode`, `historyserver`, `hive_base`, `namenode`, `prestobase`) pick the base via the +`BASE_IMAGE_TAG` build arg (default `java11`). `build_docker_images.sh` sets it automatically; bare `docker build` +invocations targeting the Java 17 base must pass `--build-arg BASE_IMAGE_TAG=java17`. ### Docker compose config for the Demo - `/compose` @@ -59,12 +59,43 @@ To build all docker images locally, you can run the script: ./build_local_docker_images.sh ``` +To build the Docker demo images with `docker` directly, rather than through the Maven build like +`build_local_docker_images.sh` above, run the script from under `/docker`: + +```shell +# With no flags, builds Hadoop 2.8.4 / Spark 3.5.3 / Hive 2.3.10, matching +# docker-compose_hadoop284_hive2310_spark353_{amd64,arm64}.yml +./build_docker_images.sh +``` + +You can override the Hadoop, Spark, and Hive versions from the command line. If you plan to use `setup_demo.sh`, +build the image set matching the default compose files first. For other flows, use one of the supported version +combinations under `docker/compose`. + +```shell +# Matches setup_demo.sh and +# docker-compose_hadoop334_hive313_spark353_{amd64,arm64}.yml +./build_docker_images.sh --hadoop-version 3.3.4 --spark-version 3.5.3 --hive-version 3.1.3 + +# Another supported combination is +# docker-compose_hadoop340_hive313_spark401_{amd64,arm64}.yml +./build_docker_images.sh --hadoop-version 3.4.0 --spark-version 4.0.1 --hive-version 3.1.3 +``` + +`setup_demo.sh` currently defaults to `docker-compose_hadoop334_hive313_spark353_{amd64,arm64}.yml`. If you build a +different image set for the demo flow, update `COMPOSE_FILE_NAME` in `setup_demo.sh` to point to the matching compose +file before running the script. Run `./setup_demo.sh dev` to use your locally built images; a plain run pulls the +Docker Hub images over them. + +By default, the script builds images for the current machine architecture and derives the version tag from the root +`pom.xml`. Use `--version-tag` to set an explicit tag if needed. + To build a single image target, you can run ```shell mvn clean pre-integration-test -DskipTests -Ddocker.compose.skip=true -Ddocker.build.skip=false -pl : -am -# For example, to build hudi-hadoop-trinobase-docker -mvn clean pre-integration-test -DskipTests -Ddocker.compose.skip=true -Ddocker.build.skip=false -pl :hudi-hadoop-trinobase-docker -am +# For example, to build hudi-hadoop-prestobase-docker +mvn clean pre-integration-test -DskipTests -Ddocker.compose.skip=true -Ddocker.build.skip=false -pl :hudi-hadoop-prestobase-docker -am ``` Alternatively, you can use `docker` cli directly under `hoodie/hadoop` to build images in a faster way. If you use this @@ -84,8 +115,8 @@ steps in the next section). ```shell # Run under hoodie/hadoop, the is optional, "latest" by default docker build -t /[:] -# For example, to build trinobase -docker build trinobase -t apachehudi/hudi-hadoop_2.8.4-trinobase_368 +# For example, to build prestobase +docker build prestobase -t apachehudi/hudi-hadoop_2.8.4-prestobase_0.232 ``` After new images are built, you can run the following script to bring up docker demo with your local images: @@ -102,7 +133,7 @@ Hud registry designated by its name or tag: ```shell docker push /: # For example -docker push apachehudi/hudi-hadoop_2.8.4-trinobase_368 +docker push apachehudi/hudi-hadoop_2.8.4-prestobase_0.232 ``` You can also easily push the image to the Docker Hub using Docker Desktop app: go to `Images`, search for the image by @@ -120,15 +151,10 @@ Please refer to the [Docker Demo Docs page](https://hudi.apache.org/docs/docker_ ## Building Multi-Arch Images -NOTE: The steps below require some code changes. Support for multi-arch builds in a fully automated manner is being -tracked by [HUDI-3601](https://issues.apache.org/jira/browse/HUDI-3601). +The `build_docker_images.sh` script supports multi-arch image builds through Docker `buildx`. First ensure a `buildx` +builder is set up locally: -By default, the docker images are built for x86_64 (amd64) architecture. Docker `buildx` allows you to build multi-arch -images, link them together with a manifest file, and push them all to a registry – with a single command. Let's say we -want to build for arm64 architecture. First we need to ensure that `buildx` setup is done locally. Please follow the -below steps (referred from https://www.docker.com/blog/multi-arch-images): - -``` +```shell # List builders ~ ❯❯❯ docker buildx ls NAME/NODE DRIVER/ENDPOINT STATUS PLATFORMS @@ -155,63 +181,45 @@ Status: running Platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6 ``` -Now goto `/docker/hoodie/hadoop` and change the `Dockerfile` to pull dependent images corresponding to -arm64. For example, in [base/Dockerfile](./hoodie/hadoop/base/Dockerfile) (which pulls jdk11 image), change the -line `FROM openjdk:11-jdk-slim-bullseye` to `FROM arm64v8/openjdk:11-jdk-slim-bullseye`. +Then run the script from under `/docker`: -Then, from under `/docker/hoodie/hadoop` directory, execute the following command to build as well as -push the image to the dockerhub repo: - -``` -# Run under hoodie/hadoop, the is optional, "latest" by default -docker buildx build --platform -t /[:] --push +```shell +./build_docker_images.sh --multi-arch -# For example, to build the Java 11 base image -docker buildx build base_java11 --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-base-java11:linux-arm64-0.10.1 --push +# Example with explicit component versions +./build_docker_images.sh --hadoop-version 3.4.0 --spark-version 4.0.1 --hive-version 3.1.3 --multi-arch ``` -Note: the base image is now tagged per Java variant (`-base-java11` / `-base-java17`). Downstream Dockerfiles -select the variant via the `BASE_IMAGE_TAG` build arg (default `java11`). If you also need the Java 17 base for -arm64, repeat the build against `base_java17` and tag it as `...-base-java17:`. +When `--multi-arch` is enabled, the script builds and pushes the amd64 and arm64 variants in one pass. Use +`--version-tag ` to override the image tag used for the push. -Once the base image is pushed then you could do something similar for other images. -Change [hive](./hoodie/hadoop/hive_base/Dockerfile) dockerfile to pull the base image with tag corresponding to -linux/arm64 platform. +Note that `--multi-arch` uses `docker buildx build --push` and the image names in the script are hardcoded to the +`apachehudi/...` Docker Hub repositories, so this flow requires push access to those repositories. No Dockerfile +changes are needed for the current amd64 plus arm64 image set in this repository. -``` -# Change below line in the Dockerfile -FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest -# as shown below (pin to the same Java variant you built above, e.g. java11) -FROM --platform=linux/arm64 apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-java11:linux-arm64-0.10.1 - -# and then build & push from under hoodie/hadoop dir -docker buildx build hive_base --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3:linux-arm64-0.10.1 --push -``` +## Trino E2E image - `/trino` -Similarly, for images that are dependent on hive (e.g. [base spark](./hoodie/hadoop/spark_base/Dockerfile) -, [sparkmaster](./hoodie/hadoop/sparkmaster/Dockerfile), [sparkworker](./hoodie/hadoop/sparkworker/Dockerfile) -and [sparkadhoc](./hoodie/hadoop/sparkadhoc/Dockerfile)), change the corresponding Dockerfile to pull the base hive -image with tag corresponding to arm64. Then build and push using `docker buildx` command. +The Trino E2E stack does not use the `hoodie/hadoop` image tree. `docker/trino/` builds +`apachehudi/hudi-trino_` directly on top of the official `trinodb/trino` +image, baking in a locally-assembled native `trino-hudi` plugin directory and the E2E +catalog config (`connector.name=hudi`, metastore at `thrift://hivemetastore:9083`). -For the sake of completeness, here is a [patch](https://gist.github.com/xushiyan/cec16585e884cf0693250631a1d10ec2) which -shows what changes to make in Dockerfiles (assuming tag is named `linux-arm64-0.10.1`), and below is the list -of `docker buildx` commands. +This image is built locally on demand (also by the `hudi_trino_e2e.yml` CI workflow) and +is NOT published to Docker Hub. The plugin directory comes from the in-repo shim project +at `docker/trino/shim/` (see `hudi-trino/README.md` for the full build-and-run flow): ``` -docker buildx build base_java11 --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-base-java11:linux-arm64-0.10.1 --push -docker buildx build datanode --platform linux/arm64 --build-arg BASE_IMAGE_TAG=java11 -t apachehudi/hudi-hadoop_2.8.4-datanode:linux-arm64-0.10.1 --push -docker buildx build historyserver --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-history:linux-arm64-0.10.1 --push -docker buildx build hive_base --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3:linux-arm64-0.10.1 --push -docker buildx build namenode --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-namenode:linux-arm64-0.10.1 --push -docker buildx build prestobase --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-prestobase_0.217:linux-arm64-0.10.1 --push -docker buildx build spark_base --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3-sparkbase_2.4.4:linux-arm64-0.10.1 --push -docker buildx build sparkadhoc --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3-sparkadhoc_2.4.4:linux-arm64-0.10.1 --push -docker buildx build sparkmaster --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3-sparkmaster_2.4.4:linux-arm64-0.10.1 --push -docker buildx build sparkworker --platform linux/arm64 -t apachehudi/hudi-hadoop_2.8.4-hive_2.3.3-sparkworker_2.4.4:linux-arm64-0.10.1 --push +# JDK 25; hudi-trino must already be installed into the local m2 +# dep.hudi.version comes from the reactor pom -- the shim is outside the reactor, so +# cut_release_branch.sh cannot bump the literal default in its own pom. +HUDI_VERSION=$(mvn -q -ntp help:evaluate -Dexpression=project.version -DforceStdout) +mvn -f docker/trino/shim/pom.xml clean package -DskipTests -Ddep.hudi.version="$HUDI_VERSION" +docker/trino/build_image.sh --plugin-dir docker/trino/shim/target/trino-hudi-481 ``` -Once all the required images are pushed to the dockerhub repos, then we need to do one additional change -in [docker compose](./compose/docker-compose_hadoop284_hive233_spark244.yml) file. -Apply [this patch](https://gist.github.com/codope/3dd986de5e54f0650dd74b6032e4456c) to the docker compose file so -that [setup_demo](./setup_demo.sh) pulls images with the correct tag for arm64. And now we should be ready to run the -setup script and follow the docker demo. +The `trinocoordinator` compose service exists only in the +`docker-compose_hadoop340_hive2310_spark402_{amd64,arm64}.yml` pair, behind the `trino` +compose profile, so the default hive-sync flows never start it. For fast plugin +iteration the container supports a bind-mounted overlay: point `TRINO_PLUGIN_DIR` (or +the `-Dtrino.plugin.dir` test property) at a freshly built plugin dir and restart the +container instead of rebuilding the image. diff --git a/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml b/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml index 1d84417b378af..c302a3c95cca8 100644 --- a/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml +++ b/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml @@ -186,78 +186,6 @@ services: - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 - ALLOW_PLAINTEXT_LISTENER=yes - presto-coordinator-1: - container_name: presto-coordinator-1 - hostname: presto-coordinator-1 - image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest - platform: linux/amd64 - ports: - - "8090:8090" - environment: - - PRESTO_JVM_MAX_HEAP=512M - - PRESTO_QUERY_MAX_MEMORY=1GB - - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB - - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB - - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB - - TERM=xterm - links: - - "hivemetastore" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: coordinator - - presto-worker-1: - container_name: presto-worker-1 - hostname: presto-worker-1 - image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest - platform: linux/amd64 - depends_on: [ "presto-coordinator-1" ] - environment: - - PRESTO_JVM_MAX_HEAP=512M - - PRESTO_QUERY_MAX_MEMORY=1GB - - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB - - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB - - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB - - TERM=xterm - links: - - "hivemetastore" - - "hiveserver" - - "hive-metastore-postgresql" - - "namenode" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: worker - - trino-coordinator-1: - container_name: trino-coordinator-1 - hostname: trino-coordinator-1 - image: apachehudi/hudi-hadoop_2.8.4-trinocoordinator_368:latest - platform: linux/amd64 - ports: - - "8091:8091" - links: - - "hivemetastore" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: http://trino-coordinator-1:8091 trino-coordinator-1 - - trino-worker-1: - container_name: trino-worker-1 - hostname: trino-worker-1 - image: apachehudi/hudi-hadoop_2.8.4-trinoworker_368:latest - platform: linux/amd64 - depends_on: [ "trino-coordinator-1" ] - ports: - - "8092:8092" - links: - - "hivemetastore" - - "hiveserver" - - "hive-metastore-postgresql" - - "namenode" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: http://trino-coordinator-1:8091 trino-worker-1 - graphite: container_name: graphite hostname: graphite @@ -287,8 +215,6 @@ services: - "hiveserver" - "hive-metastore-postgresql" - "namenode" - - "presto-coordinator-1" - - "trino-coordinator-1" volumes: - ${HUDI_WS}:/var/hoodie/ws @@ -311,8 +237,6 @@ services: - "hiveserver" - "hive-metastore-postgresql" - "namenode" - - "presto-coordinator-1" - - "trino-coordinator-1" volumes: - ${HUDI_WS}:/var/hoodie/ws diff --git a/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml b/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml index b995859c80222..a9f042155d300 100644 --- a/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml +++ b/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml @@ -179,74 +179,6 @@ services: - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 - ALLOW_PLAINTEXT_LISTENER=yes - presto-coordinator-1: - container_name: presto-coordinator-1 - hostname: presto-coordinator-1 - image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest - ports: - - "8090:8090" - environment: - - PRESTO_JVM_MAX_HEAP=512M - - PRESTO_QUERY_MAX_MEMORY=1GB - - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB - - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB - - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB - - TERM=xterm - links: - - "hivemetastore" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: coordinator - - presto-worker-1: - container_name: presto-worker-1 - hostname: presto-worker-1 - image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest - depends_on: [ "presto-coordinator-1" ] - environment: - - PRESTO_JVM_MAX_HEAP=512M - - PRESTO_QUERY_MAX_MEMORY=1GB - - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB - - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB - - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB - - TERM=xterm - links: - - "hivemetastore" - - "hiveserver" - - "hive-metastore-postgresql" - - "namenode" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: worker - - trino-coordinator-1: - container_name: trino-coordinator-1 - hostname: trino-coordinator-1 - image: apachehudi/hudi-hadoop_2.8.4-trinocoordinator_368:latest - ports: - - "8091:8091" - links: - - "hivemetastore" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: http://trino-coordinator-1:8091 trino-coordinator-1 - - trino-worker-1: - container_name: trino-worker-1 - hostname: trino-worker-1 - image: apachehudi/hudi-hadoop_2.8.4-trinoworker_368:latest - depends_on: [ "trino-coordinator-1" ] - ports: - - "8092:8092" - links: - - "hivemetastore" - - "hiveserver" - - "hive-metastore-postgresql" - - "namenode" - volumes: - - ${HUDI_WS}:/var/hoodie/ws - command: http://trino-coordinator-1:8091 trino-worker-1 - graphite: container_name: graphite hostname: graphite @@ -275,8 +207,6 @@ services: - "hiveserver" - "hive-metastore-postgresql" - "namenode" - - "presto-coordinator-1" - - "trino-coordinator-1" volumes: - ${HUDI_WS}:/var/hoodie/ws @@ -298,8 +228,6 @@ services: - "hiveserver" - "hive-metastore-postgresql" - "namenode" - - "presto-coordinator-1" - - "trino-coordinator-1" volumes: - ${HUDI_WS}:/var/hoodie/ws diff --git a/docker/compose/docker-compose_hadoop340_hive2310_spark402_amd64.yml b/docker/compose/docker-compose_hadoop340_hive2310_spark402_amd64.yml index 0cd441eef2c56..adf964e2288b7 100644 --- a/docker/compose/docker-compose_hadoop340_hive2310_spark402_amd64.yml +++ b/docker/compose/docker-compose_hadoop340_hive2310_spark402_amd64.yml @@ -256,6 +256,29 @@ services: depends_on: - minio + # Gated behind the "trino" compose profile: inert for the default hive-sync CI + # rows, only starts when COMPOSE_PROFILES=trino. The plugin overlay defaults to + # docker/trino/empty-overlay (baked-in plugin used); set TRINO_PLUGIN_DIR to a + # locally-built trino-hudi plugin dir to override it at container start. + trinocoordinator: + image: apachehudi/hudi-trino_481:latest + profiles: ["trino"] + hostname: trinocoordinator + container_name: trinocoordinator + ports: + - "8092:8080" + depends_on: + - "hivemetastore" + - "namenode" + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${TRINO_PLUGIN_DIR:-${HUDI_WS}/docker/trino/empty-overlay}:/opt/hudi-plugin-overlay:ro + - ${HUDI_WS}:/var/hoodie/ws + volumes: namenode: historyserver: diff --git a/docker/compose/docker-compose_hadoop340_hive2310_spark402_arm64.yml b/docker/compose/docker-compose_hadoop340_hive2310_spark402_arm64.yml index edc1a36bd28ac..adf964e2288b7 100644 --- a/docker/compose/docker-compose_hadoop340_hive2310_spark402_arm64.yml +++ b/docker/compose/docker-compose_hadoop340_hive2310_spark402_arm64.yml @@ -78,7 +78,7 @@ services: volumes: - historyserver:/hadoop/yarn/timeline - # Pure Hive 2.3.10 stack (postgres 2.3 schema -> HMS 2.3.10 → HS2 2.3.10). + # Pure Hive 2.3.10 stack (postgres 2.3 schema -> HMS 2.3.10 -> HS2 2.3.10). # Matches hudi-spark-bundle's compile-time Hive 2.3 client, so Hudi hive-sync # talks to HMS natively (no Thrift get_table incompat, no sharedPrefixes hack). # Hadoop 3.4.0 HDFS is backward-compat for the 2.8.4-based Hive client. @@ -256,6 +256,29 @@ services: depends_on: - minio + # Gated behind the "trino" compose profile: inert for the default hive-sync CI + # rows, only starts when COMPOSE_PROFILES=trino. The plugin overlay defaults to + # docker/trino/empty-overlay (baked-in plugin used); set TRINO_PLUGIN_DIR to a + # locally-built trino-hudi plugin dir to override it at container start. + trinocoordinator: + image: apachehudi/hudi-trino_481:latest + profiles: ["trino"] + hostname: trinocoordinator + container_name: trinocoordinator + ports: + - "8092:8080" + depends_on: + - "hivemetastore" + - "namenode" + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${TRINO_PLUGIN_DIR:-${HUDI_WS}/docker/trino/empty-overlay}:/opt/hudi-plugin-overlay:ro + - ${HUDI_WS}:/var/hoodie/ws + volumes: namenode: historyserver: diff --git a/docker/compose/docker-compose_hadoop340_hive2310_spark411_amd64.yml b/docker/compose/docker-compose_hadoop340_hive2310_spark411_amd64.yml new file mode 100644 index 0000000000000..15227b126c6b6 --- /dev/null +++ b/docker/compose/docker-compose_hadoop340_hive2310_spark411_amd64.yml @@ -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. + +services: + + namenode: + image: apachehudi/hudi-hadoop_3.4.0-namenode:latest + hostname: namenode + container_name: namenode + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + ports: + - "8020:8020" # HDFS NameNode IPC + - "9000:9000" # HDFS NameNode Client + - "9870:9870" # HDFS NameNode Web UI + env_file: + - ./hadoop.env + healthcheck: + test: ["CMD", "curl", "-f", "http://namenode:9870"] + interval: 30s + timeout: 10s + retries: 3 + + datanode1: + image: apachehudi/hudi-hadoop_3.4.0-datanode:latest + container_name: datanode1 + hostname: datanode1 + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + env_file: + - ./hadoop.env + ports: + - "50075:50075" + - "9864:9864" + - "50010:50010" + links: + - "namenode" + - "historyserver" + healthcheck: + test: ["CMD", "curl", "-f", "http://datanode1:9864"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - namenode + + historyserver: + image: apachehudi/hudi-hadoop_3.4.0-history:latest + hostname: historyserver + container_name: historyserver + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + depends_on: + - "namenode" + links: + - "namenode" + ports: + - "8188:8188" + healthcheck: + test: ["CMD", "curl", "-f", "http://historyserver:8188"] + interval: 30s + timeout: 10s + retries: 3 + env_file: + - ./hadoop.env + volumes: + - historyserver:/hadoop/yarn/timeline + + # Pure Hive 2.3.10 stack (postgres 2.3 schema -> HMS 2.3.10 -> HS2 2.3.10). + # Matches hudi-spark-bundle's compile-time Hive 2.3 client, so Hudi hive-sync + # talks to HMS natively (no Thrift get_table incompat, no sharedPrefixes hack). + # Hadoop 3.4.0 HDFS is backward-compat for the 2.8.4-based Hive client. + hive-metastore-postgresql: + image: bde2020/hive-metastore-postgresql:2.3.0 + volumes: + - hive-metastore-postgresql:/var/lib/postgresql + hostname: hive-metastore-postgresql + container_name: hive-metastore-postgresql + + hivemetastore: + image: apachehudi/hudi-hadoop_2.8.4-hive_2.3.10:latest + hostname: hivemetastore + container_name: hivemetastore + links: + - "hive-metastore-postgresql" + - "namenode" + env_file: + - ./hadoop.env + command: /opt/hive/bin/hive --service metastore + environment: + - "SERVICE_PRECONDITION=namenode:9870 hive-metastore-postgresql:5432" + ports: + - "9083:9083" + healthcheck: + test: ["CMD", "nc", "-z", "hivemetastore", "9083"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - "hive-metastore-postgresql" + - "namenode" + + hiveserver: + image: apachehudi/hudi-hadoop_2.8.4-hive_2.3.10:latest + hostname: hiveserver + container_name: hiveserver + env_file: + - ./hadoop.env + environment: + - SERVICE_PRECONDITION=hivemetastore:9083 + ports: + - "10000:10000" + depends_on: + - "hivemetastore" + links: + - "hivemetastore" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + zookeeper: + image: 'bitnamilegacy/zookeeper:3.6.4' + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + + kafka: + image: 'bitnamilegacy/kafka:3.4.1' + hostname: kafkabroker + container_name: kafkabroker + ports: + - "9092:9092" + environment: + - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 + - ALLOW_PLAINTEXT_LISTENER=yes + + sparkmaster: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkmaster_4.1.1:latest + hostname: sparkmaster + container_name: sparkmaster + env_file: + - ./hadoop.env + ports: + - "8080:8080" + - "7077:7077" + - "8888:8888" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + - ./notebooks:/opt/workspace/notebooks + environment: + - INIT_DAEMON_STEP=setup_spark + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + + spark-worker-1: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkworker_4.1.1:latest + hostname: spark-worker-1 + container_name: spark-worker-1 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + ports: + - "8081:8081" + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + + adhoc-1: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.1.1:latest + hostname: adhoc-1 + container_name: adhoc-1 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + ports: + - '4040:4040' + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + adhoc-2: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.1.1:latest + hostname: adhoc-2 + container_name: adhoc-2 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + minio: + image: 'minio/minio:latest' + hostname: minio + container_name: minio + ports: + - 9090:9090 # server address + - 9091:9091 # console address + volumes: + - minio-data:/data + environment: + - MINIO_ACCESS_KEY=minio + - MINIO_SECRET_KEY=minio123 + - MINIO_DOMAIN=minio + command: server --address ":9090" --console-address ":9091" /data + + mc: + image: minio/mc + container_name: mc + entrypoint: > + /bin/sh -c " + until (/usr/bin/mc alias set minio http://minio:9090 minio minio123 --api S3v4) 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; + tail -f /dev/null + " + depends_on: + - minio + +volumes: + namenode: + historyserver: + hive-metastore-postgresql: + minio-data: + +networks: + default: + name: hudi diff --git a/docker/compose/docker-compose_hadoop340_hive2310_spark411_arm64.yml b/docker/compose/docker-compose_hadoop340_hive2310_spark411_arm64.yml new file mode 100644 index 0000000000000..15227b126c6b6 --- /dev/null +++ b/docker/compose/docker-compose_hadoop340_hive2310_spark411_arm64.yml @@ -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. + +services: + + namenode: + image: apachehudi/hudi-hadoop_3.4.0-namenode:latest + hostname: namenode + container_name: namenode + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + ports: + - "8020:8020" # HDFS NameNode IPC + - "9000:9000" # HDFS NameNode Client + - "9870:9870" # HDFS NameNode Web UI + env_file: + - ./hadoop.env + healthcheck: + test: ["CMD", "curl", "-f", "http://namenode:9870"] + interval: 30s + timeout: 10s + retries: 3 + + datanode1: + image: apachehudi/hudi-hadoop_3.4.0-datanode:latest + container_name: datanode1 + hostname: datanode1 + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + env_file: + - ./hadoop.env + ports: + - "50075:50075" + - "9864:9864" + - "50010:50010" + links: + - "namenode" + - "historyserver" + healthcheck: + test: ["CMD", "curl", "-f", "http://datanode1:9864"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - namenode + + historyserver: + image: apachehudi/hudi-hadoop_3.4.0-history:latest + hostname: historyserver + container_name: historyserver + environment: + - CLUSTER_NAME=hudi_hadoop340_hive2310_spark411 + depends_on: + - "namenode" + links: + - "namenode" + ports: + - "8188:8188" + healthcheck: + test: ["CMD", "curl", "-f", "http://historyserver:8188"] + interval: 30s + timeout: 10s + retries: 3 + env_file: + - ./hadoop.env + volumes: + - historyserver:/hadoop/yarn/timeline + + # Pure Hive 2.3.10 stack (postgres 2.3 schema -> HMS 2.3.10 -> HS2 2.3.10). + # Matches hudi-spark-bundle's compile-time Hive 2.3 client, so Hudi hive-sync + # talks to HMS natively (no Thrift get_table incompat, no sharedPrefixes hack). + # Hadoop 3.4.0 HDFS is backward-compat for the 2.8.4-based Hive client. + hive-metastore-postgresql: + image: bde2020/hive-metastore-postgresql:2.3.0 + volumes: + - hive-metastore-postgresql:/var/lib/postgresql + hostname: hive-metastore-postgresql + container_name: hive-metastore-postgresql + + hivemetastore: + image: apachehudi/hudi-hadoop_2.8.4-hive_2.3.10:latest + hostname: hivemetastore + container_name: hivemetastore + links: + - "hive-metastore-postgresql" + - "namenode" + env_file: + - ./hadoop.env + command: /opt/hive/bin/hive --service metastore + environment: + - "SERVICE_PRECONDITION=namenode:9870 hive-metastore-postgresql:5432" + ports: + - "9083:9083" + healthcheck: + test: ["CMD", "nc", "-z", "hivemetastore", "9083"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - "hive-metastore-postgresql" + - "namenode" + + hiveserver: + image: apachehudi/hudi-hadoop_2.8.4-hive_2.3.10:latest + hostname: hiveserver + container_name: hiveserver + env_file: + - ./hadoop.env + environment: + - SERVICE_PRECONDITION=hivemetastore:9083 + ports: + - "10000:10000" + depends_on: + - "hivemetastore" + links: + - "hivemetastore" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + zookeeper: + image: 'bitnamilegacy/zookeeper:3.6.4' + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + + kafka: + image: 'bitnamilegacy/kafka:3.4.1' + hostname: kafkabroker + container_name: kafkabroker + ports: + - "9092:9092" + environment: + - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 + - ALLOW_PLAINTEXT_LISTENER=yes + + sparkmaster: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkmaster_4.1.1:latest + hostname: sparkmaster + container_name: sparkmaster + env_file: + - ./hadoop.env + ports: + - "8080:8080" + - "7077:7077" + - "8888:8888" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + - ./notebooks:/opt/workspace/notebooks + environment: + - INIT_DAEMON_STEP=setup_spark + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + + spark-worker-1: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkworker_4.1.1:latest + hostname: spark-worker-1 + container_name: spark-worker-1 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + ports: + - "8081:8081" + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + + adhoc-1: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.1.1:latest + hostname: adhoc-1 + container_name: adhoc-1 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + ports: + - '4040:4040' + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + adhoc-2: + image: apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.1.1:latest + hostname: adhoc-2 + container_name: adhoc-2 + env_file: + - ./hadoop.env + depends_on: + - sparkmaster + environment: + - SPARK_MASTER=spark://sparkmaster:7077 + links: + - "hivemetastore" + - "hiveserver" + - "hive-metastore-postgresql" + - "namenode" + volumes: + - ${HUDI_WS}:/var/hoodie/ws + + minio: + image: 'minio/minio:latest' + hostname: minio + container_name: minio + ports: + - 9090:9090 # server address + - 9091:9091 # console address + volumes: + - minio-data:/data + environment: + - MINIO_ACCESS_KEY=minio + - MINIO_SECRET_KEY=minio123 + - MINIO_DOMAIN=minio + command: server --address ":9090" --console-address ":9091" /data + + mc: + image: minio/mc + container_name: mc + entrypoint: > + /bin/sh -c " + until (/usr/bin/mc alias set minio http://minio:9090 minio minio123 --api S3v4) 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; + tail -f /dev/null + " + depends_on: + - minio + +volumes: + namenode: + historyserver: + hive-metastore-postgresql: + minio-data: + +networks: + default: + name: hudi diff --git a/docker/demo/sparksql-blob-type-df.commands b/docker/demo/sparksql-blob-type-df.commands new file mode 100644 index 0000000000000..28e0663d74e94 --- /dev/null +++ b/docker/demo/sparksql-blob-type-df.commands @@ -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. + */ + +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{Row, SaveMode} + +// BLOB is a struct with fields: type (string), data (binary, nullable), reference (struct, nullable) +val blobMetadata = new MetadataBuilder().putString("hudi_type", "BLOB").build() + +val referenceType = StructType(Seq( + StructField("external_path", StringType, nullable = false), + StructField("offset", LongType, nullable = true), + StructField("length", LongType, nullable = true), + StructField("managed", BooleanType, nullable = false) +)) + +val blobType = StructType(Seq( + StructField("type", StringType, nullable = false), + StructField("data", BinaryType, nullable = true), + StructField("reference", referenceType, nullable = true) +)) + +val schema = StructType(Seq( + StructField("id", LongType, nullable = false), + StructField("name", StringType), + StructField("blob_data", blobType, nullable = false, metadata = blobMetadata), + StructField("dt", StringType) +)) + +// Shared Hive-sync + write options factored out so the upsert and delete writes +// reuse the exact same sync configuration as the initial Overwrite. +// Body is wrapped in { } so the spark-shell REPL keeps the chained .option(...) +// calls attached to the def. Without braces, writer.format("hudi") on the first +// body line parses as a complete expression, the REPL closes the def there, and +// the remaining .option(...) lines get dot-applied to `sc` (SparkContext). +def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[Row]): org.apache.spark.sql.DataFrameWriter[Row] = { + writer.format("hudi") + .option("hoodie.table.name", "blob_test_df") + .option("hoodie.datasource.write.recordkey.field", "id") + .option("hoodie.datasource.write.precombine.field", "name") + .option("hoodie.datasource.write.partitionpath.field", "dt") + .option("hoodie.datasource.hive_sync.enable", "true") + .option("hoodie.datasource.hive_sync.database", "default") + .option("hoodie.datasource.hive_sync.table", "blob_test_df") + .option("hoodie.datasource.hive_sync.jdbcurl", "jdbc:hive2://hiveserver:10000/") + .option("hoodie.datasource.hive_sync.mode", "jdbc") + .option("hoodie.datasource.hive_sync.partition_fields", "dt") + .option("hoodie.datasource.hive_sync.partition_extractor_class", + "org.apache.hudi.hive.MultiPartKeysValueExtractor") + .option("hoodie.datasource.hive_sync.username", "hive") + .option("hoodie.datasource.hive_sync.password", "hive") +} + +// Seed two rows in dt='2024-01-01' via Overwrite. +val seed = Seq( + Row(1L, "file1", Row("INLINE", "hello world".getBytes, null), "2024-01-01"), + Row(2L, "file2", Row("INLINE", "test data".getBytes, null), "2024-01-01") +) +val seedDf = spark.createDataFrame(spark.sparkContext.parallelize(seed), schema) +applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/blob_test_df") +spark.sql("select id, name, blob_data.type, dt from blob_test_df order by id").show(10, false) +println("BLOB_DF_INSERT_SUCCESS") + +// Append-upsert: id=2 value changes, id=3 new in a new partition dt='2024-01-02'. +// precombine=name must strictly increase for the matched row so Hudi keeps the +// new value (alphabetical 'file2-updated' > 'file2'). +val upsertRows = Seq( + Row(2L, "file2-updated", Row("INLINE", "updated payload".getBytes, null), "2024-01-01"), + Row(3L, "file3", Row("INLINE", "brand new".getBytes, null), "2024-01-02") +) +val upsertDf = spark.createDataFrame(spark.sparkContext.parallelize(upsertRows), schema) +applyWriteOpts(upsertDf.write) + .option("hoodie.datasource.write.operation", "upsert") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/blob_test_df") +spark.sql("select id, name, blob_data.type, dt from blob_test_df order by id").show(10, false) +println("BLOB_DF_UPSERT_SUCCESS") + +// Delete id=3 via operation=delete. The DF still needs a schema-compatible BLOB +// column (Hudi uses only the record key/partition for delete, the payload is +// ignored but must deserialize). +val deleteRows = Seq( + Row(3L, "file3", Row("INLINE", "ignored".getBytes, null), "2024-01-02") +) +val deleteDf = spark.createDataFrame(spark.sparkContext.parallelize(deleteRows), schema) +applyWriteOpts(deleteDf.write) + .option("hoodie.datasource.write.operation", "delete") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/blob_test_df") +spark.sql("select id, name, blob_data.type, dt from blob_test_df order by id").show(10, false) +println("BLOB_DF_DELETE_SUCCESS") + +println("BLOB_DF_TEST_SUCCESS") diff --git a/docker/demo/sparksql-blob-type-sql.commands b/docker/demo/sparksql-blob-type-sql.commands new file mode 100644 index 0000000000000..8a03b1d16f305 --- /dev/null +++ b/docker/demo/sparksql-blob-type-sql.commands @@ -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. + */ + +// E2E test: BLOB type with Hive sync via SQL CREATE TABLE path. +// BLOB is parsed by HoodieSpark3_5ExtendedSqlAstBuilder into a struct +// >. +spark.sql(""" + CREATE TABLE blob_test ( + id LONG, + name STRING, + blob_data BLOB, + dt STRING + ) USING hudi + PARTITIONED BY (dt) + LOCATION '/user/hive/warehouse/blob_test' + TBLPROPERTIES ( + 'primaryKey' = 'id', + 'preCombineField' = 'name', + 'hoodie.datasource.hive_sync.enable' = 'true', + 'hoodie.datasource.hive_sync.database' = 'default', + 'hoodie.datasource.hive_sync.table' = 'blob_test', + 'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/', + 'hoodie.datasource.hive_sync.mode' = 'jdbc', + 'hoodie.datasource.hive_sync.partition_fields' = 'dt', + 'hoodie.datasource.hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', + 'hoodie.datasource.hive_sync.username' = 'hive', + 'hoodie.datasource.hive_sync.password' = 'hive' + ) +""") + +// Seed two rows in dt='2024-01-01'. Use OUT_OF_LINE here so subsequent UPDATE/MERGE +// can mutate to a different reference without changing the struct shape between ops +// (avoids mixing INLINE and OUT_OF_LINE branches in a single test). +spark.sql(""" + INSERT INTO blob_test VALUES + (1, 'file1', named_struct( + 'type', 'OUT_OF_LINE', + 'data', cast(null as binary), + 'reference', named_struct( + 'external_path', 'blobs/seed-1', + 'offset', 0L, + 'length', 11L, + 'managed', false)), '2024-01-01'), + (2, 'file2', named_struct( + 'type', 'OUT_OF_LINE', + 'data', cast(null as binary), + 'reference', named_struct( + 'external_path', 'blobs/seed-2', + 'offset', 0L, + 'length', 9L, + 'managed', false)), '2024-01-01') +""") +spark.sql("select id, name, blob_data.type, dt from blob_test").show(10, false) +println("BLOB_SQL_INSERT_SUCCESS") + +// UPDATE exercises the BLOB metadata re-attach on the UPDATE write path. +// Per RFC-100 external_path and managed are non-null. +spark.sql(""" + UPDATE blob_test + SET blob_data = named_struct( + 'type', 'OUT_OF_LINE', + 'data', cast(null as binary), + 'reference', named_struct( + 'external_path', 'blobs/updated-1', + 'offset', 10L, + 'length', 100L, + 'managed', true)), + name = 'file1-updated' + WHERE id = 1 +""") +spark.sql("select id, name, blob_data.reference.external_path from blob_test where id = 1").show(10, false) +println("BLOB_SQL_UPDATE_SUCCESS") + +// MERGE exercises both MATCHED (UPDATE SET) and NOT MATCHED (INSERT of a new +// row into a new partition dt='2024-01-02'). The USING CTE strips the hudi_type +// metadata from the struct column, so this specifically validates the reattach +// path for MERGE. +spark.sql(""" + MERGE INTO blob_test t + USING ( + SELECT 2L AS id, 'file2-merged' AS name, named_struct( + 'type', 'OUT_OF_LINE', + 'data', cast(null as binary), + 'reference', named_struct( + 'external_path', 'blobs/merged-2', + 'offset', 20L, + 'length', 200L, + 'managed', true)) AS blob_data, '2024-01-01' AS dt + UNION ALL + SELECT 3L AS id, 'file3' AS name, named_struct( + 'type', 'OUT_OF_LINE', + 'data', cast(null as binary), + 'reference', named_struct( + 'external_path', 'blobs/inserted-3', + 'offset', 300L, + 'length', 30L, + 'managed', false)) AS blob_data, '2024-01-02' AS dt + ) s + ON t.id = s.id + WHEN MATCHED THEN UPDATE SET t.name = s.name, t.blob_data = s.blob_data + WHEN NOT MATCHED THEN INSERT (id, name, blob_data, dt) VALUES (s.id, s.name, s.blob_data, s.dt) +""") +spark.sql("select id, name, blob_data.reference.external_path, dt from blob_test order by id").show(10, false) +println("BLOB_SQL_MERGE_SUCCESS") + +// DELETE the NOT MATCHED row we just inserted, returning to 2 rows total. +spark.sql("DELETE FROM blob_test WHERE id = 3") +spark.sql("select id, name, blob_data.type, dt from blob_test order by id").show(10, false) +println("BLOB_SQL_DELETE_SUCCESS") + +println("BLOB_SQL_TEST_SUCCESS") diff --git a/docker/demo/sparksql-stock-ticks-trino.commands b/docker/demo/sparksql-stock-ticks-trino.commands new file mode 100644 index 0000000000000..e90d591db617e --- /dev/null +++ b/docker/demo/sparksql-stock-ticks-trino.commands @@ -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. + */ + +// Self-contained COW + MOR stock-ticks seed for the Trino E2E tests +// (ITTestTrinoStockTicks). Mirrors the retired trino-batch1.commands data +// shape without the Kafka/streaming pipeline, which integ2 does not exercise. +// ts stays STRING so Trino's CSV_UNQUOTED output matches the test's exact +// row assertion: GOOG,2018-08-31 10:29:00,6330,1230.5,1230.5 +spark.sql(""" + CREATE TABLE stock_ticks_cow ( + symbol STRING, + ts STRING, + volume LONG, + open DOUBLE, + close DOUBLE, + dt STRING + ) USING hudi + PARTITIONED BY (dt) + LOCATION '/user/hive/warehouse/stock_ticks_cow' + TBLPROPERTIES ( + 'primaryKey' = 'symbol', + 'preCombineField' = 'ts', + 'hoodie.datasource.hive_sync.enable' = 'true', + 'hoodie.datasource.hive_sync.database' = 'default', + 'hoodie.datasource.hive_sync.table' = 'stock_ticks_cow', + 'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/', + 'hoodie.datasource.hive_sync.mode' = 'jdbc', + 'hoodie.datasource.hive_sync.partition_fields' = 'dt', + 'hoodie.datasource.hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', + 'hoodie.datasource.hive_sync.username' = 'hive', + 'hoodie.datasource.hive_sync.password' = 'hive' + ) +""") + +spark.sql("INSERT INTO stock_ticks_cow VALUES ('GOOG', '2018-08-31 10:29:00', 6330, 1230.5, 1230.5, '2018-08-31')") +spark.sql("select symbol, ts, volume, open, close from stock_ticks_cow").show(10, false) +println("STOCK_TICKS_COW_SETUP_SUCCESS") + +// MOR variant: identical schema and seed row. 'type' = 'mor' makes hive sync +// register stock_ticks_mor_ro / stock_ticks_mor_rt; the initial insert writes +// parquet base files, and the follow-up UPDATE below adds a log-only delta so +// the _ro and _rt views actually diverge. +spark.sql(""" + CREATE TABLE stock_ticks_mor ( + symbol STRING, + ts STRING, + volume LONG, + open DOUBLE, + close DOUBLE, + dt STRING + ) USING hudi + PARTITIONED BY (dt) + LOCATION '/user/hive/warehouse/stock_ticks_mor' + TBLPROPERTIES ( + 'type' = 'mor', + 'primaryKey' = 'symbol', + 'preCombineField' = 'ts', + 'hoodie.datasource.hive_sync.enable' = 'true', + 'hoodie.datasource.hive_sync.database' = 'default', + 'hoodie.datasource.hive_sync.table' = 'stock_ticks_mor', + 'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/', + 'hoodie.datasource.hive_sync.mode' = 'jdbc', + 'hoodie.datasource.hive_sync.partition_fields' = 'dt', + 'hoodie.datasource.hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', + 'hoodie.datasource.hive_sync.username' = 'hive', + 'hoodie.datasource.hive_sync.password' = 'hive' + ) +""") + +spark.sql("INSERT INTO stock_ticks_mor VALUES ('GOOG', '2018-08-31 10:29:00', 6330, 1230.5, 1230.5, '2018-08-31')") + +// Log-only delta on the same key: UPDATE routes through upsert, so the existing +// file group gains a log file that _ro must ignore (base row: 10:29:00) and +// _rt must merge (10:59:00). One delta commit stays far below the inline +// compaction threshold, so the log survives for the read-path split to matter. +// open/close use .25/.5 so the double renders exactly in Trino's CSV output. +spark.sql("UPDATE stock_ticks_mor SET ts = '2018-08-31 10:59:00', volume = 9021, open = 1227.25, close = 1227.5 WHERE symbol = 'GOOG'") +spark.sql("select symbol, ts, volume, open, close from stock_ticks_mor").show(10, false) +println("STOCK_TICKS_MOR_SETUP_SUCCESS") + +// Debug aid: proves stock_ticks_mor_ro / stock_ticks_mor_rt got registered. +spark.sql("show tables").show(100, false) +println("STOCK_TICKS_TRINO_SETUP_SUCCESS") diff --git a/docker/demo/sparksql-variant-type-df.commands b/docker/demo/sparksql-variant-type-df.commands new file mode 100644 index 0000000000000..dbbeabdad039c --- /dev/null +++ b/docker/demo/sparksql-variant-type-df.commands @@ -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. + */ + +import org.apache.spark.sql.{DataFrame, SaveMode} + +// E2E test: VARIANT type with Hive sync via DataFrame API path. +// Tests that the HiveSyncTool correctly maps VARIANT to struct in Hive. + +// Body is wrapped in { } so the spark-shell REPL keeps the chained .option(...) +// calls attached to the def. Without braces, writer.format("hudi") on the first +// body line parses as a complete expression, the REPL closes the def there, and +// the remaining .option(...) lines get dot-applied to `sc` (SparkContext). +def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[_]): org.apache.spark.sql.DataFrameWriter[_] = { + writer.format("hudi") + .option("hoodie.table.name", "variant_test_df") + .option("hoodie.datasource.write.recordkey.field", "id") + .option("hoodie.datasource.write.precombine.field", "name") + .option("hoodie.datasource.write.partitionpath.field", "dt") + .option("hoodie.datasource.hive_sync.enable", "true") + .option("hoodie.datasource.hive_sync.database", "default") + .option("hoodie.datasource.hive_sync.table", "variant_test_df") + .option("hoodie.datasource.hive_sync.jdbcurl", "jdbc:hive2://hiveserver:10000/") + .option("hoodie.datasource.hive_sync.mode", "jdbc") + .option("hoodie.datasource.hive_sync.partition_fields", "dt") + .option("hoodie.datasource.hive_sync.partition_extractor_class", + "org.apache.hudi.hive.MultiPartKeysValueExtractor") + .option("hoodie.datasource.hive_sync.username", "hive") + .option("hoodie.datasource.hive_sync.password", "hive") +} + +// Seed two rows in dt='2024-01-01' via Overwrite. +val seedDf: DataFrame = spark.sql(""" + SELECT 1L AS id, 'row1' AS name, parse_json('{"key":"value1"}') AS variant_data, '2024-01-01' AS dt + UNION ALL + SELECT 2L AS id, 'row2' AS name, parse_json('{"key":"value2"}') AS variant_data, '2024-01-01' AS dt +""") +applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/variant_test_df") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test_df order by id").show(10, false) +println("VARIANT_DF_INSERT_SUCCESS") + +// Append-upsert: id=2 mutated, id=3 new in dt='2024-01-02'. +val upsertDf: DataFrame = spark.sql(""" + SELECT 2L AS id, 'row2-updated' AS name, parse_json('{"key":"value2-updated"}') AS variant_data, '2024-01-01' AS dt + UNION ALL + SELECT 3L AS id, 'row3' AS name, parse_json('{"key":"value3"}') AS variant_data, '2024-01-02' AS dt +""") +applyWriteOpts(upsertDf.write) + .option("hoodie.datasource.write.operation", "upsert") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/variant_test_df") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test_df order by id").show(10, false) +println("VARIANT_DF_UPSERT_SUCCESS") + +// Delete id=3 via operation=delete. +val deleteDf: DataFrame = spark.sql(""" + SELECT 3L AS id, 'row3' AS name, parse_json('{}') AS variant_data, '2024-01-02' AS dt +""") +applyWriteOpts(deleteDf.write) + .option("hoodie.datasource.write.operation", "delete") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/variant_test_df") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test_df order by id").show(10, false) +println("VARIANT_DF_DELETE_SUCCESS") + +println("VARIANT_DF_TEST_SUCCESS") diff --git a/docker/demo/sparksql-variant-type-sql.commands b/docker/demo/sparksql-variant-type-sql.commands new file mode 100644 index 0000000000000..c21a62674ef34 --- /dev/null +++ b/docker/demo/sparksql-variant-type-sql.commands @@ -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. + */ + +// E2E test: VARIANT type with Hive sync via SQL CREATE TABLE path. +// Tests that VariantType is mapped to struct in Hive. +spark.sql(""" + CREATE TABLE variant_test ( + id LONG, + name STRING, + variant_data VARIANT, + dt STRING + ) USING hudi + PARTITIONED BY (dt) + LOCATION '/user/hive/warehouse/variant_test' + TBLPROPERTIES ( + 'primaryKey' = 'id', + 'preCombineField' = 'name', + 'hoodie.datasource.hive_sync.enable' = 'true', + 'hoodie.datasource.hive_sync.database' = 'default', + 'hoodie.datasource.hive_sync.table' = 'variant_test', + 'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/', + 'hoodie.datasource.hive_sync.mode' = 'jdbc', + 'hoodie.datasource.hive_sync.partition_fields' = 'dt', + 'hoodie.datasource.hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', + 'hoodie.datasource.hive_sync.username' = 'hive', + 'hoodie.datasource.hive_sync.password' = 'hive' + ) +""") + +spark.sql(""" + INSERT INTO variant_test VALUES + (1, 'row1', parse_json('{"key":"value1"}'), '2024-01-01'), + (2, 'row2', parse_json('{"key":"value2"}'), '2024-01-01') +""") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test order by id").show(10, false) +println("VARIANT_SQL_INSERT_SUCCESS") + +// UPDATE against a VariantType column. Hudi's V1 writer accepts VariantType +// writes on Spark 4 (see fix f395eea4b4a9); UPDATE exercises the same writer +// path with castIfNeeded applied to the assignment. +spark.sql(""" + UPDATE variant_test + SET variant_data = parse_json('{"key":"value1-updated"}'), + name = 'row1-updated' + WHERE id = 1 +""") +spark.sql("select id, name, cast(variant_data as string) from variant_test where id = 1").show(10, false) +println("VARIANT_SQL_UPDATE_SUCCESS") + +// MERGE exercises both MATCHED (UPDATE) and NOT MATCHED (INSERT into new +// partition dt='2024-01-02'). +spark.sql(""" + MERGE INTO variant_test t + USING ( + SELECT 2L AS id, 'row2-merged' AS name, + parse_json('{"key":"value2-merged"}') AS variant_data, + '2024-01-01' AS dt + UNION ALL + SELECT 3L AS id, 'row3' AS name, + parse_json('{"key":"value3"}') AS variant_data, + '2024-01-02' AS dt + ) s + ON t.id = s.id + WHEN MATCHED THEN UPDATE SET t.name = s.name, t.variant_data = s.variant_data + WHEN NOT MATCHED THEN INSERT (id, name, variant_data, dt) VALUES (s.id, s.name, s.variant_data, s.dt) +""") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test order by id").show(10, false) +println("VARIANT_SQL_MERGE_SUCCESS") + +spark.sql("DELETE FROM variant_test WHERE id = 3") +spark.sql("select id, name, cast(variant_data as string), dt from variant_test order by id").show(10, false) +println("VARIANT_SQL_DELETE_SUCCESS") + +println("VARIANT_SQL_TEST_SUCCESS") diff --git a/docker/demo/sparksql-vector-type-df.commands b/docker/demo/sparksql-vector-type-df.commands new file mode 100644 index 0000000000000..ef71771be2953 --- /dev/null +++ b/docker/demo/sparksql-vector-type-df.commands @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{Row, SaveMode} + +// Create schema with VECTOR metadata + partition column +val metadata = new MetadataBuilder().putString("hudi_type", "VECTOR(3)").build() +val schema = StructType(Seq( + StructField("id", LongType, nullable = false), + StructField("name", StringType), + StructField("embedding", ArrayType(FloatType, containsNull = false), nullable = false, metadata = metadata), + StructField("dt", StringType) +)) + +// Body is wrapped in { } so the spark-shell REPL keeps the chained .option(...) +// calls attached to the def. Without braces, writer.format("hudi") on the first +// body line parses as a complete expression, the REPL closes the def there, and +// the remaining .option(...) lines get dot-applied to `sc` (SparkContext). +def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[Row]): org.apache.spark.sql.DataFrameWriter[Row] = { + writer.format("hudi") + .option("hoodie.table.name", "vector_test_df") + .option("hoodie.datasource.write.recordkey.field", "id") + .option("hoodie.datasource.write.precombine.field", "name") + .option("hoodie.datasource.write.partitionpath.field", "dt") + .option("hoodie.datasource.hive_sync.enable", "true") + .option("hoodie.datasource.hive_sync.database", "default") + .option("hoodie.datasource.hive_sync.table", "vector_test_df") + .option("hoodie.datasource.hive_sync.jdbcurl", "jdbc:hive2://hiveserver:10000/") + .option("hoodie.datasource.hive_sync.mode", "jdbc") + .option("hoodie.datasource.hive_sync.partition_fields", "dt") + .option("hoodie.datasource.hive_sync.partition_extractor_class", + "org.apache.hudi.hive.MultiPartKeysValueExtractor") + .option("hoodie.datasource.hive_sync.username", "hive") + .option("hoodie.datasource.hive_sync.password", "hive") +} + +// Seed two rows in dt='2024-01-01' via Overwrite. +val seed = Seq( + Row(1L, "doc1", Seq(0.1f, 0.2f, 0.3f), "2024-01-01"), + Row(2L, "doc2", Seq(0.4f, 0.5f, 0.6f), "2024-01-01") +) +val seedDf = spark.createDataFrame(spark.sparkContext.parallelize(seed), schema) +applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/vector_test_df") +spark.sql("select id, name, embedding, dt from vector_test_df order by id").show(10, false) +println("VECTOR_DF_INSERT_SUCCESS") + +// Append-upsert: id=2 mutated, id=3 new in dt='2024-01-02'. New preCombine +// value (doc2-updated) sorts strictly after the seed (doc2). +val upsertRows = Seq( + Row(2L, "doc2-updated", Seq(0.41f, 0.51f, 0.61f), "2024-01-01"), + Row(3L, "doc3", Seq(0.7f, 0.8f, 0.9f), "2024-01-02") +) +val upsertDf = spark.createDataFrame(spark.sparkContext.parallelize(upsertRows), schema) +applyWriteOpts(upsertDf.write) + .option("hoodie.datasource.write.operation", "upsert") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/vector_test_df") +spark.sql("select id, name, embedding, dt from vector_test_df order by id").show(10, false) +println("VECTOR_DF_UPSERT_SUCCESS") + +// Delete id=3 via operation=delete. The DF still needs a schema-compatible +// VECTOR payload (Hudi uses only the record key/partition for delete). +val deleteRows = Seq( + Row(3L, "doc3", Seq(0.0f, 0.0f, 0.0f), "2024-01-02") +) +val deleteDf = spark.createDataFrame(spark.sparkContext.parallelize(deleteRows), schema) +applyWriteOpts(deleteDf.write) + .option("hoodie.datasource.write.operation", "delete") + .mode(SaveMode.Append) + .save("/user/hive/warehouse/vector_test_df") +spark.sql("select id, name, embedding, dt from vector_test_df order by id").show(10, false) +println("VECTOR_DF_DELETE_SUCCESS") + +println("VECTOR_DF_TEST_SUCCESS") diff --git a/docker/demo/sparksql-vector-type-sql.commands b/docker/demo/sparksql-vector-type-sql.commands new file mode 100644 index 0000000000000..e87736a4be2e2 --- /dev/null +++ b/docker/demo/sparksql-vector-type-sql.commands @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// E2E test: VECTOR type with Hive sync via SQL CREATE TABLE path. +// Runs the full INSERT -> UPDATE -> MERGE -> DELETE lifecycle to exercise +// the custom-type metadata re-attach path on every write command. +spark.sql(""" + CREATE TABLE vector_test ( + id LONG, + name STRING, + embedding VECTOR(3), + dt STRING + ) USING hudi + PARTITIONED BY (dt) + LOCATION '/user/hive/warehouse/vector_test' + TBLPROPERTIES ( + 'primaryKey' = 'id', + 'preCombineField' = 'name', + 'hoodie.datasource.hive_sync.enable' = 'true', + 'hoodie.datasource.hive_sync.database' = 'default', + 'hoodie.datasource.hive_sync.table' = 'vector_test', + 'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/', + 'hoodie.datasource.hive_sync.mode' = 'jdbc', + 'hoodie.datasource.hive_sync.partition_fields' = 'dt', + 'hoodie.datasource.hive_sync.partition_extractor_class' = 'org.apache.hudi.hive.MultiPartKeysValueExtractor', + 'hoodie.datasource.hive_sync.username' = 'hive', + 'hoodie.datasource.hive_sync.password' = 'hive' + ) +""") + +spark.sql(""" + INSERT INTO vector_test VALUES + (1, 'doc1', array(cast(0.1 as float), cast(0.2 as float), cast(0.3 as float)), '2024-01-01'), + (2, 'doc2', array(cast(0.4 as float), cast(0.5 as float), cast(0.6 as float)), '2024-01-01') +""") +spark.sql("select id, name, embedding, dt from vector_test order by id").show(10, false) +println("VECTOR_SQL_INSERT_SUCCESS") + +// UPDATE goes through castIfNeeded on the VECTOR column; without the reattach +// path it would fail schema compat with MISSING_UNION_BRANCH. +spark.sql(""" + UPDATE vector_test + SET embedding = array(cast(0.9 as float), cast(0.8 as float), cast(0.7 as float)), + name = 'doc1-updated' + WHERE id = 1 +""") +spark.sql("select id, name, embedding from vector_test where id = 1").show(10, false) +println("VECTOR_SQL_UPDATE_SUCCESS") + +// MERGE exercises both MATCHED (UPDATE) and NOT MATCHED (INSERT into new +// partition dt='2024-01-02'). The USING CTE does not carry the hudi_type +// marker, so this specifically exercises the reattach path for MERGE. +spark.sql(""" + MERGE INTO vector_test t + USING ( + SELECT 2L AS id, 'doc2-merged' AS name, + array(cast(0.41 as float), cast(0.51 as float), cast(0.61 as float)) AS embedding, + '2024-01-01' AS dt + UNION ALL + SELECT 3L AS id, 'doc3' AS name, + array(cast(0.7 as float), cast(0.8 as float), cast(0.9 as float)) AS embedding, + '2024-01-02' AS dt + ) s + ON t.id = s.id + WHEN MATCHED THEN UPDATE SET t.name = s.name, t.embedding = s.embedding + WHEN NOT MATCHED THEN INSERT (id, name, embedding, dt) VALUES (s.id, s.name, s.embedding, s.dt) +""") +spark.sql("select id, name, embedding, dt from vector_test order by id").show(10, false) +println("VECTOR_SQL_MERGE_SUCCESS") + +spark.sql("DELETE FROM vector_test WHERE id = 3") +spark.sql("select id, name, dt from vector_test order by id").show(10, false) +println("VECTOR_SQL_DELETE_SUCCESS") + +println("VECTOR_SQL_TEST_SUCCESS") diff --git a/docker/hoodie/hadoop/base/Dockerfile b/docker/hoodie/hadoop/base/Dockerfile index 546da57459d28..1a36fc344e36e 100644 --- a/docker/hoodie/hadoop/base/Dockerfile +++ b/docker/hoodie/hadoop/base/Dockerfile @@ -20,12 +20,12 @@ MAINTAINER Hoodie USER root # Default to UTF-8 file.encoding -ENV LANG C.UTF-8 +ENV LANG=C.UTF-8 -ARG HADOOP_VERSION=3.3.4 +ARG HADOOP_VERSION=3.3.4 ARG HADOOP_URL=https://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz -ENV HADOOP_VERSION ${HADOOP_VERSION} -ENV HADOOP_URL ${HADOOP_URL} +ENV HADOOP_VERSION=${HADOOP_VERSION} +ENV HADOOP_URL=${HADOOP_URL} RUN set -x \ && DEBIAN_FRONTEND=noninteractive apt-get -yq update && apt-get -yq install curl wget netcat procps \ @@ -46,7 +46,7 @@ ENV MULTIHOMED_NETWORK=1 ENV HADOOP_HOME=${HADOOP_PREFIX} ENV HADOOP_INSTALL=${HADOOP_HOME} ENV USER=root -ENV PATH /usr/bin:/bin:$HADOOP_PREFIX/bin/:$PATH +ENV PATH=/usr/bin:/bin:$HADOOP_PREFIX/bin/:$PATH # Exposing a union of ports across hadoop versions # Well known ports including ssh diff --git a/docker/hoodie/hadoop/base_java11/Dockerfile b/docker/hoodie/hadoop/base_java11/Dockerfile index 42333067b5698..121f94cd09eaa 100644 --- a/docker/hoodie/hadoop/base_java11/Dockerfile +++ b/docker/hoodie/hadoop/base_java11/Dockerfile @@ -20,12 +20,12 @@ LABEL maintainer="Hoodie" USER root # Default to UTF-8 file.encoding -ENV LANG C.UTF-8 +ENV LANG=C.UTF-8 -ARG HADOOP_VERSION=2.8.4 +ARG HADOOP_VERSION=2.8.4 ARG HADOOP_URL=https://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz -ENV HADOOP_VERSION ${HADOOP_VERSION} -ENV HADOOP_URL ${HADOOP_URL} +ENV HADOOP_VERSION=${HADOOP_VERSION} +ENV HADOOP_URL=${HADOOP_URL} RUN set -x \ && DEBIAN_FRONTEND=noninteractive apt-get -yq update && apt-get -yq install curl wget netcat procps \ @@ -45,7 +45,7 @@ ENV MULTIHOMED_NETWORK=1 ENV HADOOP_HOME=${HADOOP_PREFIX} ENV HADOOP_INSTALL=${HADOOP_HOME} ENV USER=root -ENV PATH /usr/bin:/bin:$HADOOP_PREFIX/bin/:$PATH +ENV PATH=/usr/bin:/bin:$HADOOP_PREFIX/bin/:$PATH # Exposing a union of ports across hadoop versions # Well known ports including ssh diff --git a/docker/hoodie/hadoop/base_java17/Dockerfile b/docker/hoodie/hadoop/base_java17/Dockerfile index 45108610b19e2..6d513fd3eb077 100644 --- a/docker/hoodie/hadoop/base_java17/Dockerfile +++ b/docker/hoodie/hadoop/base_java17/Dockerfile @@ -15,36 +15,47 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM eclipse-temurin:17-jdk +# --- Stage 1: fetch + extract Hadoop (throwaway) --- +FROM eclipse-temurin:17-jre-jammy AS hadoop-builder + +ARG HADOOP_VERSION=3.4.0 +ARG HADOOP_URL=https://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz + +RUN set -x \ + && DEBIAN_FRONTEND=noninteractive apt-get -yq update \ + && apt-get -yq install --no-install-recommends curl ca-certificates \ + && curl -fSL "${HADOOP_URL}" -o /tmp/hadoop.tar.gz \ + && mkdir -p /opt \ + && tar -xzf /tmp/hadoop.tar.gz -C /opt/ \ + && rm /tmp/hadoop.tar.gz \ + && mkdir -p /opt/hadoop-${HADOOP_VERSION}/logs + +# --- Stage 2: runtime --- +FROM eclipse-temurin:17-jre-jammy LABEL maintainer="Hoodie" USER root # Default to UTF-8 file.encoding -ENV LANG C.UTF-8 +ENV LANG=C.UTF-8 ARG HADOOP_VERSION=3.4.0 -ARG HADOOP_URL=https://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz -ENV HADOOP_VERSION ${HADOOP_VERSION} -ENV HADOOP_URL ${HADOOP_URL} +ENV HADOOP_VERSION=${HADOOP_VERSION} -RUN set -x \ - && DEBIAN_FRONTEND=noninteractive apt-get -yq update && apt-get -yq install curl wget netcat-openbsd procps \ - && echo "Fetch URL2 is : ${HADOOP_URL}" \ - && curl -fSL "${HADOOP_URL}" -o /tmp/hadoop.tar.gz \ - && curl -fSL "${HADOOP_URL}.asc" -o /tmp/hadoop.tar.gz.asc \ - && mkdir -p /opt/hadoop-$HADOOP_VERSION/logs \ - && tar -xvf /tmp/hadoop.tar.gz -C /opt/ \ - && rm /tmp/hadoop.tar.gz* \ - && ln -s /opt/hadoop-$HADOOP_VERSION/etc/hadoop /etc/hadoop \ +RUN DEBIAN_FRONTEND=noninteractive apt-get -yq update \ + && apt-get -yq install --no-install-recommends netcat-openbsd procps \ + && rm -rf /var/lib/apt/lists/* \ && mkdir /hadoop-data -ENV HADOOP_PREFIX=/opt/hadoop-$HADOOP_VERSION +COPY --from=hadoop-builder /opt/hadoop-${HADOOP_VERSION} /opt/hadoop-${HADOOP_VERSION} +RUN ln -s /opt/hadoop-${HADOOP_VERSION}/etc/hadoop /etc/hadoop + +ENV HADOOP_PREFIX=/opt/hadoop-${HADOOP_VERSION} ENV HADOOP_CONF_DIR=/etc/hadoop ENV MULTIHOMED_NETWORK=1 ENV HADOOP_HOME=${HADOOP_PREFIX} ENV HADOOP_INSTALL=${HADOOP_HOME} ENV USER=root -ENV PATH /usr/bin:/bin:$HADOOP_PREFIX/bin/:$PATH +ENV PATH=/usr/bin:/bin:${HADOOP_PREFIX}/bin/:$PATH # Exposing a union of ports across hadoop versions # Well known ports including ssh diff --git a/docker/hoodie/hadoop/datanode/Dockerfile b/docker/hoodie/hadoop/datanode/Dockerfile index bc157214f1825..b37a60fd1005e 100644 --- a/docker/hoodie/hadoop/datanode/Dockerfile +++ b/docker/hoodie/hadoop/datanode/Dockerfile @@ -20,7 +20,8 @@ ARG HADOOP_DN_PORT=50075 ARG BASE_IMAGE_TAG=java11 FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest -ENV HADOOP_DN_PORT ${HADOOP_DN_PORT} +ARG HADOOP_DN_PORT +ENV HADOOP_DN_PORT=${HADOOP_DN_PORT} ENV HDFS_CONF_dfs_datanode_data_dir=file:///hadoop/dfs/data RUN mkdir -p /hadoop/dfs/data diff --git a/docker/hoodie/hadoop/historyserver/Dockerfile b/docker/hoodie/hadoop/historyserver/Dockerfile index 0c77188e3e51c..a7700399f89dc 100644 --- a/docker/hoodie/hadoop/historyserver/Dockerfile +++ b/docker/hoodie/hadoop/historyserver/Dockerfile @@ -35,7 +35,8 @@ RUN wget https://repo1.maven.org/maven2/org/openlabtesting/leveldbjni/leveldbjni ENV LD_LIBRARY_PATH="/usr/lib" ENV JAVA_LIBRARY_PATH="/usr/lib" -ENV HADOOP_HISTORY_PORT ${HADOOP_HISTORY_PORT} +ARG HADOOP_HISTORY_PORT +ENV HADOOP_HISTORY_PORT=${HADOOP_HISTORY_PORT} ENV YARN_CONF_yarn_timeline___service_leveldb___timeline___store_path=/hadoop/yarn/timeline RUN mkdir -p /hadoop/yarn/timeline diff --git a/docker/hoodie/hadoop/hive_base/Dockerfile b/docker/hoodie/hadoop/hive_base/Dockerfile index f77c4c4e455ea..b302e9002767c 100644 --- a/docker/hoodie/hadoop/hive_base/Dockerfile +++ b/docker/hoodie/hadoop/hive_base/Dockerfile @@ -19,16 +19,16 @@ ARG HADOOP_VERSION=3.3.4 ARG BASE_IMAGE_TAG=java11 FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest -ENV HIVE_HOME /opt/hive -ENV PATH $HIVE_HOME/bin:$PATH -ENV HADOOP_HOME /opt/hadoop-$HADOOP_VERSION +ENV HIVE_HOME=/opt/hive +ENV PATH=$HIVE_HOME/bin:$PATH +ENV HADOOP_HOME=/opt/hadoop-$HADOOP_VERSION WORKDIR /opt ARG HIVE_VERSION=3.1.3 ARG HIVE_URL=https://archive.apache.org/dist/hive/hive-$HIVE_VERSION/apache-hive-$HIVE_VERSION-bin.tar.gz -ENV HIVE_VERSION ${HIVE_VERSION} -ENV HIVE_URL ${HIVE_URL} +ENV HIVE_VERSION=${HIVE_VERSION} +ENV HIVE_URL=${HIVE_URL} #Install Hive MySQL, PostgreSQL JDBC RUN echo "Hive URL is :${HIVE_URL}" && wget ${HIVE_URL} -O hive.tar.gz && \ @@ -62,9 +62,9 @@ RUN chmod +x /usr/local/bin/startup.sh COPY entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/entrypoint.sh -ENV PATH $HIVE_HOME/bin/:$PATH +ENV PATH=$HIVE_HOME/bin/:$PATH # NOTE: This is the only battle-proven method to inject jars into Hive CLI ENV AUX_CLASSPATH=file://${HUDI_HADOOP_BUNDLE} ENTRYPOINT ["entrypoint.sh"] -CMD startup.sh +CMD ["startup.sh"] diff --git a/docker/hoodie/hadoop/hive_base/entrypoint.sh b/docker/hoodie/hadoop/hive_base/entrypoint.sh index a3df5e6cf4d79..f048114ff45c4 100644 --- a/docker/hoodie/hadoop/hive_base/entrypoint.sh +++ b/docker/hoodie/hadoop/hive_base/entrypoint.sh @@ -131,4 +131,4 @@ do wait_for_it ${i} done -exec $@ +exec "$@" diff --git a/docker/hoodie/hadoop/namenode/Dockerfile b/docker/hoodie/hadoop/namenode/Dockerfile index 33e2ab4b9955c..402d7306297e9 100644 --- a/docker/hoodie/hadoop/namenode/Dockerfile +++ b/docker/hoodie/hadoop/namenode/Dockerfile @@ -20,7 +20,8 @@ ARG HADOOP_WEBHDFS_PORT=50070 ARG BASE_IMAGE_TAG=java11 FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest -ENV HADOOP_WEBHDFS_PORT ${HADOOP_WEBHDFS_PORT} +ARG HADOOP_WEBHDFS_PORT +ENV HADOOP_WEBHDFS_PORT=${HADOOP_WEBHDFS_PORT} ENV HDFS_CONF_dfs_namenode_name_dir=file:///hadoop/dfs/name RUN mkdir -p /hadoop/dfs/name diff --git a/docker/hoodie/hadoop/pom.xml b/docker/hoodie/hadoop/pom.xml index 459818eeb0ae2..3fe8093e99e5f 100644 --- a/docker/hoodie/hadoop/pom.xml +++ b/docker/hoodie/hadoop/pom.xml @@ -39,9 +39,6 @@ sparkworker sparkadhoc prestobase - trinobase - trinocoordinator - trinoworker diff --git a/docker/hoodie/hadoop/prestobase/Dockerfile b/docker/hoodie/hadoop/prestobase/Dockerfile index d40aa9c8f273e..7cc82d5421c43 100644 --- a/docker/hoodie/hadoop/prestobase/Dockerfile +++ b/docker/hoodie/hadoop/prestobase/Dockerfile @@ -25,15 +25,15 @@ FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest as h ARG PRESTO_VERSION=0.271 -ENV PRESTO_VERSION ${PRESTO_VERSION} -ENV PRESTO_HOME /opt/presto-server-${PRESTO_VERSION} -ENV PRESTO_CONF_DIR ${PRESTO_HOME}/etc -ENV PRESTO_LOG_DIR /var/log/presto -ENV PRESTO_JVM_MAX_HEAP 2G -ENV PRESTO_QUERY_MAX_MEMORY 1GB -ENV PRESTO_QUERY_MAX_MEMORY_PER_NODE 512MB -ENV PRESTO_DISCOVERY_URI http://presto-coordinator-1:8090 -ENV PATH $PATH:${PRESTO_HOME}/bin +ENV PRESTO_VERSION=${PRESTO_VERSION} +ENV PRESTO_HOME=/opt/presto-server-${PRESTO_VERSION} +ENV PRESTO_CONF_DIR=${PRESTO_HOME}/etc +ENV PRESTO_LOG_DIR=/var/log/presto +ENV PRESTO_JVM_MAX_HEAP=2G +ENV PRESTO_QUERY_MAX_MEMORY=1GB +ENV PRESTO_QUERY_MAX_MEMORY_PER_NODE=512MB +ENV PRESTO_DISCOVERY_URI=http://presto-coordinator-1:8090 +ENV PATH=$PATH:${PRESTO_HOME}/bin RUN set -x \ && DEBIAN_FRONTEND=noninteractive apt-get -yq update \ @@ -78,7 +78,7 @@ COPY lib/* /usr/local/lib/ RUN chmod +x /usr/local/bin/entrypoint.sh ADD target/ /var/hoodie/ws/docker/hoodie/hadoop/prestobase/target/ -ENV HUDI_PRESTO_BUNDLE /var/hoodie/ws/docker/hoodie/hadoop/prestobase/target/hudi-presto-bundle.jar +ENV HUDI_PRESTO_BUNDLE=/var/hoodie/ws/docker/hoodie/hadoop/prestobase/target/hudi-presto-bundle.jar RUN cp ${HUDI_PRESTO_BUNDLE} ${PRESTO_HOME}/plugin/hive-hadoop2/ # TODO: the latest master of Presto relies on hudi-presto-bundle, while current Presto releases # rely on hudi-common and hudi-hadoop-mr 0.9.0, which are pulled in plugin/hive-hadoop2/ in the diff --git a/docker/hoodie/hadoop/spark_base/Dockerfile b/docker/hoodie/hadoop/spark_base/Dockerfile index 68bfbaae76d83..4a064971536e6 100644 --- a/docker/hoodie/hadoop/spark_base/Dockerfile +++ b/docker/hoodie/hadoop/spark_base/Dockerfile @@ -19,15 +19,15 @@ ARG HADOOP_VERSION=3.3.4 ARG HIVE_VERSION=3.1.3 FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-hive_${HIVE_VERSION} -ENV ENABLE_INIT_DAEMON true -ENV INIT_DAEMON_BASE_URI http://identifier/init-daemon -ENV INIT_DAEMON_STEP spark_master_init +ENV ENABLE_INIT_DAEMON=true +ENV INIT_DAEMON_BASE_URI=http://identifier/init-daemon +ENV INIT_DAEMON_STEP=spark_master_init ARG SPARK_VERSION=3.5.3 ARG SPARK_HADOOP_VERSION=3 -ENV SPARK_VERSION ${SPARK_VERSION} -ENV HADOOP_VERSION ${SPARK_HADOOP_VERSION} +ENV SPARK_VERSION=${SPARK_VERSION} +ENV HADOOP_VERSION=${SPARK_HADOOP_VERSION} COPY wait-for-step.sh / COPY execute-step.sh / @@ -41,23 +41,10 @@ RUN echo "Installing Spark-version (${SPARK_VERSION})" \ && rm spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz \ && cd / -# Install build dependencies -RUN apt-get update && apt-get install -y \ - wget build-essential libncursesw5-dev \ - libssl-dev libgdbm-dev libreadline-dev libbz2-dev \ - libsqlite3-dev libffi-dev zlib1g-dev curl \ - && cd /usr/src \ - && wget https://www.python.org/ftp/python/3.10.14/Python-3.10.14.tgz \ - && tar xzf Python-3.10.14.tgz \ - && cd Python-3.10.14 \ - && ./configure --enable-optimizations \ - && make -j"$(nproc)" \ - && make altinstall \ - && ln -sf /usr/local/bin/python3.10 /usr/bin/python \ - && ln -sf /usr/local/bin/python3.10 /usr/bin/python3 \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python \ - && pip install --upgrade pip \ - && cd / && rm -rf /usr/src/Python-3.10.14* \ +# Install Python runtime from distro package (avoids ~400MB build toolchain) +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3-minimal python3-pip \ + && ln -sf /usr/bin/python3 /usr/bin/python \ && rm -rf /var/lib/apt/lists/* #Give permission to execute scripts @@ -65,16 +52,16 @@ RUN chmod +x /wait-for-step.sh && chmod +x /execute-step.sh && chmod +x /finish- # Fix the value of PYTHONHASHSEED # Note: this is needed when you use Python 3.3 or greater -ENV PYTHONHASHSEED 1 +ENV PYTHONHASHSEED=1 -ENV SPARK_HOME /opt/spark -ENV SPARK_INSTALL ${SPARK_HOME} -ENV SPARK_CONF_DIR ${SPARK_HOME}/conf -ENV PATH $SPARK_INSTALL/bin:$PATH +ENV SPARK_HOME=/opt/spark +ENV SPARK_INSTALL=${SPARK_HOME} +ENV SPARK_CONF_DIR=${SPARK_HOME}/conf +ENV PATH=$SPARK_INSTALL/bin:$PATH -ENV SPARK_DRIVER_PORT 5001 -ENV SPARK_UI_PORT 5002 -ENV SPARK_BLOCKMGR_PORT 5003 +ENV SPARK_DRIVER_PORT=5001 +ENV SPARK_UI_PORT=5002 +ENV SPARK_BLOCKMGR_PORT=5003 EXPOSE $SPARK_DRIVER_PORT $SPARK_UI_PORT $SPARK_BLOCKMGR_PORT diff --git a/docker/hoodie/hadoop/sparkadhoc/Dockerfile b/docker/hoodie/hadoop/sparkadhoc/Dockerfile index 70105bf3cf362..7c8e900fca539 100644 --- a/docker/hoodie/hadoop/sparkadhoc/Dockerfile +++ b/docker/hoodie/hadoop/sparkadhoc/Dockerfile @@ -24,11 +24,11 @@ ARG PRESTO_VERSION=0.268 ARG TRINO_VERSION=368 COPY adhoc.sh /opt/spark -ENV SPARK_WORKER_WEBUI_PORT 8081 -ENV SPARK_WORKER_LOG /spark/logs -ENV SPARK_MASTER "spark://spark-master:7077" -ENV PRESTO_VERSION ${PRESTO_VERSION} -ENV TRINO_VERSION ${TRINO_VERSION} +ENV SPARK_WORKER_WEBUI_PORT=8081 +ENV SPARK_WORKER_LOG=/spark/logs +ENV SPARK_MASTER="spark://spark-master:7077" +ENV PRESTO_VERSION=${PRESTO_VERSION} +ENV TRINO_VERSION=${TRINO_VERSION} ENV BASE_URL=https://repo1.maven.org/maven2 ENV SPARK_BUNDLE_JAR=/var/hoodie/ws/docker/hoodie/hadoop/hive_base/target/hoodie-spark-bundle.jar diff --git a/docker/hoodie/hadoop/sparkadhoc/adhoc.sh b/docker/hoodie/hadoop/sparkadhoc/adhoc.sh index 86fbbf4e775a0..d2529e9ce165d 100644 --- a/docker/hoodie/hadoop/sparkadhoc/adhoc.sh +++ b/docker/hoodie/hadoop/sparkadhoc/adhoc.sh @@ -23,12 +23,10 @@ export SPARK_HOME=/opt/spark export PRESTO_CLI_CMD="/usr/local/bin/presto --server presto-coordinator-1:8090" -export TRINO_CLI_CMD="/usr/local/bin/trino --server trino-coordinator-1:8091" date echo "SPARK HOME is : $SPARK_HOME" echo "PRESTO CLI CMD is : $PRESTO_CLI_CMD" -echo "TRINO CLI CMD is : $TRINO_CLI_CMD" tail -f /dev/null diff --git a/docker/hoodie/hadoop/sparkmaster/Dockerfile b/docker/hoodie/hadoop/sparkmaster/Dockerfile index 94898dcdcfb0a..9acaf027606dd 100644 --- a/docker/hoodie/hadoop/sparkmaster/Dockerfile +++ b/docker/hoodie/hadoop/sparkmaster/Dockerfile @@ -22,9 +22,9 @@ FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-hive_${HIVE_VERSION}-sparkbase_${S COPY master.sh /opt/spark -ENV SPARK_MASTER_PORT 7077 -ENV SPARK_MASTER_WEBUI_PORT 8080 -ENV SPARK_MASTER_LOG /opt/spark/logs +ENV SPARK_MASTER_PORT=7077 +ENV SPARK_MASTER_WEBUI_PORT=8080 +ENV SPARK_MASTER_LOG=/opt/spark/logs EXPOSE 8080 7077 6066 diff --git a/docker/hoodie/hadoop/sparkmaster/master.sh b/docker/hoodie/hadoop/sparkmaster/master.sh index 9409cbc0c12e0..e437f68ddc005 100644 --- a/docker/hoodie/hadoop/sparkmaster/master.sh +++ b/docker/hoodie/hadoop/sparkmaster/master.sh @@ -29,4 +29,4 @@ export SPARK_HOME=/opt/spark ln -sf /dev/stdout $SPARK_MASTER_LOG/spark-master.out cd /opt/spark/bin && /opt/spark/sbin/../bin/spark-class org.apache.spark.deploy.master.Master \ - --ip $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT >> $SPARK_MASTER_LOG/spark-master.out + --host $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT >> $SPARK_MASTER_LOG/spark-master.out diff --git a/docker/hoodie/hadoop/sparkworker/Dockerfile b/docker/hoodie/hadoop/sparkworker/Dockerfile index c8515c735b4ca..5b03834067ec2 100644 --- a/docker/hoodie/hadoop/sparkworker/Dockerfile +++ b/docker/hoodie/hadoop/sparkworker/Dockerfile @@ -22,9 +22,9 @@ FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-hive_${HIVE_VERSION}-sparkbase_${S COPY worker.sh /opt/spark -ENV SPARK_WORKER_WEBUI_PORT 8081 -ENV SPARK_WORKER_LOG /spark/logs -ENV SPARK_MASTER "spark://spark-master:7077" +ENV SPARK_WORKER_WEBUI_PORT=8081 +ENV SPARK_WORKER_LOG=/spark/logs +ENV SPARK_MASTER="spark://spark-master:7077" EXPOSE 8081 diff --git a/docker/hoodie/hadoop/trinobase/Dockerfile b/docker/hoodie/hadoop/trinobase/Dockerfile deleted file mode 100644 index 0700fa2f6bfb5..0000000000000 --- a/docker/hoodie/hadoop/trinobase/Dockerfile +++ /dev/null @@ -1,67 +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. -# -# Trino docker setup is adapted from https://github.com/Lewuathe/docker-trino-cluster - -ARG HADOOP_VERSION=2.8.4 -ARG HIVE_VERSION=2.3.3 -ARG BASE_IMAGE_TAG=java11 -FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-base-${BASE_IMAGE_TAG}:latest as hadoop-base - -ENV TRINO_VERSION=368 -ENV TRINO_HOME=/usr/local/trino -ENV BASE_URL=https://repo1.maven.org/maven2 - -RUN apt-get update -RUN apt-get install -y \ - curl \ - tar \ - sudo \ - rsync \ - python \ - wget \ - python3-pip \ - python-dev \ - build-essential \ - uuid-runtime \ - less - -ENV JAVA_HOME /usr/java/default -ENV PATH $PATH:$JAVA_HOME/bin - -WORKDIR /usr/local/bin -RUN wget -q ${BASE_URL}/io/trino/trino-cli/${TRINO_VERSION}/trino-cli-${TRINO_VERSION}-executable.jar -RUN chmod +x trino-cli-${TRINO_VERSION}-executable.jar -RUN mv trino-cli-${TRINO_VERSION}-executable.jar trino-cli - -WORKDIR /usr/local -RUN wget -q ${BASE_URL}/io/trino/trino-server/${TRINO_VERSION}/trino-server-${TRINO_VERSION}.tar.gz -RUN tar xvzf trino-server-${TRINO_VERSION}.tar.gz -C /usr/local/ -RUN ln -s /usr/local/trino-server-${TRINO_VERSION} $TRINO_HOME - -ENV TRINO_BASE_WS /var/hoodie/ws/docker/hoodie/hadoop/trinobase -RUN mkdir -p ${TRINO_BASE_WS}/target/ -ADD target/ ${TRINO_BASE_WS}/target/ -ENV HUDI_TRINO_BUNDLE ${TRINO_BASE_WS}/target/hudi-trino-bundle.jar -RUN cp ${HUDI_TRINO_BUNDLE} ${TRINO_HOME}/plugin/hive/ - -ADD scripts ${TRINO_HOME}/scripts -RUN chmod +x ${TRINO_HOME}/scripts/trino.sh - -RUN mkdir -p $TRINO_HOME/data -VOLUME ["$TRINO_HOME/data"] diff --git a/docker/hoodie/hadoop/trinobase/pom.xml b/docker/hoodie/hadoop/trinobase/pom.xml deleted file mode 100644 index 595d272e54588..0000000000000 --- a/docker/hoodie/hadoop/trinobase/pom.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - hudi-hadoop-docker - org.apache.hudi - 1.2.0 - - 4.0.0 - pom - hudi-hadoop-trinobase-docker - Trino Base Docker Image with Hudi - - - UTF-8 - true - ${project.parent.parent.basedir} - - - - - - org.apache.hudi - hudi-hadoop-base-java11-docker - ${project.version} - pom - import - - - - - - - - org.apache.maven.plugins - maven-antrun-plugin - 1.7 - - - package - - - - - - - run - - - - - - - com.spotify - dockerfile-maven-plugin - ${dockerfile.maven.version} - - - tag-latest - pre-integration-test - - build - tag - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinobase_${docker.trino.version} - - true - latest - - - - tag-version - pre-integration-test - - build - tag - - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinobase_${docker.trino.version} - - true - ${project.version} - - - - - - - diff --git a/docker/hoodie/hadoop/trinobase/scripts/trino.sh b/docker/hoodie/hadoop/trinobase/scripts/trino.sh deleted file mode 100644 index 4efaed0cd8d31..0000000000000 --- a/docker/hoodie/hadoop/trinobase/scripts/trino.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -# Copy the trino bundle at run time so that locally built bundle overrides the one that is present in the image -echo "Copying trino bundle to ${TRINO_HOME}/plugin/hive/" -cp ${HUDI_TRINO_BUNDLE} ${TRINO_HOME}/plugin/hive/ - -/usr/local/trino/bin/launcher run diff --git a/docker/hoodie/hadoop/trinocoordinator/Dockerfile b/docker/hoodie/hadoop/trinocoordinator/Dockerfile deleted file mode 100644 index 67a31448d7a65..0000000000000 --- a/docker/hoodie/hadoop/trinocoordinator/Dockerfile +++ /dev/null @@ -1,29 +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. -# -# Trino docker setup is adapted from https://github.com/Lewuathe/docker-trino-cluster - -ARG HADOOP_VERSION=2.8.4 -ARG TRINO_VERSION=368 -FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-trinobase_${TRINO_VERSION}:latest as trino-base - -ADD etc /usr/local/trino/etc -EXPOSE 8091 - -WORKDIR /usr/local/trino -ENTRYPOINT [ "./scripts/trino.sh" ] diff --git a/docker/hoodie/hadoop/trinocoordinator/etc/catalog/hive.properties b/docker/hoodie/hadoop/trinocoordinator/etc/catalog/hive.properties deleted file mode 100644 index ed7fce1b3e640..0000000000000 --- a/docker/hoodie/hadoop/trinocoordinator/etc/catalog/hive.properties +++ /dev/null @@ -1,22 +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. -# -connector.name=hive -hive.metastore.uri=thrift://hivemetastore:9083 -hive.config.resources=/etc/hadoop/core-site.xml,/etc/hadoop/hdfs-site.xml -hive.hdfs.authentication.type=NONE diff --git a/docker/hoodie/hadoop/trinocoordinator/etc/node.properties b/docker/hoodie/hadoop/trinocoordinator/etc/node.properties deleted file mode 100644 index d97d547485998..0000000000000 --- a/docker/hoodie/hadoop/trinocoordinator/etc/node.properties +++ /dev/null @@ -1,21 +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. -# -node.environment=development -node.id=3044b958-f077-4fce-87ed-ca8308f800b6 -node.data-dir=/usr/local/trino/data diff --git a/docker/hoodie/hadoop/trinocoordinator/pom.xml b/docker/hoodie/hadoop/trinocoordinator/pom.xml deleted file mode 100644 index 90e250ad0f461..0000000000000 --- a/docker/hoodie/hadoop/trinocoordinator/pom.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - hudi-hadoop-docker - org.apache.hudi - 1.2.0 - - 4.0.0 - pom - hudi-hadoop-trinocoordinator-docker - Trino Coordinator Docker Image with Hudi - - - UTF-8 - true - ${project.parent.parent.basedir} - - - - - - org.apache.hudi - hudi-hadoop-trinobase-docker - ${project.version} - pom - - - - - - - - - com.spotify - dockerfile-maven-plugin - ${dockerfile.maven.version} - - - tag-latest - pre-integration-test - - build - tag - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinocoordinator_${docker.trino.version} - - true - latest - - - - tag-version - pre-integration-test - - build - tag - - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinocoordinator_${docker.trino.version} - - true - ${project.version} - - - - - - - diff --git a/docker/hoodie/hadoop/trinoworker/Dockerfile b/docker/hoodie/hadoop/trinoworker/Dockerfile deleted file mode 100644 index ae5b2766dc9d9..0000000000000 --- a/docker/hoodie/hadoop/trinoworker/Dockerfile +++ /dev/null @@ -1,29 +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. -# -# Trino docker setup is adapted from https://github.com/Lewuathe/docker-trino-cluster - -ARG HADOOP_VERSION=2.8.4 -ARG TRINO_VERSION=368 -FROM apachehudi/hudi-hadoop_${HADOOP_VERSION}-trinobase_${TRINO_VERSION}:latest as trino-base - -ADD etc /usr/local/trino/etc -EXPOSE 8092 - -WORKDIR /usr/local/trino -ENTRYPOINT [ "./scripts/trino.sh" ] diff --git a/docker/hoodie/hadoop/trinoworker/etc/config.properties b/docker/hoodie/hadoop/trinoworker/etc/config.properties deleted file mode 100644 index 0e15d3d7c1e9c..0000000000000 --- a/docker/hoodie/hadoop/trinoworker/etc/config.properties +++ /dev/null @@ -1,24 +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. -# -coordinator=false -http-server.http.port=8091 -query.max-memory=50GB -query.max-memory-per-node=1GB -query.max-total-memory-per-node=2GB -discovery.uri=http://trino-coordinator-1:8091 diff --git a/docker/hoodie/hadoop/trinoworker/etc/log.properties b/docker/hoodie/hadoop/trinoworker/etc/log.properties deleted file mode 100644 index 23b063080b4fe..0000000000000 --- a/docker/hoodie/hadoop/trinoworker/etc/log.properties +++ /dev/null @@ -1,19 +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. -# -io.trinosql=INFO diff --git a/docker/hoodie/hadoop/trinoworker/pom.xml b/docker/hoodie/hadoop/trinoworker/pom.xml deleted file mode 100644 index ab3066322407f..0000000000000 --- a/docker/hoodie/hadoop/trinoworker/pom.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - hudi-hadoop-docker - org.apache.hudi - 1.2.0 - - 4.0.0 - pom - hudi-hadoop-trinoworker-docker - Trino Worker Docker Image with Hudi - - - UTF-8 - true - ${project.parent.parent.basedir} - - - - - - org.apache.hudi - hudi-hadoop-trinobase-docker - ${project.version} - pom - - - - - - - - - com.spotify - dockerfile-maven-plugin - ${dockerfile.maven.version} - - - tag-latest - pre-integration-test - - build - tag - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinoworker_${docker.trino.version} - - true - latest - - - - tag-version - pre-integration-test - - build - tag - - - - ${docker.build.skip} - false - - apachehudi/hudi-hadoop_${docker.hadoop.version}-trinoworker_${docker.trino.version} - - true - ${project.version} - - - - - - - diff --git a/docker/hoodie/hadoop/trinocoordinator/etc/log.properties b/docker/trino/.dockerignore similarity index 84% rename from docker/hoodie/hadoop/trinocoordinator/etc/log.properties rename to docker/trino/.dockerignore index 23b063080b4fe..3471b8973e1fd 100644 --- a/docker/hoodie/hadoop/trinocoordinator/etc/log.properties +++ b/docker/trino/.dockerignore @@ -16,4 +16,6 @@ # specific language governing permissions and limitations # under the License. # -io.trinosql=INFO +# Keep the shim build tree out of the docker build context; build_image.sh +# stages the one plugin dir the Dockerfile needs into plugin/. +shim/ diff --git a/docker/trino/.gitignore b/docker/trino/.gitignore new file mode 100644 index 0000000000000..939686302a858 --- /dev/null +++ b/docker/trino/.gitignore @@ -0,0 +1,3 @@ +# Transient staging dir populated by build_image.sh (leading slash: must not +# swallow the shim's io/trino/plugin/ source package under shim/). +/plugin/ diff --git a/docker/trino/Dockerfile b/docker/trino/Dockerfile new file mode 100644 index 0000000000000..3189368a2283d --- /dev/null +++ b/docker/trino/Dockerfile @@ -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. + +ARG TRINO_VERSION=481 +FROM trinodb/trino:${TRINO_VERSION} + +USER root + +# Replace the bundled hudi plugin with the locally-built trino-hudi plugin +# staged into the build context at plugin/ (see build_image.sh). +# +# The staged plugin dir carries only the connector's top-level runtime jars. +# fs.hadoop.enabled=true additionally needs the isolated HDFS loader jar set at +# /hdfs (io.trino.filesystem.manager.HdfsFileSystemLoader). That jar set +# is only distributed inside the trino-server tarball / base image +# (io.trino:trino-hdfs:zip is not on Maven Central), so preserve the stock hudi +# plugin's version-matched copy before replacing it, and re-attach it when the +# staged plugin dir lacks one. /opt/hudi-hdfs-lib stays in the image so the +# overlay entrypoint can do the same for bind-mounted plugin overlays. +RUN cp -r /usr/lib/trino/plugin/hudi/hdfs /opt/hudi-hdfs-lib \ + && rm -rf /usr/lib/trino/plugin/hudi +COPY --chown=trino:trino plugin/ /usr/lib/trino/plugin/hudi/ +RUN if [ ! -d /usr/lib/trino/plugin/hudi/hdfs ]; then \ + cp -r /opt/hudi-hdfs-lib /usr/lib/trino/plugin/hudi/hdfs; \ + fi \ + && chown -R trino:trino /usr/lib/trino/plugin/hudi /opt/hudi-hdfs-lib + +# Bake the Hudi E2E Trino config (coordinator, catalog, hadoop-conf) into the image. +COPY --chown=trino:trino etc/ /etc/trino/ + +# Overlay-aware entrypoint: a bind-mounted plugin overlay (if present) replaces +# the baked-in plugin at container start, otherwise the baked-in plugin is used. +COPY --chown=trino:trino overlay-entrypoint.sh /opt/overlay-entrypoint.sh +RUN chmod +x /opt/overlay-entrypoint.sh + +USER trino +ENTRYPOINT ["/opt/overlay-entrypoint.sh"] diff --git a/docker/trino/build_image.sh b/docker/trino/build_image.sh new file mode 100755 index 0000000000000..da1cc8fe2ef13 --- /dev/null +++ b/docker/trino/build_image.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Builds the apachehudi/hudi-trino_ image with a locally-built +# trino-hudi plugin baked in. The plugin dir (typically the in-repo shim's +# docker/trino/shim/target/trino-hudi-, see docker/trino/shim/pom.xml) is +# staged into the build context at docker/trino/plugin/ (gitignored), then +# baked into the image. +# Usage: ./build_image.sh --plugin-dir [--trino-version ] [--image-tag ] +# Typical: ./build_image.sh --plugin-dir "$(dirname "$0")/shim/target/trino-hudi-481" +# Note: --trino-version must match the shim pom's parent version and the root +# pom's trino.version property. + +set -e + +# Default values +PLUGIN_DIR="" +TRINO_VERSION="481" +IMAGE_TAG="latest" + +# Parse command-line arguments +while [[ "$#" -gt 0 ]]; do + case $1 in + --plugin-dir) PLUGIN_DIR="$2"; shift ;; + --trino-version) TRINO_VERSION="$2"; shift ;; + --image-tag) IMAGE_TAG="$2"; shift ;; + *) echo "Unknown parameter passed: $1"; exit 1 ;; + esac + shift +done + +# Directory of this script, so the build context path is stable regardless of cwd +SCRIPT_DIR=$(cd $(dirname "$0") && pwd) + +# Validate --plugin-dir: required, must exist and be non-empty +if [ -z "$PLUGIN_DIR" ]; then + echo "Error: --plugin-dir is required (the locally-built trino-hudi plugin directory)." >&2 + exit 1 +fi +if [ ! -d "$PLUGIN_DIR" ]; then + echo "Error: plugin dir '$PLUGIN_DIR' does not exist." >&2 + exit 1 +fi +if [ -z "$(ls -A "$PLUGIN_DIR" 2>/dev/null)" ]; then + echo "Error: plugin dir '$PLUGIN_DIR' is empty." >&2 + exit 1 +fi + +# Stage the plugin into the build context (plugin/ must be IN the context to be COPY-able) +STAGE_DIR="$SCRIPT_DIR/plugin" +echo "Staging plugin from '$PLUGIN_DIR' into '$STAGE_DIR'" +rm -rf "$STAGE_DIR" +cp -r "$PLUGIN_DIR" "$STAGE_DIR" + +IMAGE="apachehudi/hudi-trino_${TRINO_VERSION}:${IMAGE_TAG}" +echo "Building $IMAGE (TRINO_VERSION=${TRINO_VERSION})" +docker build --build-arg TRINO_VERSION="${TRINO_VERSION}" -t "$IMAGE" "$SCRIPT_DIR" + +# Clean up the staged plugin dir +echo "Cleaning up staged plugin dir '$STAGE_DIR'" +rm -rf "$STAGE_DIR" + +echo "Done: $IMAGE" diff --git a/docker/hoodie/hadoop/trinocoordinator/etc/jvm.config b/docker/trino/empty-overlay/.gitkeep similarity index 78% rename from docker/hoodie/hadoop/trinocoordinator/etc/jvm.config rename to docker/trino/empty-overlay/.gitkeep index fb17203ca211b..9e386d0cdd886 100644 --- a/docker/hoodie/hadoop/trinocoordinator/etc/jvm.config +++ b/docker/trino/empty-overlay/.gitkeep @@ -16,12 +16,6 @@ # specific language governing permissions and limitations # under the License. # --server --Xmx16G --XX:+UseG1GC --XX:G1HeapRegionSize=32M --XX:+UseGCOverheadLimit --XX:+ExplicitGCInvokesConcurrent --XX:+HeapDumpOnOutOfMemoryError --XX:OnOutOfMemoryError=kill -9 %p --Djdk.attach.allowAttachSelf=true +# This file exists only to keep the directory in git: it is the compose +# default (empty) mount source for the trino-hudi plugin overlay, and the +# overlay entrypoint applies an overlay only when it contains jars. diff --git a/docker/hoodie/hadoop/trinoworker/etc/catalog/hive.properties b/docker/trino/etc/catalog/hudi.properties similarity index 55% rename from docker/hoodie/hadoop/trinoworker/etc/catalog/hive.properties rename to docker/trino/etc/catalog/hudi.properties index ed7fce1b3e640..14c17746a8972 100644 --- a/docker/hoodie/hadoop/trinoworker/etc/catalog/hive.properties +++ b/docker/trino/etc/catalog/hudi.properties @@ -16,7 +16,16 @@ # specific language governing permissions and limitations # under the License. # -connector.name=hive +# Native trino-hudi connector (assembled from org.apache.hudi:hudi-trino by the +# docker/trino/shim project). +# Pre-Trino 398 the only way to read Hudi was the hive-connector + hudi-trino-bundle shim; +# plugin/trino-hudi landed upstream at 398 as the native replacement, hence +# connector.name=hudi. +connector.name=hudi +hive.metastore=thrift hive.metastore.uri=thrift://hivemetastore:9083 -hive.config.resources=/etc/hadoop/core-site.xml,/etc/hadoop/hdfs-site.xml -hive.hdfs.authentication.type=NONE +# trino-filesystem-manager flag that turns on the legacy Hadoop FileSystem path +# (HDFS via fs.defaultFS in hive.config.resources). Without this the plugin can't +# read hdfs:// URIs in Trino 460+. +fs.hadoop.enabled=true +hive.config.resources=/etc/trino/hadoop-conf/core-site.xml,/etc/trino/hadoop-conf/hdfs-site.xml diff --git a/docker/hoodie/hadoop/trinocoordinator/etc/config.properties b/docker/trino/etc/config.properties similarity index 76% rename from docker/hoodie/hadoop/trinocoordinator/etc/config.properties rename to docker/trino/etc/config.properties index 9876a0fe0f008..8239eacffdf6d 100644 --- a/docker/hoodie/hadoop/trinocoordinator/etc/config.properties +++ b/docker/trino/etc/config.properties @@ -16,11 +16,11 @@ # specific language governing permissions and limitations # under the License. # +# Single-node Trino: the coordinator also runs splits. Good enough for E2E, +# halves container startup vs a separate worker. coordinator=true -node-scheduler.include-coordinator=false -http-server.http.port=8091 -query.max-memory=50GB +node-scheduler.include-coordinator=true +http-server.http.port=8080 +discovery.uri=http://trinocoordinator:8080 +query.max-memory=2GB query.max-memory-per-node=1GB -query.max-total-memory-per-node=2GB -discovery-server.enabled=true -discovery.uri=http://trino-coordinator-1:8091 diff --git a/docker/trino/etc/hadoop-conf/core-site.xml b/docker/trino/etc/hadoop-conf/core-site.xml new file mode 100644 index 0000000000000..455fbb9181d63 --- /dev/null +++ b/docker/trino/etc/hadoop-conf/core-site.xml @@ -0,0 +1,23 @@ + + + + + fs.defaultFS + hdfs://namenode:8020 + + diff --git a/docker/trino/etc/hadoop-conf/hdfs-site.xml b/docker/trino/etc/hadoop-conf/hdfs-site.xml new file mode 100644 index 0000000000000..5bd3bf51dffe3 --- /dev/null +++ b/docker/trino/etc/hadoop-conf/hdfs-site.xml @@ -0,0 +1,27 @@ + + + + + dfs.client.use.datanode.hostname + true + + + dfs.replication + 1 + + diff --git a/docker/hoodie/hadoop/trinoworker/etc/jvm.config b/docker/trino/etc/jvm.config similarity index 59% rename from docker/hoodie/hadoop/trinoworker/etc/jvm.config rename to docker/trino/etc/jvm.config index fb17203ca211b..b1d8ff3772dc6 100644 --- a/docker/hoodie/hadoop/trinoworker/etc/jvm.config +++ b/docker/trino/etc/jvm.config @@ -17,11 +17,22 @@ # under the License. # -server --Xmx16G --XX:+UseG1GC +-Xmx2G +-XX:InitialRAMPercentage=80 +-XX:MaxRAMPercentage=80 -XX:G1HeapRegionSize=32M --XX:+UseGCOverheadLimit -XX:+ExplicitGCInvokesConcurrent +-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --XX:OnOutOfMemoryError=kill -9 %p +-XX:-OmitStackTraceInFastThrow +-XX:ReservedCodeCacheSize=512M +-XX:PerMethodRecompilationCutoff=10000 +-XX:PerBytecodeRecompilationCutoff=10000 -Djdk.attach.allowAttachSelf=true +-Djdk.nio.maxCachedBufferSize=2000000 +-Dfile.encoding=UTF-8 +# Allow loading dynamic agents (used by JOL, referenced by Trino's runtime). +-XX:+EnableDynamicAgentLoading +# NOTE: do NOT add -XX:GCLockerRetryAllocationCount here (Hudi's JDK 11/17 CI +# workaround): the GCLocker was removed in modern JDKs and the trinodb/trino:481 +# JVM (JDK 25) refuses to start on the unrecognized option. diff --git a/docker/hoodie/hadoop/trinoworker/etc/node.properties b/docker/trino/etc/node.properties similarity index 82% rename from docker/hoodie/hadoop/trinoworker/etc/node.properties rename to docker/trino/etc/node.properties index 6cfebf995602e..7e0222cc3aec0 100644 --- a/docker/hoodie/hadoop/trinoworker/etc/node.properties +++ b/docker/trino/etc/node.properties @@ -16,6 +16,7 @@ # specific language governing permissions and limitations # under the License. # -node.environment=development -node.id=6606f0b3-6ae7-4152-a4b1-ddadb6345fe6 -node.data-dir=/var/trino/data +node.environment=hudi +# Fixed node.id so a container restart reuses the same identity in the discovery service. +node.id=hudi-trino-coordinator +node.data-dir=/data/trino diff --git a/docker/trino/overlay-entrypoint.sh b/docker/trino/overlay-entrypoint.sh new file mode 100755 index 0000000000000..2ba9b517e26a6 --- /dev/null +++ b/docker/trino/overlay-entrypoint.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Overlay-aware Trino entrypoint. If a plugin overlay is bind-mounted at +# /opt/hudi-plugin-overlay (set TRINO_PLUGIN_DIR to the in-repo shim's +# docker/trino/shim/target/trino-hudi- build output, or to a trinodb/trino +# checkout's plugin/trino-hudi/target/trino-hudi-), fully replace the +# image's baked-in trino-hudi plugin with it (rm -rf then copy), so plugin +# iterations need only a rebuild of that dir plus a container restart, not a +# docker image rebuild. Otherwise the image-baked plugin is used as-is. +set -euo pipefail + +OVERLAY=/opt/hudi-plugin-overlay +PLUGIN_DIR=/usr/lib/trino/plugin/hudi + +# The overlay counts as present only if it holds at least one jar: the compose +# default mount is docker/trino/empty-overlay, whose .gitkeep must not trigger +# a wipe of the baked-in plugin. +if [ -d "$OVERLAY" ] && [ -n "$(find "$OVERLAY" -name '*.jar' -print -quit 2>/dev/null)" ]; then + echo "Applying trino-hudi plugin overlay from $OVERLAY (fully replacing $PLUGIN_DIR)" + rm -rf "$PLUGIN_DIR" + mkdir -p "$PLUGIN_DIR" + cp -r "$OVERLAY"/. "$PLUGIN_DIR"/ +else + echo "No plugin overlay found at $OVERLAY; using the image-baked trino-hudi plugin as-is." +fi + +# Overlays built from the in-repo shim (docker/trino/shim/target/trino-hudi-) +# lack the hdfs/ loader dir that fs.hadoop.enabled=true needs; restore the copy +# the image preserved from the stock plugin (see Dockerfile). +if [ ! -d "$PLUGIN_DIR/hdfs" ] && [ -d /opt/hudi-hdfs-lib ]; then + echo "Restoring hdfs/ loader dir into $PLUGIN_DIR from /opt/hudi-hdfs-lib" + cp -r /opt/hudi-hdfs-lib "$PLUGIN_DIR/hdfs" +fi + +exec /usr/lib/trino/bin/run-trino diff --git a/docker/trino/shim/pom.xml b/docker/trino/shim/pom.xml new file mode 100644 index 0000000000000..fdb0a62443573 --- /dev/null +++ b/docker/trino/shim/pom.xml @@ -0,0 +1,161 @@ + + + + + 4.0.0 + + + io.trino + trino-root + 481 + + + + + trino-hudi + trino-plugin + Trino - Hudi connector plugin assembly (in-repo E2E shim mirroring the upstream plugin/trino-hudi shim planned by RFC-105; never deployed) + + + + 1.2.0 + + true + + true + true + true + + + + + + com.google.guava + guava + + + + org.apache.hudi + hudi-trino + ${dep.hudi.version} + + + + org.apache.arrow + * + + + org.apache.hudi + hudi-timeline-service + + + org.apache.orc + orc-core + + + org.lance + * + + + org.rocksdb + * + + + + + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + + io.airlift + slice + provided + + + + io.opentelemetry + opentelemetry-api + provided + + + + io.opentelemetry + opentelemetry-api-incubator + provided + + + + io.opentelemetry + opentelemetry-common + provided + + + + io.opentelemetry + opentelemetry-context + provided + + + + io.trino + trino-spi + provided + + + + + diff --git a/docker/trino/shim/src/main/java/io/trino/plugin/hudi/HudiPlugin.java b/docker/trino/shim/src/main/java/io/trino/plugin/hudi/HudiPlugin.java new file mode 100644 index 0000000000000..5c3f185ec6ab9 --- /dev/null +++ b/docker/trino/shim/src/main/java/io/trino/plugin/hudi/HudiPlugin.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 io.trino.plugin.hudi; + +import com.google.common.collect.ImmutableList; +import io.trino.spi.Plugin; +import io.trino.spi.connector.ConnectorFactory; + +/** + * Thin shim plugin mirroring the upstream trinodb/trino plugin/trino-hudi module + * (RFC-105). Same FQCN as the copy inside the hudi-trino jar - the duplication is + * intentional: trino-maven-plugin's service descriptor generator only scans this + * module's own classes, and both class bodies are identical, so classloader + * ordering does not matter. + */ +public class HudiPlugin + implements Plugin +{ + @Override + public Iterable getConnectorFactories() + { + return ImmutableList.of(new HudiConnectorFactory()); + } +} diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/PKG-INFO b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/PKG-INFO new file mode 100644 index 0000000000000..e55cb3aedd60e --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/PKG-INFO @@ -0,0 +1,163 @@ +Metadata-Version: 2.4 +Name: hudi-agent-gateway +Version: 0.1.0 +Summary: The Apache Hudi AI gateway: agent loop, MCP server, and chat UI over lakehouse tools +License-Expression: Apache-2.0 +Project-URL: Homepage, https://hudi.apache.org +Project-URL: Source, https://github.com/apache/hudi +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +Requires-Dist: fastapi>=0.115 +Requires-Dist: uvicorn[standard]>=0.30 +Requires-Dist: langgraph<2,>=1.0 +Requires-Dist: langchain-core<2,>=1.0 +Requires-Dist: langchain-anthropic>=1.0 +Requires-Dist: langchain-openai>=1.0 +Requires-Dist: langchain-ollama>=1.0 +Requires-Dist: fastmcp<3,>=2.10 +Requires-Dist: trino>=0.330 +Requires-Dist: sqlglot>=25.0 +Requires-Dist: pydantic>=2.7 +Requires-Dist: pydantic-settings>=2.3 +Requires-Dist: sse-starlette>=2.1 +Requires-Dist: httpx>=0.27 +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" +Requires-Dist: pytest-asyncio>=0.24; extra == "dev" +Requires-Dist: ruff>=0.6; extra == "dev" +Requires-Dist: mypy>=1.11; extra == "dev" + + +# hudi-agent-gateway + +**One deployable service that serves your Hudi lakehouse to AI.** A single +process hosts three surfaces over the same set of guarded lakehouse tools: + +| Surface | Where | What | +|---|---|---| +| Agent chat API | `POST /v1/chat` | prompt in → LangGraph agent loop (model ↔ tools) → grounded answer out; multi-turn sessions; optional SSE streaming | +| MCP server | `/mcp` (streamable HTTP) | external agents (Claude, anything MCP) call the lakehouse tools directly | +| Chat UI | `/ui/` | first-party ChatGPT-style web UI (zero third-party code) | + +The v1 tools query the lakehouse through Trino: `query_lakehouse` (guarded, +read-only SQL), `list_tables`, `describe_table`. Every model-written query +passes AST-level guardrails (single statement, SELECT-only, row cap injected +as a real `LIMIT`) and every invocation is logged as structured JSON — the +seed of the gateway's trace collection. + +## Quickstart (local process) + +```bash +cd hudi-agent-gateway +python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]" + +# point at a Trino with the Hudi connector (e.g. the local-dev stack, port-forwarded): +GATEWAY_TRINO_HOST=localhost GATEWAY_TRINO_PORT=18080 \ +GATEWAY_LLM_PROVIDER=anthropic GATEWAY_LLM_MODEL=claude-haiku-4-5-20251001 \ + .venv/bin/hudi-agent-gateway serve + +curl -X POST localhost:8000/v1/chat -H 'Content-Type: application/json' \ + -d '{"message": "How many trips per city?", "session_id": "s1"}' +open http://localhost:8000/ui/ +``` + +`GET /v1/models` lists the models the configured provider offers (live: +Anthropic and OpenAI model APIs, Ollama's local tags, vLLM's served models), +and `POST /v1/chat` accepts an optional `"model"` to pick one per request — +the chat UI exposes this as a model picker. Sessions survive model switches. + +For a fully local model, install [Ollama](https://ollama.com), pull a +tool-capable model, and use the default provider: + +```bash +ollama pull qwen3:8b +GATEWAY_LLM_PROVIDER=ollama GATEWAY_LLM_MODEL=qwen3:8b hudi-agent-gateway serve +``` + +Connect an MCP client: + +```bash +claude mcp add --transport http hudi-lakehouse http://localhost:8000/mcp/ +``` + +## Deploying on Kubernetes + +See `hudi-lakehouse/charts/hudi-agent-gateway` — the product Helm chart — +and `hudi-lakehouse/local-dev/` for a complete laptop environment +(MinIO + Hive Metastore + Trino + this gateway) where the gateway is +installed alongside Trino by default. + +## Configuration + +Environment variables (prefix `GATEWAY_` except the standard key names): + +| Variable | Default | Purpose | +|---|---|---| +| `GATEWAY_LLM_PROVIDER` | `ollama` | `anthropic` \| `openai` \| `ollama` \| `openai-compatible` | +| `GATEWAY_LLM_MODEL` | `qwen3:8b` | model name for the provider | +| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | — | required by the matching provider | +| `GATEWAY_OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama endpoint | +| `GATEWAY_OPENAI_BASE_URL` | — | endpoint for `openai-compatible` (vLLM, Together, …) | +| `GATEWAY_LLM_TIMEOUT_SECONDS` | `120` | per-model-call timeout | +| `GATEWAY_TRINO_HOST` / `_PORT` | `hudi-trino.hudi-lakehouse.svc` / `8080` | Trino coordinator | +| `GATEWAY_TRINO_CATALOG` / `_SCHEMA` / `_USER` | `hudi` / `default` / `hudi-agent-gateway` | query defaults | +| `GATEWAY_SQL_ROW_CAP` | `200` | LIMIT enforced on every query | +| `GATEWAY_SQL_TIMEOUT_SECONDS` | `120` | per-query timeout | +| `GATEWAY_TOOL_RESULT_MAX_BYTES` | `50000` | tool results truncated beyond this (with notice) | +| `GATEWAY_AGENT_MAX_ITERATIONS` | `25` | agent loop recursion limit | +| `GATEWAY_SESSION_TTL_SECONDS` / `GATEWAY_MAX_SESSIONS` | `3600` / `1000` | session store bounds | +| `GATEWAY_MAX_MESSAGES_PER_SESSION` | `40` | context window per session (trimmed pre-model) | +| `GATEWAY_SYSTEM_PROMPT_EXTRA` | — | appended to the built-in system prompt | +| `GATEWAY_MCP_ENABLED` | `true` | serve /mcp | +| `GATEWAY_HOST` / `GATEWAY_PORT` / `GATEWAY_LOG_LEVEL` | `0.0.0.0` / `8000` / `INFO` | server basics | + +Startup never depends on the LLM or Trino being reachable: `/health` is +liveness, `/ready` reports per-dependency status (and gates the Kubernetes +readiness probe). + +## Development + +```bash +.venv/bin/pytest # offline suite (fake Trino + scripted model) +.venv/bin/ruff check src tests +.venv/bin/mypy src + +# live integration (against a port-forwarded local-dev stack): +GATEWAY_IT_TRINO_HOST=localhost GATEWAY_IT_TRINO_PORT=18080 .venv/bin/pytest tests/integration +``` + +Adding a tool: write a module under `src/hudi_agent_gateway/tools/` with a +`register(registry, ...)` function and call it from +`tools/__init__.py:build_registry`. One registration exposes it to the agent +loop, the MCP server, `GET /v1/tools`, and the invocation log. + +## Design notes & limits (v1) + +- **No authentication in v1**: `/v1/chat`, `/mcp` and the other endpoints are + open to anyone who can reach the pod (and `GATEWAY_HOST` defaults to + `0.0.0.0`). Deploy behind an authenticating proxy or on a trusted network. +- **Single replica**: sessions live in an in-memory LangGraph checkpointer. + The seam for horizontal scale is swapping in + `langgraph-checkpoint-postgres` inside `sessions.py`. +- **Read-only by construction**: non-SELECT statements are rejected at the + AST level (sqlglot, fail-closed); `EXPLAIN` is also blocked in v1. +- The chat UI is deliberately first-party and dependency-free (no CDN, no + npm) so the whole service works air-gapped and stays license-clean. The + one remote reference is the Hudi logo image, hotlinked from + hudi.apache.org; offline it degrades to alt text. diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/SOURCES.txt b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/SOURCES.txt new file mode 100644 index 0000000000000..df0fb57efdff4 --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/SOURCES.txt @@ -0,0 +1,43 @@ +README.md +pyproject.toml +src/hudi_agent_gateway/__init__.py +src/hudi_agent_gateway/__main__.py +src/hudi_agent_gateway/agent.py +src/hudi_agent_gateway/app.py +src/hudi_agent_gateway/cli.py +src/hudi_agent_gateway/config.py +src/hudi_agent_gateway/llm.py +src/hudi_agent_gateway/log.py +src/hudi_agent_gateway/mcp_server.py +src/hudi_agent_gateway/py.typed +src/hudi_agent_gateway/sessions.py +src/hudi_agent_gateway.egg-info/PKG-INFO +src/hudi_agent_gateway.egg-info/SOURCES.txt +src/hudi_agent_gateway.egg-info/dependency_links.txt +src/hudi_agent_gateway.egg-info/entry_points.txt +src/hudi_agent_gateway.egg-info/requires.txt +src/hudi_agent_gateway.egg-info/top_level.txt +src/hudi_agent_gateway/api/__init__.py +src/hudi_agent_gateway/api/chat.py +src/hudi_agent_gateway/api/meta.py +src/hudi_agent_gateway/api/models.py +src/hudi_agent_gateway/tools/__init__.py +src/hudi_agent_gateway/tools/guardrails.py +src/hudi_agent_gateway/tools/registry.py +src/hudi_agent_gateway/tools/trino_client.py +src/hudi_agent_gateway/tools/trino_tools.py +src/hudi_agent_gateway/ui/app.js +src/hudi_agent_gateway/ui/index.html +src/hudi_agent_gateway/ui/markdown.js +src/hudi_agent_gateway/ui/style.css +tests/test_agent_loop.py +tests/test_api_meta.py +tests/test_chat_sse.py +tests/test_config.py +tests/test_guardrails.py +tests/test_llm.py +tests/test_mcp.py +tests/test_registry.py +tests/test_sessions.py +tests/test_trino_tools.py +tests/test_ui.py \ No newline at end of file diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/dependency_links.txt b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/dependency_links.txt new file mode 100644 index 0000000000000..8b137891791fe --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/entry_points.txt b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/entry_points.txt new file mode 100644 index 0000000000000..84a249ac48f40 --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +hudi-agent-gateway = hudi_agent_gateway.cli:main diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/requires.txt b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/requires.txt new file mode 100644 index 0000000000000..8a2b84a1f23c6 --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/requires.txt @@ -0,0 +1,20 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +langgraph<2,>=1.0 +langchain-core<2,>=1.0 +langchain-anthropic>=1.0 +langchain-openai>=1.0 +langchain-ollama>=1.0 +fastmcp<3,>=2.10 +trino>=0.330 +sqlglot>=25.0 +pydantic>=2.7 +pydantic-settings>=2.3 +sse-starlette>=2.1 +httpx>=0.27 + +[dev] +pytest>=8.0 +pytest-asyncio>=0.24 +ruff>=0.6 +mypy>=1.11 diff --git a/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/top_level.txt b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/top_level.txt new file mode 100644 index 0000000000000..a739e485153b5 --- /dev/null +++ b/hudi-agent-gateway/src/hudi_agent_gateway.egg-info/top_level.txt @@ -0,0 +1 @@ +hudi_agent_gateway diff --git a/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java b/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java index ba9abc55bef79..470fda3dfa1f5 100644 --- a/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java +++ b/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java @@ -20,7 +20,7 @@ import org.apache.hudi.aws.credentials.HoodieAWSCredentialsProviderFactory; import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.common.util.StringUtils; import com.codahale.metrics.Clock; import com.codahale.metrics.Counter; @@ -45,7 +45,9 @@ import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.SortedMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -66,6 +68,8 @@ public class CloudWatchReporter extends ScheduledReporter { private final String prefix; private final String namespace; private final int maxDatumsPerRequest; + /** Metric names already reported as unmappable, so the warning is logged once rather than every interval. */ + private final Set unmappableMetricNames = ConcurrentHashMap.newKeySet(); public static Builder forRegistry(MetricRegistry registry) { return new Builder(registry); @@ -276,8 +280,24 @@ private void stageMetricDatum(String metricName, long timestampMilliSec, List metricData) { String[] metricNameParts = metricName.split("\\.", 2); - ValidationUtils.checkArgument(metricNameParts.length >= 2, - "metricName doesn't follow the naming convention and doesn't contain a dot as splitter! metricName:" + metricName); + if (metricNameParts.length < 2 || StringUtils.isNullOrEmpty(metricNameParts[0])) { + // The table dimension comes from the part before the first dot, so a name without one, or one whose + // first segment is empty, cannot be mapped. An empty first segment is reachable: + // hoodie.metrics.reporter.metricsname.prefix defaults to "" and Metrics#registerGauges still joins it + // with a dot, producing ".foo" - and CloudWatch rejects a whole PutMetricData request whose dimension + // value is empty, which would lose the batch again. + // + // Skip just this metric rather than throwing: ScheduledReporter suppresses whatever report() throws, + // so failing here dropped every metric staged in the same interval and left no metrics in CloudWatch + // at all. + if (unmappableMetricNames.add(metricName)) { + log.warn("Not reporting metric \"{}\" to CloudWatch: no table name can be derived for the Table " + + "dimension. Metric names normally carry hoodie.metrics.reporter.metricsname.prefix, but some " + + "Hudi-internal metadata metrics do not (see HUDI issue #19507). Other metrics in this batch " + + "are unaffected, and this is logged once per metric name.", metricName); + } + return; + } String tableName = metricNameParts[0]; metricData.add(MetricDatum.builder() diff --git a/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java b/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java index 884b5c53a25d1..eb0a50ca0638c 100644 --- a/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java +++ b/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java @@ -71,6 +71,7 @@ import software.amazon.awssdk.services.glue.model.GetPartitionsResponse; import software.amazon.awssdk.services.glue.model.GetTableRequest; import software.amazon.awssdk.services.glue.model.KeySchemaElement; +import software.amazon.awssdk.services.glue.model.PartitionError; import software.amazon.awssdk.services.glue.model.PartitionIndex; import software.amazon.awssdk.services.glue.model.PartitionIndexDescriptor; import software.amazon.awssdk.services.glue.model.PartitionInput; @@ -137,6 +138,7 @@ public class AWSGlueCatalogSyncClient extends HoodieSyncClient { private static final int MAX_PARTITIONS_PER_CHANGE_REQUEST = 100; private static final int MAX_PARTITIONS_PER_READ_REQUEST = 1000; private static final int MAX_DELETE_PARTITIONS_PER_REQUEST = 25; + private static final String ENTITY_NOT_FOUND_ERROR_CODE = "EntityNotFoundException"; protected final GlueAsyncClient awsGlue; private static final String GLUE_PARTITION_INDEX_ENABLE = "partition_filtering.enabled"; private static final int PARTITION_INDEX_MAX_NUMBER = 3; @@ -250,7 +252,7 @@ public List getAllPartitions(String tableName) { @Override public List getPartitionsFromList(String tableName, List partitionList) { if (partitionList.isEmpty()) { - log.info("No partitions to read for " + tableId(this.databaseName, tableName)); + log.info("No partitions to read for {}", tableId(this.databaseName, tableName)); return Collections.emptyList(); } HoodieTimer timer = HoodieTimer.start(); @@ -308,7 +310,7 @@ public void addPartitionsToTable(String tableName, List partitionsToAdd) HoodieTimer timer = HoodieTimer.start(); try { if (partitionsToAdd.isEmpty()) { - log.info("No partitions to add for " + tableId(this.databaseName, tableName)); + log.info("No partitions to add for {}", tableId(this.databaseName, tableName)); return; } Table table = getTable(awsGlue, databaseName, tableName); @@ -371,7 +373,7 @@ public void updatePartitionsToTable(String tableName, List changedPartit HoodieTimer timer = HoodieTimer.start(); try { if (changedPartitions.isEmpty()) { - log.info("No partitions to update for " + tableId(this.databaseName, tableName)); + log.info("No partitions to update for {}", tableId(this.databaseName, tableName)); return; } Table table = getTable(awsGlue, databaseName, tableName); @@ -411,7 +413,7 @@ public void dropPartitions(String tableName, List partitionsToDrop) { HoodieTimer timer = HoodieTimer.start(); try { if (partitionsToDrop.isEmpty()) { - log.info("No partitions to drop for " + tableId(this.databaseName, tableName)); + log.info("No partitions to drop for {}", tableId(this.databaseName, tableName)); return; } parallelizeChange(partitionsToDrop, this.changeParallelism, partitions -> this.dropPartitionsInternal(tableName, partitions), MAX_DELETE_PARTITIONS_PER_REQUEST); @@ -437,8 +439,22 @@ private void dropPartitionsInternal(String tableName, List partitionsToD BatchDeletePartitionResponse response = future.get(); if (CollectionUtils.nonEmpty(response.errors())) { - throw new HoodieGlueSyncException("Fail to drop partitions to " + tableId(databaseName, tableName) - + " with error(s): " + response.errors()); + // Dropping a partition that no longer exists is a no-op for an idempotent cleanup, so + // ignore EntityNotFoundException errors and only fail on other (e.g. permission/throttling) errors. + Map> errorsByIgnorable = response.errors().stream() + .collect(Collectors.partitioningBy( + error -> ENTITY_NOT_FOUND_ERROR_CODE.equals(error.errorDetail().errorCode()))); + List ignorableErrors = errorsByIgnorable.get(true); + if (!ignorableErrors.isEmpty()) { + log.info("Ignored dropping {} non-existent partition(s) from table {}: {}", ignorableErrors.size(), + tableId(databaseName, tableName), + ignorableErrors.stream().map(PartitionError::partitionValues).collect(Collectors.toList())); + } + List realErrors = errorsByIgnorable.get(false); + if (!realErrors.isEmpty()) { + throw new HoodieGlueSyncException("Fail to drop partitions to " + tableId(databaseName, tableName) + + " with error(s): " + realErrors); + } } } catch (Exception e) { throw new HoodieGlueSyncException("Fail to drop partitions to " + tableId(databaseName, tableName), e); diff --git a/hudi-aws/src/main/java/org/apache/hudi/aws/utils/DynamoTableUtils.java b/hudi-aws/src/main/java/org/apache/hudi/aws/utils/DynamoTableUtils.java index ace74a1fbc796..1a70419c79d03 100644 --- a/hudi-aws/src/main/java/org/apache/hudi/aws/utils/DynamoTableUtils.java +++ b/hudi-aws/src/main/java/org/apache/hudi/aws/utils/DynamoTableUtils.java @@ -222,7 +222,7 @@ public static boolean createTableIfNotExists(final DynamoDbClient dynamo, final return true; } catch (final ResourceInUseException e) { if (log.isTraceEnabled()) { - log.trace("Table " + createTableRequest.tableName() + " already exists", e); + log.trace("Table {} already exists", createTableRequest.tableName(), e); } } return false; @@ -240,7 +240,7 @@ public static boolean deleteTableIfExists(final DynamoDbClient dynamo, final Del return true; } catch (final ResourceNotFoundException e) { if (log.isTraceEnabled()) { - log.trace("Table " + deleteTableRequest.tableName() + " does not exist", e); + log.trace("Table {} does not exist", deleteTableRequest.tableName(), e); } } return false; diff --git a/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java b/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java index 0073f3687db2b..d6fb7a4cfb7ed 100644 --- a/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java +++ b/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java @@ -27,6 +27,12 @@ import com.codahale.metrics.MetricFilter; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.LoggerConfig; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,6 +49,8 @@ import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest; import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataResponse; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.SortedMap; import java.util.TreeMap; @@ -54,7 +62,6 @@ import static org.apache.hudi.aws.metrics.cloudwatch.CloudWatchReporter.DIMENSION_METRIC_TYPE_KEY; import static org.apache.hudi.aws.metrics.cloudwatch.CloudWatchReporter.DIMENSION_TABLE_NAME_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; @ExtendWith(MockitoExtension.class) public class TestCloudWatchReporter { @@ -168,21 +175,139 @@ public void testReporter() { Mockito.verify(cloudWatchAsync).close(); } + /** + * A metric name with no dot has no table name to report under, and such names do reach the reporter: + * {@code HoodieMetadataMetrics#setMetric} registers gauges without the metrics-name prefix, so + * {@code BaseTableMetadata#getBloomFilters} contributes a bare + * {@code lookup_meta_index_bloom_filters_file_count} on the normal bloom-index read path. This used to + * throw, and {@link com.codahale.metrics.ScheduledReporter} suppresses whatever {@code report()} throws, + * so no metrics reached CloudWatch at all - which is what #12182 and #13051 report. The unmappable metric + * is now skipped and the rest of the batch is still published. See HUDI issue #19507 for the producer side. + */ @Test - public void testReportOnMetricsWithoutTableName() { + public void testReportSkipsMetricsWithoutTableNameAndPublishesTheRest() { SortedMap gauges = new TreeMap<>(); - Gauge gauge1 = () -> 100L; - Gauge gauge2 = () -> 100.1; - gauges.put("gauge1", gauge1); - gauges.put(TABLE_NAME + ".gauge2", gauge2); + Gauge unmappable = () -> 7L; + Gauge wellFormed = () -> 100.1; + gauges.put("lookup_meta_index_bloom_filters_file_count", unmappable); + gauges.put(TABLE_NAME + ".gauge2", wellFormed); Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges); - // should fail if metric name doesn't have at least two parts - assertThrows(IllegalArgumentException.class, () -> reporter.report()); + reporter.report(); - reporter.stop(); - Mockito.verify(cloudWatchAsync).close(); + Mockito.verify(cloudWatchAsync, Mockito.times(1)).putMetricData(putMetricDataRequestCaptor.capture()); + List metricData = putMetricDataRequestCaptor.getValue().metricData(); + assertEquals(1, metricData.size(), + "The unmappable metric should be skipped and the well-formed one still published"); + assertEquals(PREFIX + ".gauge2", metricData.get(0).metricName()); + assertEquals(wellFormed.getValue(), metricData.get(0).value()); + assertDimensions(metricData.get(0).dimensions(), DIMENSION_GAUGE_TYPE_VALUE); + } + + /** + * An empty first segment is reachable: {@code hoodie.metrics.reporter.metricsname.prefix} defaults to + * {@code ""} and {@code Metrics#registerGauges} still joins it with a dot, giving {@code ".foo"}. That + * splits into two parts and so passed the length check, then asked CloudWatch for an empty {@code Table} + * dimension value, which it rejects for the whole PutMetricData request - losing the batch again. + */ + @Test + public void testReportSkipsMetricsWithAnEmptyTableName() { + SortedMap gauges = new TreeMap<>(); + gauges.put(".gauge1", (Gauge) () -> 7L); + gauges.put(TABLE_NAME + ".gauge2", (Gauge) () -> 100L); + + Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges); + + reporter.report(); + + Mockito.verify(cloudWatchAsync, Mockito.times(1)).putMetricData(putMetricDataRequestCaptor.capture()); + List metricData = putMetricDataRequestCaptor.getValue().metricData(); + assertEquals(1, metricData.size(), "a metric whose table name is empty should be skipped"); + assertEquals(PREFIX + ".gauge2", metricData.get(0).metricName()); + } + + /** + * An interval in which every metric is unmappable leaves nothing staged. CloudWatch rejects an empty + * PutMetricData request, so the reporter must not send one. + */ + @Test + public void testReportSendsNothingWhenEveryMetricIsUnmappable() { + SortedMap gauges = new TreeMap<>(); + gauges.put("lookup_meta_index_bloom_filters_file_count", (Gauge) () -> 7L); + gauges.put("bootstrap_error", (Gauge) () -> 1L); + + Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges); + + reporter.report(); + + Mockito.verify(cloudWatchAsync, Mockito.never()).putMetricData(ArgumentMatchers.any(PutMetricDataRequest.class)); + } + + /** + * The unmappable-name set exists so a persistent offender is logged once rather than every reporting + * interval. Without this, deleting the set and logging unconditionally would pass the suite. + */ + @Test + public void testUnmappableMetricIsLoggedOncePerName() { + SortedMap gauges = new TreeMap<>(); + gauges.put("lookup_meta_index_bloom_filters_file_count", (Gauge) () -> 7L); + Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges); + + CapturingAppender appender = CapturingAppender.attachTo(CloudWatchReporter.class); + try { + reporter.report(); + reporter.report(); + } finally { + appender.detach(); + } + + assertEquals(1, appender.warningsContaining("lookup_meta_index_bloom_filters_file_count"), + "a persistent unmappable name should be warned about once, not once per interval"); + } + + /** Captures WARN events from a single logger, so "logged once" can be asserted. */ + private static final class CapturingAppender extends AbstractAppender { + private final List warnings = Collections.synchronizedList(new ArrayList<>()); + private final LoggerConfig loggerConfig; + private final Level previousLevel; + + private CapturingAppender(LoggerConfig loggerConfig) { + super("CapturingAppender", null, null, true, null); + this.loggerConfig = loggerConfig; + this.previousLevel = loggerConfig.getLevel(); + } + + static CapturingAppender attachTo(Class loggerFor) { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + LoggerConfig loggerConfig = context.getConfiguration().getLoggerConfig(loggerFor.getName()); + CapturingAppender appender = new CapturingAppender(loggerConfig); + appender.start(); + loggerConfig.addAppender(appender, Level.WARN, null); + loggerConfig.setLevel(Level.WARN); + context.updateLoggers(); + return appender; + } + + void detach() { + loggerConfig.removeAppender(getName()); + loggerConfig.setLevel(previousLevel); + ((LoggerContext) LogManager.getContext(false)).updateLoggers(); + stop(); + } + + long warningsContaining(String needle) { + synchronized (warnings) { + return warnings.stream().filter(m -> m.contains(needle)).count(); + } + } + + @Override + public void append(LogEvent event) { + if (event.getLevel().isMoreSpecificThan(Level.WARN)) { + warnings.add(event.getMessage().getFormattedMessage()); + } + } } private void assertDimensions(List actualDimensions, String metricTypeDimensionVal) { diff --git a/hudi-aws/src/test/java/org/apache/hudi/aws/sync/TestAWSGlueSyncClient.java b/hudi-aws/src/test/java/org/apache/hudi/aws/sync/TestAWSGlueSyncClient.java index f4822e32f05de..8c3b24c0b6776 100644 --- a/hudi-aws/src/test/java/org/apache/hudi/aws/sync/TestAWSGlueSyncClient.java +++ b/hudi-aws/src/test/java/org/apache/hudi/aws/sync/TestAWSGlueSyncClient.java @@ -647,6 +647,62 @@ void testDropPartitions_ErrorResponses() { assertTrue(ex.getCause().getCause().getMessage().contains("Fail to drop partitions")); } + @Test + void testDropPartitions_IgnoresEntityNotFound() { + String tableName = "tbl"; + List toDrop = List.of("2025/05/19"); + + // Glue reports EntityNotFoundException for a partition that no longer exists; it should be ignored. + ErrorDetail detail = ErrorDetail.builder().errorCode(EntityNotFoundException.class.getSimpleName()).build(); + PartitionError pe = PartitionError.builder().partitionValues(Arrays.asList("2025", "05", "19")).errorDetail(detail).build(); + BatchDeletePartitionResponse resp = BatchDeletePartitionResponse.builder() + .errors(Collections.singletonList(pe)) + .build(); + when(mockAwsGlue.batchDeletePartition(any(BatchDeletePartitionRequest.class))) + .thenReturn(CompletableFuture.completedFuture(resp)); + + // should swallow the EntityNotFound error and not throw + awsGlueSyncClient.dropPartitions(tableName, toDrop); + + verify(mockAwsGlue).batchDeletePartition(any(BatchDeletePartitionRequest.class)); + } + + @Test + void testDropPartitions_MixedErrorsStillThrow() { + String tableName = "tbl"; + List toDrop = Arrays.asList("2025/05/19", "2025/05/18"); + + // One ignorable EntityNotFound error and one real error -> should still throw for the real one. + PartitionError ignorable = PartitionError.builder() + .partitionValues(Arrays.asList("2025", "05", "19")) + .errorDetail(ErrorDetail.builder().errorCode(EntityNotFoundException.class.getSimpleName()).build()) + .build(); + PartitionError real = PartitionError.builder() + .partitionValues(Arrays.asList("2025", "05", "18")) + .errorDetail(ErrorDetail.builder().errorCode("InternalServiceException").build()) + .build(); + BatchDeletePartitionResponse resp = BatchDeletePartitionResponse.builder() + .errors(Arrays.asList(ignorable, real)) + .build(); + when(mockAwsGlue.batchDeletePartition(any(BatchDeletePartitionRequest.class))) + .thenReturn(CompletableFuture.completedFuture(resp)); + + HoodieGlueSyncException ex = assertThrows( + HoodieGlueSyncException.class, + () -> awsGlueSyncClient.dropPartitions(tableName, toDrop) + ); + // Walk the full cause chain: the error list is nested a few wrappers deep. + StringBuilder chain = new StringBuilder(); + for (Throwable t = ex; t != null; t = t.getCause()) { + chain.append(t.getMessage()).append('\n'); + } + String messages = chain.toString(); + assertTrue(messages.contains("Fail to drop partitions")); + // Only the real error should be surfaced, not the ignored EntityNotFound one. + assertTrue(messages.contains("InternalServiceException")); + assertFalse(messages.contains(EntityNotFoundException.class.getSimpleName())); + } + @Disabled("Integration test – requires real AWS environment") @Test void testIntegrationTableExists_RealGlueEnvironment() { diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CommitsCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CommitsCommand.java index fa177eca99527..f2089c9d38447 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CommitsCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CommitsCommand.java @@ -363,7 +363,7 @@ public String showCommitFiles( limit, headerOnly, rows, exportTableName); } - @ShellMethod(key = "commits show_infights", value = "Show inflight instants that are left longer than a certain duration") + @ShellMethod(key = "commits show_inflights", value = "Show inflight instants that are left longer than a certain duration") public String showInflightCommits( @ShellOption(value = {"--lookbackInMins"}, help = "Only show inflight commits that started before the specified lookback duration (in minutes).", defaultValue = "0") final Long durationInMins) { HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient(); diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CompactionCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CompactionCommand.java index 61a8ba5c6b12c..53c48c847570d 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CompactionCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/CompactionCommand.java @@ -432,7 +432,7 @@ private T deSerializeOperationResult(StoragePath inputPath, ObjectInputStream in = new ObjectInputStream(inputStream); try { T result = (T) in.readObject(); - log.info("Result : " + result); + log.info("Result : {}", result); return result; } finally { in.close(); diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/ExportCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/ExportCommand.java index 8692efb8f6baa..83bdbbdb9c5c0 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/ExportCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/ExportCommand.java @@ -172,7 +172,7 @@ private int copyArchivedInstants(List pathInfoList, final String instantTime = archiveEntryRecord.get("commitTime").toString(); if (metadata == null) { - log.error("Could not load metadata for action " + action + " at instant time " + instantTime); + log.error("Could not load metadata for action {} at instant time {}", action, instantTime); continue; } final String outPath = localFolder + StoragePath.SEPARATOR + instantTime + "." + action; diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/HoodieLogFileCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/HoodieLogFileCommand.java index 6138e1fb38396..d6d2546e2cb11 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/HoodieLogFileCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/HoodieLogFileCommand.java @@ -242,10 +242,12 @@ storage, new StoragePath(logFilePathPattern)).stream() Option.empty(), Option.empty(), fileGroupReaderProperties); - try (HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + try (HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(HoodieCLI.getTableMetaClient()) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(readerSchema) .withRequestedSchema(readerSchema) .withLatestCommitTime(client.getActiveTimeline().getCommitAndReplaceTimeline().lastInstant().map(HoodieInstant::requestedTime).orElse(HoodieInstantTimeGenerator.getCurrentInstantTimeStr())) diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/LockAuditingCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/LockAuditingCommand.java index 8aa709646d3fc..3550916373e4b 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/LockAuditingCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/LockAuditingCommand.java @@ -503,7 +503,7 @@ private CleanupResult performAuditCleanup(boolean dryRun, int ageDays) { deletedCount++; } catch (Exception e) { failedCount++; - log.warn("Failed to delete audit file: " + pathInfo.getPath(), e); + log.warn("Failed to delete audit file: {}", pathInfo.getPath(), e); } } @@ -543,7 +543,7 @@ private Option parseAuditFile(StoragePathInfo pathInfo) { AuditRecord entry = OBJECT_MAPPER.readValue(line, AuditRecord.class); entries.add(entry); } catch (Exception e) { - log.warn("Failed to parse JSON line in file " + filename + ": " + line, e); + log.warn("Failed to parse JSON line in file {}: {}", filename, line, e); } } } @@ -592,7 +592,7 @@ private Option parseAuditFile(StoragePathInfo pathInfo) { filename )); } catch (Exception e) { - log.warn("Failed to parse audit file: " + filename, e); + log.warn("Failed to parse audit file: {}", filename, e); return Option.empty(); } } diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/MetadataCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/MetadataCommand.java index dbdc32211a96b..e2e86193b2ee9 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/MetadataCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/MetadataCommand.java @@ -307,8 +307,8 @@ public String validateFiles( if (!fsPartitions.equals(metadataPartitions)) { log.error("FS partition listing is not matching with metadata partition listing!"); - log.error("All FS partitions: " + Arrays.toString(fsPartitions.toArray())); - log.error("All Metadata partitions: " + Arrays.toString(metadataPartitions.toArray())); + log.error("All FS partitions: {}", Arrays.toString(fsPartitions.toArray())); + log.error("All Metadata partitions: {}", Arrays.toString(metadataPartitions.toArray())); } final List rows = new ArrayList<>(); @@ -351,34 +351,33 @@ public String validateFiles( } if (metadataPathInfoList.size() != pathInfoList.size()) { - log.error(" FS and metadata files count not matching for " + partition - + ". FS files count " + pathInfoList.size() - + ", metadata base files count " + metadataPathInfoList.size()); + log.error(" FS and metadata files count not matching for {}. FS files count {}, metadata base files count {}", partition, pathInfoList.size(), metadataPathInfoList.size()); } for (Map.Entry entry : pathInfoMap.entrySet()) { if (!metadataPathInfoMap.containsKey(entry.getKey())) { - log.error("FS file not found in metadata " + entry.getKey()); + log.error("FS file not found in metadata {}", entry.getKey()); } else { if (entry.getValue().getLength() != metadataPathInfoMap.get(entry.getKey()).getLength()) { - log.error(" FS file size mismatch " + entry.getKey() + ", size equality " - + (entry.getValue().getLength() - == metadataPathInfoMap.get(entry.getKey()).getLength()) - + ". FS size " + entry.getValue().getLength() - + ", metadata size " + metadataPathInfoMap.get(entry.getKey()).getLength()); + log.error(" FS file size mismatch {}, size equality {}. FS size {}, metadata size {}", + entry.getKey(), + entry.getValue().getLength() == metadataPathInfoMap.get(entry.getKey()).getLength(), + entry.getValue().getLength(), + metadataPathInfoMap.get(entry.getKey()).getLength()); } } } for (Map.Entry entry : metadataPathInfoMap.entrySet()) { if (!pathInfoMap.containsKey(entry.getKey())) { - log.error("Metadata file not found in FS " + entry.getKey()); + log.error("Metadata file not found in FS {}", entry.getKey()); } else { if (entry.getValue().getLength() != pathInfoMap.get(entry.getKey()).getLength()) { - log.error(" Metadata file size mismatch " + entry.getKey() + ", size equality " - + (entry.getValue().getLength() == pathInfoMap.get(entry.getKey()).getLength()) - + ". Metadata size " + entry.getValue().getLength() + ", FS size " - + metadataPathInfoMap.get(entry.getKey()).getLength()); + log.error(" Metadata file size mismatch {}, size equality {}. Metadata size {}, FS size {}", + entry.getKey(), + entry.getValue().getLength() == pathInfoMap.get(entry.getKey()).getLength(), + entry.getValue().getLength(), + metadataPathInfoMap.get(entry.getKey()).getLength()); } } } diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java index 6003e936b819d..872ff342dce5b 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java @@ -197,12 +197,12 @@ public void removeCorruptedPendingCleanAction() { try { CleanerUtils.getCleanerPlan(client, instant); } catch (AvroRuntimeException e) { - log.warn("Corruption found. Trying to remove corrupted clean instant file: " + instant); + log.warn("Corruption found. Trying to remove corrupted clean instant file: {}", instant); TimelineUtils.deleteInstantFile(client.getStorage(), client.getTimelinePath(), instant, client.getInstantFileNameGenerator()); } catch (IOException ioe) { if (ioe.getMessage().contains("Not an Avro data file")) { - log.warn("Corruption found. Trying to remove corrupted clean instant file: " + instant); + log.warn("Corruption found. Trying to remove corrupted clean instant file: {}", instant); TimelineUtils.deleteInstantFile(client.getStorage(), client.getTimelinePath(), instant, client.getInstantFileNameGenerator()); } else { @@ -216,7 +216,7 @@ public void removeCorruptedPendingCleanAction() { public void showFailedCommits() { HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient(); HoodieActiveTimeline activeTimeline = metaClient.getActiveTimeline(); - activeTimeline.filterCompletedInstants().getInstantsAsStream().filter(activeTimeline::isEmpty).forEach(hoodieInstant -> log.warn("Empty Commit: " + hoodieInstant.toString())); + activeTimeline.filterCompletedInstants().getInstantsAsStream().filter(activeTimeline::isEmpty).forEach(hoodieInstant -> log.warn("Empty Commit: {}", hoodieInstant)); } @ShellMethod(key = "repair migrate-partition-meta", value = "Migrate all partition meta file currently stored in text format " diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java index 097ba984dc923..a7955032497ea 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java @@ -599,7 +599,7 @@ private static HoodieWriteConfig getWriteConfig(String basePath, Boolean rollbac private static int archive(JavaSparkContext jsc, int minCommits, int maxCommits, int commitsRetained, boolean enableMetadata, String basePath) { try { - return ArchiveExecutorUtils.archive(jsc, minCommits, maxCommits, commitsRetained, enableMetadata, basePath); + return ArchiveExecutorUtils.archive(jsc, minCommits, maxCommits, commitsRetained, enableMetadata, basePath, new HashMap<>()); } catch (IOException ex) { return -1; } diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java index 0e3f7a4029dcd..ed4b75b3924a1 100644 --- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java +++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/TableCommand.java @@ -206,8 +206,9 @@ public String fetchTableSchema( TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(client); HoodieSchema schema = tableSchemaResolver.getTableSchema(); if (outputFilePath != null) { - log.info("Latest table schema : " + schema.toString(true)); - writeToFile(outputFilePath, schema.toString(true)); + String schemaStr = schema.toString(true); + log.info("Latest table schema : {}", schemaStr); + writeToFile(outputFilePath, schemaStr); return String.format("Latest table schema written to %s", outputFilePath); } else { return String.format("Latest table schema %s", schema.toString(true)); diff --git a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCommitsCommand.java b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCommitsCommand.java index 7a0fd534309b3..9c80bbd2ec808 100644 --- a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCommitsCommand.java +++ b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCommitsCommand.java @@ -609,7 +609,7 @@ public void testInflightCommand() throws Exception { // Reload meta client to pick up new instants metaClient = HoodieTableMetaClient.reload(HoodieCLI.getTableMetaClient()); - Object lookupBackInZeroMinsResult = shell.evaluate(() -> "commits show_infights --lookbackInMins 0"); + Object lookupBackInZeroMinsResult = shell.evaluate(() -> "commits show_inflights --lookbackInMins 0"); assertTrue(ShellEvaluationResultUtil.isSuccess(lookupBackInZeroMinsResult)); // All three instants should be shown when duration is 0 @@ -619,21 +619,21 @@ public void testInflightCommand() throws Exception { assertTrue(output.contains(oldInstantTime3)); // Only one instants should be shown when duration is 15 since 2nd commit is a completed commit. - Object lookupBackIn15MinsResult = shell.evaluate(() -> "commits show_infights --lookbackInMins 15"); + Object lookupBackIn15MinsResult = shell.evaluate(() -> "commits show_inflights --lookbackInMins 15"); assertTrue(ShellEvaluationResultUtil.isSuccess(lookupBackIn15MinsResult)); output = lookupBackIn15MinsResult.toString(); assertTrue(output.contains(oldInstantTime1)); assertFalse(output.contains(oldInstantTime2)); // Only one instant should be shown when duration is 50 - Object lookupBackIn50MinsResult = shell.evaluate(() -> "commits show_infights --lookbackInMins 50"); + Object lookupBackIn50MinsResult = shell.evaluate(() -> "commits show_inflights --lookbackInMins 50"); assertTrue(ShellEvaluationResultUtil.isSuccess(lookupBackIn50MinsResult)); output = lookupBackIn50MinsResult.toString(); assertTrue(output.contains(oldInstantTime1)); assertFalse(output.contains(oldInstantTime2)); // No instants should be shown when duration is > 60 - Object lookupBackIn70MinsResult = shell.evaluate(() -> "commits show_infights --lookbackInMins 70"); + Object lookupBackIn70MinsResult = shell.evaluate(() -> "commits show_inflights --lookbackInMins 70"); assertTrue(ShellEvaluationResultUtil.isSuccess(lookupBackIn70MinsResult)); output = lookupBackIn70MinsResult.toString(); assertTrue(output.contains(oldInstantTime1)); diff --git a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestHoodieLogFileCommand.java b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestHoodieLogFileCommand.java index 51bd2c2843f37..7ba69b323acad 100644 --- a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestHoodieLogFileCommand.java +++ b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestHoodieLogFileCommand.java @@ -36,6 +36,7 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.HoodieMergedLogRecordScanner; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; @@ -109,11 +110,14 @@ public void init() throws IOException, InterruptedException, URISyntaxException Files.createDirectories(Paths.get(partitionPath)); storage = HoodieStorageUtils.getStorage(tablePath, storageConf()); - try (HoodieLogFormat.Writer writer = HoodieLogFormat.newWriterBuilder() - .onParentPath(new StoragePath(partitionPath)) + try (HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(partitionPath)) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-log-fileid1").withInstantTime("100").withStorage(storage) - .withSizeThreshold(1).build()) { + .withLogFileId("test-log-fileid1") + .withInstantTime("100") + .withStorage(storage) + .withSizeThreshold(1L) + .build()) { // write data to file List records = SchemaTestUtil.generateTestRecords(0, 100).stream().map(HoodieAvroIndexedRecord::new).collect(Collectors.toList()); @@ -203,16 +207,14 @@ public void testShowLogFileRecordsWithMerge() throws IOException, InterruptedExc partitionPath = tablePath + StoragePath.SEPARATOR + HoodieTestCommitMetadataGenerator.DEFAULT_SECOND_PARTITION_PATH; Files.createDirectories(Paths.get(partitionPath)); - HoodieLogFormat.Writer writer = null; - try { - // set little threshold to split file. - writer = - HoodieLogFormat.newWriterBuilder().onParentPath(new StoragePath(partitionPath)) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-log-fileid1").withInstantTime(INSTANT_TIME).withStorage( - storage) - .withSizeThreshold(500).build(); - + try (HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(partitionPath)) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-log-fileid1") + .withInstantTime(INSTANT_TIME) + .withStorage(storage) + .withSizeThreshold(500L) // set little threshold to split file. + .build()) { SchemaTestUtil testUtil = new SchemaTestUtil(); List records1 = testUtil.generateHoodieTestRecords(0, 100).stream().map(HoodieAvroIndexedRecord::new).collect(Collectors.toList()); Map header = new HashMap<>(); @@ -220,10 +222,6 @@ public void testShowLogFileRecordsWithMerge() throws IOException, InterruptedExc header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString()); HoodieAvroDataBlock dataBlock = new HoodieAvroDataBlock(records1, header, HoodieRecord.RECORD_KEY_METADATA_FIELD); writer.appendBlock(dataBlock); - } finally { - if (writer != null) { - writer.close(); - } } Object result = shell.evaluate(() -> "show logfile records --logFilePathPattern " diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java index 2bcd851208fb2..2e2588ed28e4f 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java @@ -71,7 +71,7 @@ protected Pair startService() { return Pair.of(CompletableFuture.allOf(IntStream.range(0, maxConcurrentClustering).mapToObj(i -> CompletableFuture.supplyAsync(() -> { try { // Set Compactor Pool Name for allowing users to prioritize compaction - log.info("Setting pool name for clustering to " + CLUSTERING_POOL_NAME); + log.info("Setting pool name for clustering to {}", CLUSTERING_POOL_NAME); context.setProperty(EngineProperty.CLUSTERING_POOL_NAME, CLUSTERING_POOL_NAME); while (!isShutdownRequested()) { final String instant = fetchNextAsyncServiceInstant(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java index 52088d8d683e6..6298c01e4ded8 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java @@ -71,16 +71,16 @@ protected Pair startService() { return Pair.of(CompletableFuture.allOf(IntStream.range(0, maxConcurrentCompaction).mapToObj(i -> CompletableFuture.supplyAsync(() -> { try { // Set Compactor Pool Name for allowing users to prioritize compaction - log.info("Setting pool name for compaction to " + COMPACT_POOL_NAME); + log.info("Setting pool name for compaction to {}", COMPACT_POOL_NAME); context.setProperty(EngineProperty.COMPACTION_POOL_NAME, COMPACT_POOL_NAME); while (!isShutdownRequested()) { final String instantTime = fetchNextAsyncServiceInstant(); if (null != instantTime) { - log.info("Starting Compaction for instant " + instantTime); + log.info("Starting Compaction for instant {}", instantTime); compactor.compact(instantTime); - log.info("Finished Compaction for instant " + instantTime); + log.info("Finished Compaction for instant {}", instantTime); } } log.info("Compactor shutting down properly!!"); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java index 917156af89a05..4f207b026e882 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java @@ -186,7 +186,7 @@ public void waitTillPendingAsyncServiceInstantsReducesTo(int numPending) throws * @param instantTime {@link String} to enqueue. */ public void enqueuePendingAsyncServiceInstant(String instantTime) { - log.info("Enqueuing new pending table service instant: " + instantTime); + log.info("Enqueuing new pending table service instant: {}", instantTime); pendingInstants.add(instantTime); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/common/HoodieWriteCommitCallbackMessage.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/common/HoodieWriteCommitCallbackMessage.java index 713427b52c01f..23a1e08b86c26 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/common/HoodieWriteCommitCallbackMessage.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/common/HoodieWriteCommitCallbackMessage.java @@ -19,20 +19,26 @@ import org.apache.hudi.ApiMaturityLevel; import org.apache.hudi.PublicAPIClass; +import org.apache.hudi.callback.util.HoodieWriteCommitCallbackUtil; import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView; +import org.apache.hudi.util.Lazy; import org.apache.hudi.common.util.Option; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; +import java.io.IOException; +import java.io.ObjectOutputStream; import java.io.Serializable; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Supplier; /** * Base callback message, which contains commitTime and tableName only for now. */ -@AllArgsConstructor @Getter @PublicAPIClass(maturity = ApiMaturityLevel.EVOLVING) public class HoodieWriteCommitCallbackMessage implements Serializable { @@ -69,10 +75,116 @@ public class HoodieWriteCommitCallbackMessage implements Serializable { */ private final Option> extraMetadata; + /** + * Previous base file paths keyed by fileId, derived from {@link #hoodieWriteStat} and the + * {@link BaseFileOnlyView} handed over by the write client, so that callback + * implementations don't have to rebuild a view themselves. Empty for inserts and for + * callers that don't supply a view. + * + *

Holds the resolved map once {@link #getPrevFilePaths()} has run, and stays null until + * then. Not transient: this is the copy that crosses Java serialization, which is why + * {@link #writeObject} forces resolution before writing. Excluded from the generated + * getters so it is published only through {@link #getPrevFilePaths()}. + */ + @Getter(AccessLevel.NONE) + private volatile Map prevFilePaths; + + /** + * Resolves {@link #prevFilePaths} on demand. Resolution is deferred until the first + * {@link #getPrevFilePaths()} call, so a callback that never reads the previous paths pays + * nothing (no FileSystemView access at all). Transient because it captures a + * FileSystemView supplier, which is not serializable: on a deserialized instance this is + * null and the already-resolved {@link #prevFilePaths} is used instead. Excluded from the + * generated getters so the {@link Lazy} wrapper never leaks into JSON. + */ + @Getter(AccessLevel.NONE) + private final transient Lazy> prevFilePathsResolver; + + /** + * Free-form context that producers can attach for downstream callback consumers. + * The OSS write client populates this as empty; specialized callsites or wrappers + * may populate it with whatever context their callbacks need. + */ + private final Map extraContext; + + public HoodieWriteCommitCallbackMessage(String commitTime, + String tableName, + String basePath, + List hoodieWriteStat, + Option commitActionType, + Option> extraMetadata, + Supplier fsViewSupplier, + Map extraContext) { + this.commitTime = commitTime; + this.tableName = tableName; + this.basePath = basePath; + this.hoodieWriteStat = hoodieWriteStat; + this.commitActionType = commitActionType; + this.extraMetadata = extraMetadata; + this.prevFilePathsResolver = Lazy.lazily(() -> HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + hoodieWriteStat, fsViewSupplier == null ? null : fsViewSupplier.get())); + this.extraContext = extraContext; + } + public HoodieWriteCommitCallbackMessage(String commitTime, String tableName, String basePath, List hoodieWriteStat) { - this(commitTime, tableName, basePath, hoodieWriteStat, Option.empty(), Option.empty()); + this(commitTime, tableName, basePath, hoodieWriteStat, Option.empty(), Option.empty(), + null, Collections.emptyMap()); + } + + public HoodieWriteCommitCallbackMessage(String commitTime, + String tableName, + String basePath, + List hoodieWriteStat, + Option commitActionType, + Option> extraMetadata) { + this(commitTime, tableName, basePath, hoodieWriteStat, commitActionType, extraMetadata, + null, Collections.emptyMap()); + } + + /** + * Returns the previous base file paths keyed by fileId, resolving them from the file-system + * view on first access and memoizing the result. A consumer that never calls this triggers + * no FileSystemView lookup. Never null: empty when no view was supplied and when the commit + * only inserted. + */ + public Map getPrevFilePaths() { + Map paths = prevFilePaths; + if (paths == null) { + // The resolver is null only on an instance restored from Java serialization, and there + // the resolved map has already been read back into prevFilePaths (see writeObject). + paths = prevFilePathsResolver == null ? Collections.emptyMap() : prevFilePathsResolver.get(); + prevFilePaths = paths; + } + return paths; + } + + /** + * A {@link BaseFileOnlyView} cannot cross a serialization boundary, so materialize the + * paths at the last possible moment and let the resolved map travel in their place. + */ + private void writeObject(ObjectOutputStream out) throws IOException { + getPrevFilePaths(); + out.defaultWriteObject(); + } + + /** + * Container for previously-existing file paths associated with a single fileId in a + * commit. {@link #baseFilePath} is the base file the new write replaces, and + * {@link #bootstrapBaseFilePath} is the bootstrap-source file the previous + * base file referenced (null for non-bootstrap tables). + */ + @Getter + public static class PrevFilePaths implements Serializable { + private static final long serialVersionUID = 1L; + private final String baseFilePath; + private final String bootstrapBaseFilePath; + + public PrevFilePaths(String baseFilePath, String bootstrapBaseFilePath) { + this.baseFilePath = baseFilePath; + this.bootstrapBaseFilePath = bootstrapBaseFilePath; + } } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/util/HoodieWriteCommitCallbackUtil.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/util/HoodieWriteCommitCallbackUtil.java index cd05b78dfcf2b..c255e31b9fc8e 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/util/HoodieWriteCommitCallbackUtil.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/callback/util/HoodieWriteCommitCallbackUtil.java @@ -17,15 +17,27 @@ package org.apache.hudi.callback.util; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage.PrevFilePaths; +import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.HoodieBaseFile; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.exception.HoodieCommitCallbackException; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Util helps to prepare callback message. */ +@Slf4j public class HoodieWriteCommitCallbackUtil { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -41,4 +53,46 @@ public static String convertToJsonString(Object obj) { } } + /** + * Resolve the previous base file (and bootstrap base file, if any) for every + * {@link HoodieWriteStat} that represents an update, using a populated + * {@link BaseFileOnlyView}. The lookup is O(1) per stat against the cached view, so + * this adds no I/O on top of what the writer already paid. + * + *

Feeds {@link org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage#getPrevFilePaths()} + * so the callback message can ship actual file paths rather than forcing each callback + * impl to rebuild a {@code FileSystemView}. + */ + public static Map resolvePrevFilePaths(List stats, + BaseFileOnlyView fsView) { + Map pathsByFileId = new HashMap<>(); + if (stats == null || fsView == null) { + return pathsByFileId; + } + for (HoodieWriteStat stat : stats) { + String prevCommit = stat.getPrevCommit(); + if (StringUtils.isNullOrEmpty(prevCommit) || HoodieWriteStat.NULL_COMMIT.equals(prevCommit)) { + continue; + } + Option prev; + try { + prev = fsView.getBaseFileOn(stat.getPartitionPath(), prevCommit, stat.getFileId()); + } catch (Exception e) { + // Best-effort: a remote view 4xx/5xx, a stale view, or a replaced file group must not + // fail the commit. Drop the prev path for this stat and keep going. + log.warn("Could not resolve prev base file for fileId={} prevCommit={}; skipping", + stat.getFileId(), prevCommit, e); + continue; + } + if (!prev.isPresent()) { + continue; + } + HoodieBaseFile prevBaseFile = prev.get(); + Option bootstrapBaseFile = prevBaseFile.getBootstrapBaseFile(); + String prevPath = prevBaseFile.getPath(); + String bootstrapPath = bootstrapBaseFile.isPresent() ? bootstrapBaseFile.get().getPath() : null; + pathsByFileId.put(stat.getFileId(), new PrevFilePaths(prevPath, bootstrapPath)); + } + return pathsByFileId; + } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java index 842dc38177a59..fbd0d5e4f5794 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java @@ -18,7 +18,11 @@ package org.apache.hudi.client; +import org.apache.hudi.avro.model.HoodieCleanMetadata; import org.apache.hudi.callback.HoodieClientInitCallback; +import org.apache.hudi.callback.HoodieWriteCommitCallback; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage; +import org.apache.hudi.callback.util.HoodieCommitCallbackFactory; import org.apache.hudi.client.embedded.EmbeddedTimelineServerHelper; import org.apache.hudi.client.embedded.EmbeddedTimelineService; import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient; @@ -33,6 +37,7 @@ import org.apache.hudi.common.table.timeline.TimeGenerator; import org.apache.hudi.common.table.timeline.TimeGenerators; import org.apache.hudi.common.table.timeline.TimelineUtils; +import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.StringUtils; @@ -58,11 +63,13 @@ import java.io.IOException; import java.io.Serializable; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -85,6 +92,14 @@ public abstract class BaseHoodieClient implements Serializable, AutoCloseable { protected final TransactionManager txnManager; protected final TimeGenerator timeGenerator; + /** + * Lazily-initialized commit callback (HoodieWriteCommitCallback). Lifted from + * {@link BaseHoodieWriteClient} so that {@link BaseHoodieTableServiceClient} can also + * fire callbacks for compaction and clustering completions. Transient is fine + * because the callback is only ever invoked from the driver after a commit. + */ + protected transient HoodieWriteCommitCallback commitCallback; + /** * Timeline Server has the same lifetime as that of Client. Any operations done on the same timeline service will be * able to take advantage of the cached file-system view. New completed actions will be synced automatically in an @@ -313,19 +328,24 @@ protected boolean isStreamingWriteToMetadataEnabled(HoodieTable table) { } /** - * Merges rolling metadata from recent completed commits into the current commit metadata. + * Merges rolling metadata from recent completed instants into the current commit metadata. * This method MUST be called within the transaction lock after conflict resolution. * *

Rolling metadata keys configured via {@link HoodieWriteConfig#ROLLING_METADATA_KEYS} will be - * automatically carried forward from recent commits. The system walks back up to - * {@link HoodieWriteConfig#ROLLING_METADATA_TIMELINE_LOOKBACK_COMMITS} commits to find the most - * recent value for each key. This ensures that important metadata like checkpoint information - * remains accessible without worrying about archival or missing keys in individual commits. + * automatically carried forward from recent instants. The system walks back through completed + * commits and clean instants (in reverse completion-time order) up to + * {@link HoodieWriteConfig#ROLLING_METADATA_TIMELINE_LOOKBACK_COMMITS} to find the most + * recent value for each key. * * @param table HoodieTable instance (may have refreshed timeline after conflict resolution) * @param metadata Current commit metadata to be augmented with rolling metadata */ protected void mergeRollingMetadata(HoodieTable table, HoodieCommitMetadata metadata) { + // IMPORTANT: We're inside the lock here. The timeline in 'table' is either: + // 1. Fresh from createTable() if no conflict resolution happened + // 2. Reloaded during resolveWriteConflict() if conflicts were checked + // In both cases, we have the latest view of the timeline. + // Skip for metadata table - rolling metadata is only for data tables if (table.isMetadataTable()) { return; @@ -336,88 +356,155 @@ protected void mergeRollingMetadata(HoodieTable table, HoodieCommitMetadata meta return; // No rolling metadata configured } - // IMPORTANT: We're inside the lock here. The timeline in 'table' is either: - // 1. Fresh from createTable() if no conflict resolution happened - // 2. Reloaded during resolveWriteConflict() if conflicts were checked - // In both cases, we have the latest view of the timeline. + Map foundRollingMetadata = collectRollingMetadataFromTimeline(table, config, rollingKeys, metadata.getExtraMetadata()); + for (Map.Entry entry : foundRollingMetadata.entrySet()) { + metadata.addMetadata(entry.getKey(), entry.getValue()); + } + } - HoodieTimeline commitsTimeline = table.getActiveTimeline().getCommitsTimeline().filterCompletedInstants(); + /** + * Overload of {@link #mergeRollingMetadata(HoodieTable, HoodieCommitMetadata)} for clean + * commits. Populates {@link HoodieCleanMetadata#getExtraMetadata()} with rolling metadata + * values found on the active timeline. + * + *

This is {@code public static} so that {@code CleanActionExecutor} (which does not extend + * {@code BaseHoodieClient}) can invoke it. + */ + public static void mergeRollingMetadata(HoodieTable table, HoodieWriteConfig config, HoodieCleanMetadata metadata) { + if (table.isMetadataTable()) { + return; + } + Set rollingKeys = config.getRollingMetadataKeys(); + if (rollingKeys.isEmpty()) { + return; + } - if (commitsTimeline.empty()) { - log.info("No previous commits found. Rolling metadata will start with current commit."); - return; // First commit - nothing to roll forward + Map existing = metadata.getExtraMetadata() != null + ? metadata.getExtraMetadata() : Collections.emptyMap(); + Map foundRollingMetadata = collectRollingMetadataFromTimeline(table, config, rollingKeys, existing); + if (!foundRollingMetadata.isEmpty()) { + Map merged = new HashMap<>(existing); + merged.putAll(foundRollingMetadata); + metadata.setExtraMetadata(merged); } + } - try { - Map existingExtraMetadata = metadata.getExtraMetadata(); - Map foundRollingMetadata = new HashMap<>(); - Set remainingKeys = new HashSet<>(rollingKeys); - - // Remove keys that are already present with non-empty values in current commit (current values take precedence) - for (String key : rollingKeys) { - if (existingExtraMetadata.containsKey(key) && !StringUtils.isNullOrEmpty(existingExtraMetadata.get(key))) { - remainingKeys.remove(key); - } - } + /** + * Walks backwards through completed instants (commits, replace-commits, delta-commits, and + * clean) on the active timeline, extracting extra-metadata values for the requested rolling + * keys. For commit-type instants the values come from {@link HoodieCommitMetadata#getMetadata}; + * for clean instants they come from {@link HoodieCleanMetadata#getExtraMetadata()}. + * + *

Keys already present with a non-empty value in {@code existingExtra} are skipped (empty + * strings are treated as "missing"). + */ + private static Map collectRollingMetadataFromTimeline( + HoodieTable table, HoodieWriteConfig config, + Set rollingKeys, Map existingExtra) { - if (remainingKeys.isEmpty()) { - log.debug("All rolling metadata keys are present in current commit. No walkback needed."); - return; - } + Map foundRollingMetadata = new HashMap<>(); + Set remaining = new HashSet<>(rollingKeys); - int lookbackLimit = config.getRollingMetadataTimelineLookbackCommits(); - int commitsWalkedBack = 0; + for (String key : rollingKeys) { + if (existingExtra.containsKey(key) && !StringUtils.isNullOrEmpty(existingExtra.get(key))) { + remaining.remove(key); + } + } + if (remaining.isEmpty()) { + log.debug("All rolling metadata keys already present. No walkback needed."); + return foundRollingMetadata; + } - // Walk back through the timeline in reverse order (most recent first) to find values for all remaining keys - List recentCommits = commitsTimeline.getReverseOrderedInstantsByCompletionTime() - .limit(lookbackLimit) - .collect(Collectors.toList()); + int lookbackLimit = config.getRollingMetadataTimelineLookbackCommits(); + HoodieTimeline completed = table.getActiveTimeline().filterCompletedInstants(); + List instants = completed.getReverseOrderedInstantsByCompletionTime() + .filter(i -> HoodieTimeline.VALID_ACTIONS_FOR_ROLLING_METADATA.contains(i.getAction())) + .limit(lookbackLimit) + .collect(Collectors.toList()); - log.debug("Walking back up to {} commits to find rolling metadata for keys: {}", - lookbackLimit, remainingKeys); + log.debug("Walking back up to {} instants to find rolling metadata for keys: {}", lookbackLimit, remaining); + int instantsWalkedBack = 0; - for (HoodieInstant instant : recentCommits) { - if (remainingKeys.isEmpty()) { - break; // Found all keys + try { + for (HoodieInstant instant : instants) { + if (remaining.isEmpty()) { + break; } + String action = instant.getAction(); + Map extraMeta = null; - commitsWalkedBack++; - HoodieCommitMetadata commitMetadata = table.getMetaClient().getActiveTimeline().readInstantContent(instant, HoodieCommitMetadata.class); + if (HoodieTimeline.CLEAN_ACTION.equals(action)) { + HoodieCleanMetadata cleanMeta = table.getActiveTimeline().readCleanMetadata(instant); + extraMeta = cleanMeta.getExtraMetadata(); + } else { + HoodieCommitMetadata commitMeta = table.getMetaClient().getActiveTimeline() + .readInstantContent(instant, HoodieCommitMetadata.class); + extraMeta = commitMeta.getExtraMetadata(); + } + instantsWalkedBack++; - // Check for remaining keys in this commit - for (String key : new HashSet<>(remainingKeys)) { - String value = commitMetadata.getMetadata(key); + if (extraMeta == null) { + continue; + } + for (String key : new HashSet<>(remaining)) { + String value = extraMeta.get(key); if (!StringUtils.isNullOrEmpty(value)) { foundRollingMetadata.put(key, value); - remainingKeys.remove(key); - log.debug("Found rolling metadata key '{}' in commit {} with value: {}", - key, instant.requestedTime(), value); + remaining.remove(key); + log.debug("Found rolling metadata key '{}' in {} instant {} with value: {}", + key, action, instant.requestedTime(), value); } } } - // Add found rolling metadata to current commit - for (Map.Entry entry : foundRollingMetadata.entrySet()) { - metadata.addMetadata(entry.getKey(), entry.getValue()); + if (!foundRollingMetadata.isEmpty() || !remaining.isEmpty()) { + log.info("Rolling metadata: walked {} instants. Rolled forward: {}, Not found: {}, Total keys: {}", + instantsWalkedBack, foundRollingMetadata.size(), remaining.size(), rollingKeys.size()); + } + if (!remaining.isEmpty()) { + log.warn("Rolling metadata keys not found in last {} instants: {}.", instantsWalkedBack, remaining); } + } catch (IOException e) { + log.error("Failed to read previous metadata for rolling metadata keys: {}.", rollingKeys, e); + throw new HoodieIOException("Failed to read previous metadata for rolling keys: " + rollingKeys, e); + } - int rolledForwardCount = foundRollingMetadata.size(); - int updatedCount = rollingKeys.size() - remainingKeys.size() - rolledForwardCount; + return foundRollingMetadata; + } - if (rolledForwardCount > 0 || updatedCount > 0 || !remainingKeys.isEmpty()) { - log.info("Rolling metadata merge completed. Walked back {} commits. " - + "Rolled forward: {}, Updated in current: {}, Not found: {}, Total rolling keys: {}", - commitsWalkedBack, rolledForwardCount, updatedCount, remainingKeys.size(), rollingKeys.size()); - } + protected Option> updateExtraMetadata(Option> extraMetadata) { + return CommitMetadataProperties.enrich(extraMetadata, config, context); + } - if (!remainingKeys.isEmpty()) { - log.warn("Rolling metadata keys not found in last {} commits: {}. " - + "These keys will not be included in the current commit.", lookbackLimit, remainingKeys); + /** + * Fire {@link HoodieWriteCommitCallback} for a commit, if enabled. Shared by + * {@link BaseHoodieWriteClient#postCommit} (regular auto- and explicit-commit paths) + * and {@link BaseHoodieTableServiceClient} (compaction and clustering completions). + * Lazily constructs the callback instance from {@code hoodie.write.commit.callback.class}. + * + *

Best-effort: catches and logs any exception from the user-supplied callback so a + * misbehaving observer cannot fail the commit. + */ + protected void fireCommitCallbackIfNecessary(String commitTime, + String commitActionType, + List stats, + Supplier fsViewSupplier, + Option> extraMetadata) { + if (!config.writeCommitCallbackOn()) { + return; + } + try { + if (commitCallback == null) { + commitCallback = HoodieCommitCallbackFactory.create(config); } - - } catch (IOException e) { - log.error("Failed to read previous commit metadata for rolling metadata keys: {}.", rollingKeys, e); - throw new HoodieIOException("Failed to read previous commit metadata for rolling metadata keys: " + rollingKeys, e); + commitCallback.call(new HoodieWriteCommitCallbackMessage( + commitTime, config.getTableName(), config.getBasePath(), + stats, Option.of(commitActionType), extraMetadata, + fsViewSupplier, + Collections.emptyMap())); + } catch (Exception e) { + log.warn("HoodieWriteCommitCallback failed for commit {} ({}); ignoring", + commitTime, commitActionType, e); } } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieTableServiceClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieTableServiceClient.java index 17106d8d940e5..59a4f2d4db682 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieTableServiceClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieTableServiceClient.java @@ -425,6 +425,8 @@ protected void completeCompaction(HoodieCommitMetadata metadata, HoodieTable tab ); } log.info("Compacted successfully on commit {}", compactionCommitTime); + fireCommitCallbackIfNecessary(compactionCommitTime, HoodieTimeline.COMMIT_ACTION, + writeStats, table::getBaseFileOnlyView, Option.empty()); } finally { if (config.getWriteConcurrencyMode().supportsMultiWriter()) { this.heartbeatClient.stop(compactionCommitTime); @@ -497,6 +499,8 @@ protected void completeLogCompaction(HoodieCommitMetadata metadata, HoodieTable ); } log.info("Log Compacted successfully on commit {}", logCompactionCommitTime); + fireCommitCallbackIfNecessary(logCompactionCommitTime, HoodieTimeline.DELTA_COMMIT_ACTION, + writeStats, table::getBaseFileOnlyView, Option.empty()); } /** @@ -641,6 +645,8 @@ private void completeClustering(HoodieReplaceCommitMetadata replaceCommitMetadat heartbeatClient.stop(clusteringCommitTime); } log.info("Clustering successfully on commit {} for table {}", clusteringCommitTime, table.getConfig().getBasePath()); + fireCommitCallbackIfNecessary(clusteringCommitTime, HoodieTimeline.REPLACE_COMMIT_ACTION, + writeStats, table::getBaseFileOnlyView, Option.empty()); } protected void runTableServicesInline(HoodieTable table, HoodieCommitMetadata metadata, Option> extraMetadata) { @@ -726,6 +732,8 @@ Option scheduleTableServiceInternal(Option providedInstantTime, // so it is handled differently to avoid locking for planning. return scheduleCleaning(createTable(config, storageConf), providedInstantTime); } + // Only enrich metadata after early-return checks, when we're actually going to use it + extraMetadata = updateExtraMetadata(extraMetadata); Option lastCompletedInstant = lastCompletedTxnAndMetadata.isPresent() ? Option.of(lastCompletedTxnAndMetadata.get().getLeft()) : Option.empty(); @@ -1430,7 +1438,7 @@ private Option delegateToTableServiceManager(TableServiceType tableServi case CLEAN: return tableServiceManagerClient.executeClean(); default: - log.info("Not supported delegate to table service manager, tableServiceType : " + tableServiceType.getAction()); + log.info("Not supported delegate to table service manager, tableServiceType : {}", tableServiceType.getAction()); return Option.empty(); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java index df06df2fbbba9..d97ea7dbb7cd1 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java @@ -24,10 +24,7 @@ import org.apache.hudi.avro.model.HoodieRestoreMetadata; import org.apache.hudi.avro.model.HoodieRestorePlan; import org.apache.hudi.avro.model.HoodieRollbackMetadata; -import org.apache.hudi.callback.HoodieWriteCommitCallback; -import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage; import org.apache.hudi.callback.common.WriteStatusValidator; -import org.apache.hudi.callback.util.HoodieCommitCallbackFactory; import org.apache.hudi.client.embedded.EmbeddedTimelineService; import org.apache.hudi.client.heartbeat.HeartbeatUtils; import org.apache.hudi.client.transaction.TransactionManager; @@ -86,12 +83,14 @@ import org.apache.hudi.internal.schema.io.FileBasedInternalSchemaStorageManager; import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils; import org.apache.hudi.internal.schema.utils.InternalSchemaUtils; +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils; import org.apache.hudi.internal.schema.utils.SerDeHelper; import org.apache.hudi.keygen.constant.KeyGeneratorType; import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.metadata.HoodieTableMetadataWriter; import org.apache.hudi.metadata.MetadataPartitionType; import org.apache.hudi.metrics.HoodieMetrics; +import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.BulkInsertPartitioner; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.HoodieWriteMetadata; @@ -145,7 +144,6 @@ public abstract class BaseHoodieWriteClient extends BaseHoodieClient @Getter @Setter private transient WriteOperationType operationType; - private transient HoodieWriteCommitCallback commitCallback; protected transient Timer.Context writeTimer = null; @@ -253,6 +251,7 @@ public boolean commitStats(String instantTime, TableWriteStats tableWriteStats, if (!config.allowEmptyCommit() && tableWriteStats.isEmptyDataTableWriteStats()) { return true; } + extraMetadata = updateExtraMetadata(extraMetadata); log.info("Committing {} action {}", instantTime, commitActionType); // Create a Hoodie table which encapsulated the commits and files visible HoodieTable table = hoodieTableOpt.orElse(createTable(config)); @@ -285,7 +284,7 @@ public boolean commitStats(String instantTime, TableWriteStats tableWriteStats, boolean postCommitStatus = true; HoodieTimer postCommitTimer = HoodieTimer.start(); try { - postCommit(table, metadata, instantTime, extraMetadata); + postCommit(table, metadata, instantTime, commitActionType, extraMetadata); mayBeCleanAndArchive(table); runTableServicesInline(table, metadata, extraMetadata); } catch (Exception e) { @@ -301,15 +300,6 @@ public boolean commitStats(String instantTime, TableWriteStats tableWriteStats, } emitCommitMetrics(instantTime, metadata, commitActionType); - - // callback if needed. - if (config.writeCommitCallbackOn()) { - if (null == commitCallback) { - commitCallback = HoodieCommitCallbackFactory.create(config); - } - commitCallback.call(new HoodieWriteCommitCallbackMessage( - instantTime, config.getTableName(), config.getBasePath(), tableWriteStats.getDataTableWriteStats(), Option.of(commitActionType), extraMetadata)); - } return true; } @@ -367,7 +357,10 @@ private void saveInternalSchema(HoodieTable table, String instantTime, HoodieCom internalSchema = InternalSchemaUtils.searchSchema(Long.parseLong(instantTime), SerDeHelper.parseSchemas(historySchemaStr)); } - InternalSchema evolvedSchema = AvroSchemaEvolutionUtils.reconcileSchema(schema.toAvroSchema(), internalSchema, config.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS)); + InternalSchema evolvedSchema = AvroSchemaEvolutionUtils.reconcileSchema(schema, internalSchema, + config.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + config.getStringOrDefault(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES))); if (evolvedSchema.equals(internalSchema)) { metadata.addMetadata(SerDeHelper.LATEST_SCHEMA, SerDeHelper.toJson(evolvedSchema)); //TODO save history schema by metaTable @@ -637,7 +630,9 @@ public O postWrite(HoodieWriteMetadata result, String instantTime, HoodieTabl boolean postCommitStatus = true; HoodieTimer postCommitTimer = HoodieTimer.start(); try { - postCommit(hoodieTable, result.getCommitMetadata().get(), instantTime, Option.empty()); + String commitActionType = CommitUtils.getCommitActionType(operationType, hoodieTable.getMetaClient().getTableType()); + postCommit(hoodieTable, result.getCommitMetadata().get(), instantTime, + commitActionType, Option.empty()); mayBeCleanAndArchive(hoodieTable); } catch (Exception e) { postCommitStatus = false; @@ -664,8 +659,37 @@ public O postWrite(HoodieWriteMetadata result, String instantTime, HoodieTabl * @param instantTime Instant Time * @param extraMetadata Additional Metadata passed by user */ - protected void postCommit(HoodieTable table, HoodieCommitMetadata metadata, String instantTime, Option> extraMetadata) { + protected void postCommit(HoodieTable table, HoodieCommitMetadata metadata, String instantTime, String commitActionType, Option> extraMetadata) { + try { + context.setJobStatus(this.getClass().getSimpleName(), "Cleaning up marker directories for commit " + instantTime + " in table " + + config.getTableName()); + // Delete the marker directory for the instant. + WriteMarkersFactory.get(config.getMarkersType(), table, instantTime) + .quietDeleteMarkerDir(context, config.getMarkersDeleteParallelism()); + metrics.updateTableServiceInstantMetrics(table.getActiveTimeline()); + // Fire write commit callback if a callback class is registered. postCommit() is reached + // by both auto-commit and explicit-commit paths; compaction and clustering have their own + // explicit fireCommitCallbackIfNecessary call sites in BaseHoodieTableServiceClient. + List stats = metadata.getWriteStats(); + fireCommitCallbackIfNecessary(instantTime, commitActionType, stats, + table::getBaseFileOnlyView, extraMetadata); + } finally { + this.heartbeatClient.stop(instantTime); + } + } + + /** + * Performs post-commit cleanup when the instant is already completed and commit metadata is not + * available to invoke the regular post-commit hook. This can happen while recovering a streaming + * metadata-table write after failover. The table is recreated from the write configuration so its + * marker directory can still be removed, and the heartbeat is always stopped even if marker cleanup + * fails. + * + * @param instantTime the completed instant to clean up + */ + public void postCommit(String instantTime) { try { + HoodieTable table = createTable(config); context.setJobStatus(this.getClass().getSimpleName(), "Cleaning up marker directories for commit " + instantTime + " in table " + config.getTableName()); // Delete the marker directory for the instant. @@ -846,44 +870,11 @@ public void restoreToSavepoint() { */ public void restoreToSavepoint(String savepointTime) { boolean initializeMetadataTableIfNecessary = config.isMetadataTableEnabled(); - if (initializeMetadataTableIfNecessary) { - try { - // Delete metadata table directly when users trigger savepoint rollback if mdt existed and if the savePointTime is beforeTimelineStarts - // or before the oldest compaction on MDT. - // We cannot restore to before the oldest compaction on MDT as we don't have the basefiles before that time. - HoodieTableMetaClient mdtMetaClient = HoodieTableMetaClient.builder() - .setConf(storageConf.newInstance()) - .setBasePath(getMetadataTableBasePath(config.getBasePath())).build(); - Option oldestMdtCompaction = mdtMetaClient.getCommitTimeline().filterCompletedInstants().firstInstant(); - boolean deleteMDT = false; - if (oldestMdtCompaction.isPresent()) { - if (LESSER_THAN_OR_EQUALS.test(savepointTime, oldestMdtCompaction.get().requestedTime())) { - log.warn("Deleting MDT during restore to {} as the savepoint is older than oldest compaction {} on MDT", - savepointTime, oldestMdtCompaction.get().requestedTime()); - deleteMDT = true; - } - } - - // The instant required to sync rollback to MDT has been archived and the mdt syncing will be failed - // So that we need to delete the whole MDT here. - if (!deleteMDT) { - HoodieInstant syncedInstant = mdtMetaClient.createNewInstant(HoodieInstant.State.COMPLETED, HoodieTimeline.DELTA_COMMIT_ACTION, savepointTime); - if (mdtMetaClient.getCommitsTimeline().isBeforeTimelineStarts(syncedInstant.requestedTime())) { - log.warn("Deleting MDT during restore to {} as the savepoint is older than the MDT timeline {}", - savepointTime, mdtMetaClient.getCommitsTimeline().firstInstant().get().requestedTime()); - deleteMDT = true; - } - } - - if (deleteMDT) { - HoodieTableMetadataUtil.deleteMetadataTable(config.getBasePath(), context); - // rollbackToSavepoint action will try to bootstrap MDT at first but sync to MDT will fail at the current scenario. - // so that we need to disable metadata initialized here. - initializeMetadataTableIfNecessary = false; - } - } catch (Exception e) { - // Metadata directory does not exist - } + if (initializeMetadataTableIfNecessary && shouldDeleteMdtBeforeRestore(savepointTime)) { + HoodieTableMetadataUtil.deleteMetadataTable(config.getBasePath(), context); + // rollbackToSavepoint action will try to bootstrap MDT at first but sync to MDT will fail at the current scenario. + // so that we need to disable metadata initialized here. + initializeMetadataTableIfNecessary = false; } HoodieTable table = initTable(WriteOperationType.UNKNOWN, Option.empty(), initializeMetadataTableIfNecessary); @@ -894,6 +885,82 @@ public void restoreToSavepoint(String savepointTime) { SavepointHelpers.validateSavepointRestore(table, savepointTime); } + /** + * Decides whether the metadata table (MDT) must be deleted before restoring the data table to + * {@code targetInstant}. Returns true when restoring would leave the MDT in an inconsistent + * state, specifically when any of the following holds: + *

    + *
  1. The target is at or before the oldest completed compaction. We cannot restore to before + * the oldest compaction because we don't have base files before that time.
  2. + *
  3. The target is before the MDT timeline start (the relevant history was archived away).
  4. + *
+ * Returns false when the MDT directory does not exist or is not readable (nothing to delete or + * worry about). Wraps genuine IO failures ({@link IOException}) in a {@link HoodieException} + * so permission / network errors surface to the caller. + */ + protected boolean shouldDeleteMdtBeforeRestore(String targetInstant) { + String mdtBasePath = getMetadataTableBasePath(config.getBasePath()); + try { + // Cheap existence check first to avoid constructing an MDT meta client when there is no MDT. + if (!storage.exists(new StoragePath(mdtBasePath))) { + return false; + } + HoodieTableMetaClient mdtMetaClient = HoodieTableMetaClient.builder() + .setConf(storageConf.newInstance()) + .setBasePath(mdtBasePath).build(); + List completedCompactions = mdtMetaClient.getCommitTimeline() + .filterCompletedInstants().getInstants(); + Option oldestMdtCompaction = completedCompactions.isEmpty() + ? Option.empty() : Option.of(completedCompactions.get(0)); + if (oldestMdtCompaction.isPresent() + && LESSER_THAN_OR_EQUALS.test(targetInstant, oldestMdtCompaction.get().requestedTime())) { + log.warn("Deleting MDT before restore to {}: target is at or before oldest MDT compaction {}", + targetInstant, oldestMdtCompaction.get().requestedTime()); + return true; + } + if (mdtMetaClient.getCommitsTimeline().isBeforeTimelineStarts(targetInstant)) { + log.warn("Deleting MDT before restore to {}: target is before MDT timeline start", targetInstant); + return true; + } + return false; + } catch (IOException e) { + throw new HoodieException( + "Failed to inspect MDT at " + mdtBasePath + " before restore to " + targetInstant + + " - refusing to silently proceed without an MDT integrity check.", e); + } catch (HoodieException e) { + // MDT directory exists but is not usable (e.g. TableNotFoundException from a partially + // initialized MDT). Treat as absent: no deletion needed, let the restore proceed. + log.warn("MDT at {} is present but could not be read ({}); skipping pre-check.", + mdtBasePath, e.getMessage()); + return false; + } + } + + /** + * Deletes the metadata table (MDT) if it would be left in an inconsistent state by a restore + * to {@code targetInstant}, and returns whether the MDT was actually deleted. + * + *

Callers that drive restore via {@link #restoreToInstant} directly (e.g. the + * {@code restore_to_instant} stored procedure) should call this method before invoking + * {@code restoreToInstant} and suppress MDT initialization when it returns {@code true}: + * + *

{@code
+   * boolean mdtDeleted = client.deleteMdtIfNecessaryBeforeRestore(targetInstant);
+   * client.restoreToInstant(targetInstant, !mdtDeleted && enableMetadata);
+   * }
+ * + * @param targetInstant the instant the data table will be restored to + * @return {@code true} if the MDT was deleted (caller must not re-initialize it); + * {@code false} otherwise (MDT either did not need deletion or does not exist) + */ + public boolean deleteMdtIfNecessaryBeforeRestore(String targetInstant) { + if (shouldDeleteMdtBeforeRestore(targetInstant)) { + HoodieTableMetadataUtil.deleteMetadataTable(config.getBasePath(), context); + return true; + } + return false; + } + @Deprecated public boolean rollback(final String commitInstantTime) throws HoodieRollbackException { HoodieTable table = initTable(WriteOperationType.UNKNOWN, Option.empty()); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CommitMetadataProperties.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CommitMetadataProperties.java new file mode 100644 index 0000000000000..3a7b79812c8e7 --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CommitMetadataProperties.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.client; + +import org.apache.hudi.HoodieVersion; +import org.apache.hudi.common.config.ConfigProperty; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Enriches the {@code extraMetadata} map persisted with every commit, with version, engine, and + * (optionally) engine-specific properties and a configurable subset of {@link HoodieWriteConfig} + * values. + * + *

Key namespacing: + *

    + *
  • {@code hudi.version} — writer version. Always emitted.
  • + *
  • {@code engine} — engine type (SPARK/FLINK/JAVA). Always emitted.
  • + *
  • Engine-supplied keys (Spark: {@code spark.*}, Java: {@code java.*}/{@code os.*}, etc.) + * — gated by {@link #EMBED_ENGINE_PROPERTIES_IN_COMMIT_METADATA}.
  • + *
  • {@code config.} — values of {@link HoodieWriteConfig} entries whose keys are listed + * in {@link #WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA}.
  • + *
+ */ +public class CommitMetadataProperties { + + static final String HUDI_VERSION_KEY = "hudi.version"; + static final String ENGINE_KEY = "engine"; + static final String CONFIG_KEY_PREFIX = "config."; + + /** + * Default allowlist of write-config keys serialized into commit metadata. These are values that + * change across jobs/runs but aren't already captured in {@code hoodie.properties}, so they're + * useful for after-the-fact debugging. Intentionally excludes immutable table identity + * (already in {@code hoodie.properties}) and per-record/sensitive values. + */ + private static final String DEFAULT_WRITE_CONFIG_KEYS = String.join(",", + Arrays.asList( + "hoodie.datasource.write.operation", + "hoodie.insert.shuffle.parallelism", + "hoodie.upsert.shuffle.parallelism", + "hoodie.bulkinsert.shuffle.parallelism", + "hoodie.delete.shuffle.parallelism", + "hoodie.write.concurrency.mode", + "hoodie.metadata.enable")); + + /** + * When enabled, engine-specific properties supplied by + * {@link HoodieEngineContext#getEngineProperties()} are embedded into commit metadata for + * debugging (e.g. {@code spark.application.id}, {@code spark.user}). {@code hudi.version} and + * {@code engine} are always embedded regardless of this flag. + * + *

Default is {@code false} since these add per-commit growth to the timeline. Long-running + * ingestion workloads writing many commits should leave this off unless debugging. + */ + public static final ConfigProperty EMBED_ENGINE_PROPERTIES_IN_COMMIT_METADATA = + ConfigProperty + .key("hoodie.commit.metadata.engine.properties.embed.enable") + .defaultValue(false) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("When enabled, engine-specific properties (e.g. spark.application.id, " + + "spark.user, java.version) are embedded into commit metadata for debugging. " + + "hudi.version and engine name are always embedded regardless of this flag."); + + /** + * Comma-separated list of {@link HoodieWriteConfig} keys whose values should be serialized into + * commit metadata under the {@code config.} prefix. Use with care: every key listed here + * adds an entry to every commit, which lives forever in the active and archived timeline. + * + *

Empty value disables config-key serialization entirely (only {@code hudi.version} and + * {@code engine} are emitted). + */ + public static final ConfigProperty WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA = + ConfigProperty + .key("hoodie.write.config.keys.to.serialize.to.commit.metadata") + .defaultValue(DEFAULT_WRITE_CONFIG_KEYS) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("Comma-separated list of write-config keys whose values are " + + "serialized into the extraMetadata map of every commit (under the 'config.' " + + "prefix). Set to empty to skip config-key serialization entirely. Avoid adding " + + "keys whose values may contain credentials or large payloads, since commit " + + "metadata is persisted in the timeline."); + + public static Option> enrich(Option> extraMetadata, + HoodieWriteConfig config, + HoodieEngineContext context) { + Map newMetadata = new HashMap<>(); + if (extraMetadata.isPresent()) { + newMetadata.putAll(extraMetadata.get()); + } + + newMetadata.put(HUDI_VERSION_KEY, HoodieVersion.get()); + newMetadata.put(ENGINE_KEY, config.getEngineType().name()); + + if (config.getBoolean(EMBED_ENGINE_PROPERTIES_IN_COMMIT_METADATA)) { + newMetadata.putAll(context.getEngineProperties()); + } + + for (String key : parseConfigKeys(config.getString(WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA))) { + String value = config.getString(key); + if (!StringUtils.isNullOrEmpty(value)) { + newMetadata.put(CONFIG_KEY_PREFIX + key, value); + } + } + + return Option.of(newMetadata); + } + + private static List parseConfigKeys(String csv) { + if (StringUtils.isNullOrEmpty(csv)) { + return Collections.emptyList(); + } + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java index 32be4b2741390..6abe10c6cca75 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java @@ -299,13 +299,12 @@ private List runRenamingOps(HoodieTableMetaClient metaClient, context.setJobStatus(this.getClass().getSimpleName(), "Execute unschedule operations: " + config.getTableName()); return context.map(renameActions, lfPair -> { try { - log.info("RENAME " + lfPair.getLeft().getPath() + " => " + lfPair.getRight().getPath()); + log.info("RENAME {} => {}", lfPair.getLeft().getPath(), lfPair.getRight().getPath()); renameLogFile(metaClient, lfPair.getLeft(), lfPair.getRight()); return new RenameOpResult(lfPair, true, Option.empty()); } catch (IOException e) { log.error("Error renaming log file", e); - log.error("\n\n\n***NOTE Compaction is in inconsistent state. Try running \"compaction repair " - + lfPair.getLeft().getDeltaCommitTime() + "\" to recover from failure ***\n\n\n"); + log.error("\n\n\n***NOTE Compaction is in inconsistent state. Try running \"compaction repair {}\" to recover from failure ***\n\n\n", lfPair.getLeft().getDeltaCommitTime()); return new RenameOpResult(lfPair, false, Option.of(e)); } }, parallelism); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java index 4f13034c89df8..ec7dd1b30b0e8 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java @@ -93,7 +93,7 @@ private String executeRequest(String requestPath, Map queryParam queryParameters.forEach(builder::addParameter); String url = builder.toString(); - log.info("Sending request to table management service : (" + url + ")"); + log.info("Sending request to table management service : ({})", url); int timeoutMs = this.config.getConnectionTimeoutSec() * 1000; int requestRetryLimit = config.getConnectionRetryLimit(); int connectionRetryDelay = config.getConnectionRetryDelay(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/bootstrap/selector/BootstrapRegexModeSelector.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/bootstrap/selector/BootstrapRegexModeSelector.java index 65fda8e6cf7df..6869016cf8851 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/bootstrap/selector/BootstrapRegexModeSelector.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/bootstrap/selector/BootstrapRegexModeSelector.java @@ -48,7 +48,7 @@ public BootstrapRegexModeSelector(HoodieWriteConfig writeConfig) { this.bootstrapModeOnMatch = writeConfig.getBootstrapModeForRegexMatch(); this.defaultMode = BootstrapMode.FULL_RECORD.equals(bootstrapModeOnMatch) ? BootstrapMode.METADATA_ONLY : BootstrapMode.FULL_RECORD; - log.info("Default Mode :" + defaultMode + ", on Match Mode :" + bootstrapModeOnMatch); + log.info("Default Mode :{}, on Match Mode :{}", defaultMode, bootstrapModeOnMatch); } @Override diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java index 2df9d0940d5b2..7caeab7ead3d9 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java @@ -97,7 +97,7 @@ static EmbeddedTimelineService getOrStartEmbeddedTimelineService(HoodieEngineCon synchronized (SERVICE_LOCK) { if (RUNNING_SERVICES.containsKey(timelineServiceIdentifier)) { RUNNING_SERVICES.get(timelineServiceIdentifier).addBasePath(writeConfig.getBasePath()); - log.info("Reusing existing embedded timeline server with configuration: " + RUNNING_SERVICES.get(timelineServiceIdentifier).serviceConfig); + log.info("Reusing existing embedded timeline server with configuration: {}", RUNNING_SERVICES.get(timelineServiceIdentifier).serviceConfig); return RUNNING_SERVICES.get(timelineServiceIdentifier); } // if no compatible instance is found, create a new one diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java index a043f73e632c5..b8f2f15fdf0a4 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/heartbeat/HoodieHeartbeatClient.java @@ -19,6 +19,7 @@ package org.apache.hudi.client.heartbeat; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.util.CustomizedThreadFactory; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieHeartbeatException; @@ -35,9 +36,15 @@ import java.io.OutputStream; import java.io.Serializable; import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import static org.apache.hudi.common.heartbeat.HoodieHeartbeatUtils.getLastHeartbeatTime; @@ -58,7 +65,16 @@ public class HoodieHeartbeatClient implements AutoCloseable, Serializable { // heartbeat interval in millis private final Long heartbeatIntervalInMs; private final Long maxAllowableHeartbeatIntervalInMs; + // Maximum time the scheduler thread will wait for a single heartbeat file write to complete before + // abandoning it and letting the next tick retry. Bounded to one interval so that a slow/hung + // storage write cannot block the scheduler thread (and thus freeze all subsequent heartbeats). + private final Long heartbeatWriteTimeoutMs; private final Map instantToHeartbeatMap; + // Daemon executor used to perform the (potentially slow) storage write off the scheduler thread so the + // write can be time-bounded. A cached pool is intentional: if one write hangs, that thread is left + // parked while the next tick proceeds on a fresh thread. Lazily created and marked transient since + // this client is Serializable with a transient storage handle. + private transient ExecutorService heartbeatWriteExecutor; public HoodieHeartbeatClient(HoodieStorage storage, String basePath, Long heartbeatIntervalInMs, Integer numTolerableHeartbeatMisses) { @@ -68,9 +84,18 @@ public HoodieHeartbeatClient(HoodieStorage storage, String basePath, Long heartb this.heartbeatFolderPath = HoodieTableMetaClient.getHeartbeatFolderPath(basePath); this.heartbeatIntervalInMs = heartbeatIntervalInMs; this.maxAllowableHeartbeatIntervalInMs = this.heartbeatIntervalInMs * numTolerableHeartbeatMisses; + this.heartbeatWriteTimeoutMs = this.heartbeatIntervalInMs; this.instantToHeartbeatMap = new ConcurrentHashMap<>(); } + private synchronized ExecutorService getHeartbeatWriteExecutor() { + if (heartbeatWriteExecutor == null) { + heartbeatWriteExecutor = + Executors.newCachedThreadPool(new CustomizedThreadFactory("heartbeat_write", true)); + } + return heartbeatWriteExecutor; + } + @Data static class Heartbeat { @@ -79,10 +104,12 @@ static class Heartbeat { private boolean isHeartbeatStopped = false; private Long lastHeartbeatTime; private Integer numHeartbeats = 0; - private Timer timer = new Timer(true); + private ScheduledExecutorService heartbeatScheduler = + Executors.newSingleThreadScheduledExecutor(new CustomizedThreadFactory("heartbeat_scheduler", true)); + private ScheduledFuture scheduledFuture; } - class HeartbeatTask extends TimerTask { + class HeartbeatTask implements Runnable { private final String instantTime; @@ -92,7 +119,11 @@ class HeartbeatTask extends TimerTask { @Override public void run() { - updateHeartbeat(instantTime); + try { + updateHeartbeat(instantTime); + } catch (Exception e) { + log.error("Failed to update heartbeat for instant {}; will retry on next tick", instantTime, e); + } } } @@ -114,11 +145,11 @@ public void start(String instantTime) { newHeartbeat.setHeartbeatStarted(true); instantToHeartbeatMap.put(instantTime, newHeartbeat); // Ensure heartbeat is generated for the first time with this blocking call. - // Since timer submits the task to a thread, no guarantee when that thread will get CPU + // Since scheduler submits the task to a thread, no guarantee when that thread will get CPU // cycles to generate the first heartbeat. updateHeartbeat(instantTime); - newHeartbeat.getTimer().scheduleAtFixedRate(new HeartbeatTask(instantTime), this.heartbeatIntervalInMs, - this.heartbeatIntervalInMs); + newHeartbeat.setScheduledFuture(newHeartbeat.getHeartbeatScheduler().scheduleAtFixedRate( + new HeartbeatTask(instantTime), this.heartbeatIntervalInMs, this.heartbeatIntervalInMs, TimeUnit.MILLISECONDS)); } /** @@ -130,7 +161,7 @@ public void start(String instantTime) { public Heartbeat stop(String instantTime) throws HoodieException { Heartbeat heartbeat = instantToHeartbeatMap.remove(instantTime); if (isHeartbeatStarted(heartbeat)) { - stopHeartbeatTimer(heartbeat); + stopHeartbeatScheduler(heartbeat); HeartbeatUtils.deleteHeartbeatFile(storage, basePath, instantTime); log.info("Deleted heartbeat file for instant {}", instantTime); } @@ -138,12 +169,12 @@ public Heartbeat stop(String instantTime) throws HoodieException { } /** - * Stops all timers of heartbeats started via this instance of the client. + * Stops all heartbeat schedulers started via this instance of the client. * * @throws HoodieException */ public void stopHeartbeatTimers() throws HoodieException { - instantToHeartbeatMap.values().stream().filter(this::isHeartbeatStarted).forEach(this::stopHeartbeatTimer); + instantToHeartbeatMap.values().stream().filter(this::isHeartbeatStarted).forEach(this::stopHeartbeatScheduler); } /** @@ -158,17 +189,24 @@ private boolean isHeartbeatStarted(Heartbeat heartbeat) { } /** - * Stops the timer of the given heartbeat. + * Stops the scheduler of the given heartbeat. * * @param heartbeat The heartbeat to stop. */ - private void stopHeartbeatTimer(Heartbeat heartbeat) { + private void stopHeartbeatScheduler(Heartbeat heartbeat) { log.info("Stopping heartbeat for instant {}", heartbeat.getInstantTime()); - heartbeat.getTimer().cancel(); + shutdownHeartbeatScheduler(heartbeat); heartbeat.setHeartbeatStopped(true); log.info("Stopped heartbeat for instant {}", heartbeat.getInstantTime()); } + private void shutdownHeartbeatScheduler(Heartbeat heartbeat) { + if (heartbeat.getScheduledFuture() != null) { + heartbeat.getScheduledFuture().cancel(false); + } + heartbeat.getHeartbeatScheduler().shutdownNow(); + } + public static Boolean heartbeatExists(HoodieStorage storage, String basePath, String instantTime) throws IOException { StoragePath heartbeatFilePath = new StoragePath( HoodieTableMetaClient.getHeartbeatFolderPath(basePath), instantTime); @@ -178,17 +216,18 @@ public static Boolean heartbeatExists(HoodieStorage storage, String basePath, St public boolean isHeartbeatExpired(String instantTime) throws IOException { Long currentTime = System.currentTimeMillis(); Heartbeat lastHeartbeatForWriter = instantToHeartbeatMap.get(instantTime); - if (lastHeartbeatForWriter == null) { - log.info("Heartbeat not found in internal map, falling back to reading from DFS"); - long lastHeartbeatForWriterTime = getLastHeartbeatTime(this.storage, basePath, instantTime); - lastHeartbeatForWriter = new Heartbeat(); - lastHeartbeatForWriter.setLastHeartbeatTime(lastHeartbeatForWriterTime); - lastHeartbeatForWriter.setInstantTime(instantTime); - lastHeartbeatForWriter.getTimer().cancel(); + Long lastHeartbeatTime = lastHeartbeatForWriter == null ? null : lastHeartbeatForWriter.getLastHeartbeatTime(); + // lastHeartbeatTime can be null when the heartbeat is not in the internal map, or when it is in the + // map but no heartbeat has been generated yet (e.g. the first write timed out). In both cases fall + // back to reading the last heartbeat time from DFS (returns 0 if no heartbeat file exists, which is + // correctly treated as expired). + if (lastHeartbeatTime == null) { + log.info("Heartbeat time not available in internal map, falling back to reading from DFS"); + lastHeartbeatTime = getLastHeartbeatTime(this.storage, basePath, instantTime); } - if (currentTime - lastHeartbeatForWriter.getLastHeartbeatTime() > this.maxAllowableHeartbeatIntervalInMs) { + if (currentTime - lastHeartbeatTime > this.maxAllowableHeartbeatIntervalInMs) { log.warn("Heartbeat expired, currentTime = {}, last heartbeat = {}, heartbeat interval = {}", currentTime, - lastHeartbeatForWriter, this.heartbeatIntervalInMs); + lastHeartbeatTime, this.heartbeatIntervalInMs); return true; } return false; @@ -197,20 +236,31 @@ public boolean isHeartbeatExpired(String instantTime) throws IOException { private void updateHeartbeat(String instantTime) throws HoodieHeartbeatException { try { Long newHeartbeatTime = System.currentTimeMillis(); - OutputStream outputStream = - this.storage.create( - new StoragePath(heartbeatFolderPath, instantTime), true); - outputStream.close(); + writeHeartbeatFile(instantTime); Heartbeat heartbeat = instantToHeartbeatMap.get(instantTime); if (heartbeat.getLastHeartbeatTime() != null && isHeartbeatExpired(instantTime)) { - log.error("Aborting, missed generating heartbeat within allowable interval {} ms", this.maxAllowableHeartbeatIntervalInMs); - // Since TimerTask allows only java.lang.Runnable, cannot throw an exception and bubble to the caller thread, hence - // explicitly interrupting the timer thread. - Thread.currentThread().interrupt(); + // A previous refresh was delayed past the tolerable interval. Stop refreshing this heartbeat + // (cancel the scheduler) and do NOT advance the last heartbeat time, so the heartbeat stays expired + // and the writer aborts at commit time via HeartbeatUtils.abortIfHeartbeatExpired(). We must not + // keep refreshing here: a concurrent process (e.g. an async cleaner under LAZY failed-writes + // policy) may already have started rolling back this instant once it observed the expiry, and + // resurrecting the heartbeat could let this writer commit on top of rolled-back files. + // The scheduler is cancelled cleanly rather than via Thread.interrupt(), which would permanently + // kill the scheduler thread (turning a transient delay into a permanent blackout on the first miss). + log.error("Missed generating heartbeat for instant {} within allowable interval {} ms; stopping heartbeat refresh", + instantTime, this.maxAllowableHeartbeatIntervalInMs); + shutdownHeartbeatScheduler(heartbeat); + return; } heartbeat.setInstantTime(instantTime); heartbeat.setLastHeartbeatTime(newHeartbeatTime); heartbeat.setNumHeartbeats(heartbeat.getNumHeartbeats() + 1); + } catch (TimeoutException te) { + // The storage write did not complete within the bounded window. Do not advance the last heartbeat + // time (the write is unconfirmed); the next scheduled tick will retry on a fresh executor thread. + // Crucially, the scheduler thread is freed instead of being blocked by a hung storage call. + log.warn("Heartbeat file write for instant {} did not complete within {} ms; will retry on next tick", + instantTime, this.heartbeatWriteTimeoutMs); } catch (IOException io) { boolean isHeartbeatStopped = instantToHeartbeatMap.get(instantTime).isHeartbeatStopped(); if (isHeartbeatStopped) { @@ -221,13 +271,49 @@ private void updateHeartbeat(String instantTime) throws HoodieHeartbeatException } } + /** + * Writes the heartbeat file for the given instant on a dedicated daemon executor, bounded by + * {@link #heartbeatWriteTimeoutMs}. Performing the storage write off the scheduler thread (and with a + * timeout) ensures that a slow or hung storage call cannot block the scheduler thread and freeze all + * subsequent heartbeats for this instant. + */ + private void writeHeartbeatFile(String instantTime) throws IOException, TimeoutException { + Future future = getHeartbeatWriteExecutor().submit(() -> { + try (OutputStream outputStream = + this.storage.create(new StoragePath(heartbeatFolderPath, instantTime), true)) { + // create + close confirms the heartbeat file write landed on storage. + } + return null; + }); + try { + future.get(heartbeatWriteTimeoutMs, TimeUnit.MILLISECONDS); + } catch (TimeoutException te) { + future.cancel(true); + throw te; + } catch (InterruptedException ie) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new HoodieHeartbeatException("Interrupted while writing heartbeat for instant " + instantTime, ie); + } catch (ExecutionException ee) { + Throwable cause = ee.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new HoodieHeartbeatException("Failed to write heartbeat for instant " + instantTime, cause); + } + } + public Heartbeat getHeartbeat(String instantTime) { return this.instantToHeartbeatMap.get(instantTime); } @Override - public void close() { + public synchronized void close() { this.stopHeartbeatTimers(); this.instantToHeartbeatMap.clear(); + if (heartbeatWriteExecutor != null) { + heartbeatWriteExecutor.shutdownNow(); + heartbeatWriteExecutor = null; + } } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v1/TimelineArchiverV1.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v1/TimelineArchiverV1.java index d518ac5525dd6..579b2a8c6c87d 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v1/TimelineArchiverV1.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v1/TimelineArchiverV1.java @@ -31,8 +31,8 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType; @@ -116,9 +116,12 @@ public TimelineArchiverV1(HoodieWriteConfig config, HoodieTable tabl private Writer openWriter(StoragePath archivePath) { try { if (this.writer == null) { - return HoodieLogFormat.newWriterBuilder().onParentPath(archivePath).withInstantTime("") - .withFileId(archiveFilePath.getName()).withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) - .withStorage(metaClient.getStorage()).build(); + return HoodieLogFormatWriter.builder() + .withParentPath(archivePath).withInstantTime("") + .withLogFileId(archiveFilePath.getName()) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withStorage(metaClient.getStorage()) + .build(); } else { return this.writer; } @@ -356,7 +359,7 @@ private List getInstantsToArchive() throws IOException { log.info("Not archiving as there is no compaction yet on the metadata table"); instants = Stream.empty(); } else { - log.info("Limiting archiving of instants to latest compaction on metadata table at " + latestCompactionTime.get()); + log.info("Limiting archiving of instants to latest compaction on metadata table at {}", latestCompactionTime.get()); instants = instants.filter(instant -> compareTimestamps(instant.requestedTime(), LESSER_THAN, latestCompactionTime.get())); } @@ -416,7 +419,7 @@ private List getInstantsToArchive() throws IOException { } private boolean deleteArchivedInstants(List archivedInstants, HoodieEngineContext context) throws IOException { - log.info("Deleting instants " + archivedInstants); + log.info("Deleting instants {}", archivedInstants); List pendingInstants = new ArrayList<>(); List completedInstants = new ArrayList<>(); @@ -460,7 +463,7 @@ private boolean deleteArchivedInstants(List archivedInstants, Hoo public void archive(HoodieEngineContext context, List instants) throws HoodieCommitException { try { Schema wrapperSchema = HoodieArchivedMetaEntry.getClassSchema(); - log.info("Wrapper schema " + wrapperSchema.toString()); + log.info("Wrapper schema {}", wrapperSchema); List records = new ArrayList<>(); for (HoodieInstant hoodieInstant : instants) { try { @@ -471,7 +474,7 @@ public void archive(HoodieEngineContext context, List instants) t } } catch (Exception e) { InstantFileNameGenerator fileNameFactory = new InstantFileNameGeneratorV1(); - log.error("Failed to archive commits, .commit file: " + fileNameFactory.getFileName(hoodieInstant), e); + log.error("Failed to archive commits, .commit file: {}", fileNameFactory.getFileName(hoodieInstant), e); if (this.config.isFailOnTimelineArchivingEnabled()) { throw e; } @@ -486,7 +489,7 @@ public void archive(HoodieEngineContext context, List instants) t private void deleteAnyLeftOverMarkers(HoodieEngineContext context, HoodieInstant instant) { WriteMarkers writeMarkers = WriteMarkersFactory.get(config.getMarkersType(), table, instant.requestedTime()); if (writeMarkers.deleteMarkerDir(context, config.getMarkersDeleteParallelism())) { - log.info("Cleaned up left over marker directory for instant :" + instant); + log.info("Cleaned up left over marker directory for instant :{}", instant); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v2/LSMTimelineWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v2/LSMTimelineWriter.java index b555a0646183c..97b2dc01d3acc 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v2/LSMTimelineWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/timeline/versioning/v2/LSMTimelineWriter.java @@ -49,7 +49,6 @@ import org.apache.hudi.table.HoodieTable; import lombok.extern.slf4j.Slf4j; -import org.apache.avro.Schema; import org.apache.avro.generic.IndexedRecord; import java.io.IOException; @@ -137,9 +136,8 @@ public void write( throw new HoodieIOException("Failed to check archiving file before write: " + filePath, ioe); } try (HoodieFileWriter writer = openWriter(filePath)) { - Schema wrapperSchema = HoodieLSMTimelineInstant.getClassSchema(); - log.info("Writing schema " + wrapperSchema.toString()); - HoodieSchema schema = HoodieSchema.fromAvroSchema(wrapperSchema); + HoodieSchema schema = HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema()); + log.info("Writing schema {}", schema); for (ActiveAction activeAction : activeActions) { try { preWriteCallback.ifPresent(callback -> callback.accept(activeAction)); @@ -147,7 +145,7 @@ public void write( final HoodieLSMTimelineInstant metaEntry = MetadataConversionUtils.createLSMTimelineInstant(activeAction, metaClient); writer.write(metaEntry.getInstantTime(), new HoodieAvroIndexedRecord(metaEntry), schema); } catch (Exception e) { - log.error("Failed to write instant: " + activeAction.getInstantTime(), e); + log.error("Failed to write instant: {}", activeAction.getInstantTime(), e); exceptionHandler.ifPresent(handler -> handler.accept(e)); } } @@ -290,7 +288,7 @@ private Option doCompact(HoodieLSMTimelineManifest manifest, int layer) compactFiles(candidateFiles, compactedFileName); // 4. update the manifest file updateManifest(candidateFiles, compactedFileName); - log.info("Finishes compaction of source files: " + candidateFiles); + log.info("Finishes compaction of source files: {}", candidateFiles); return Option.of(compactedFileName); } return Option.empty(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/BucketIndexConcurrentFileWritesConflictResolutionStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/BucketIndexConcurrentFileWritesConflictResolutionStrategy.java index 54112abd75eb2..01b071f714a17 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/BucketIndexConcurrentFileWritesConflictResolutionStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/BucketIndexConcurrentFileWritesConflictResolutionStrategy.java @@ -51,8 +51,7 @@ public boolean hasConflict(ConcurrentOperation thisOperation, ConcurrentOperatio Set intersection = new HashSet<>(partitionBucketIdSetForFirstInstant); intersection.retainAll(partitionBucketIdSetForSecondInstant); if (!intersection.isEmpty()) { - log.info("Found conflicting writes between first operation = " + thisOperation - + ", second operation = " + otherOperation + " , intersecting bucket ids " + intersection); + log.info("Found conflicting writes between first operation = {}, second operation = {} , intersecting bucket ids {}", thisOperation, otherOperation, intersection); return true; } return false; diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java index 0bb7db3fa5833..3ff8625e7457f 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java @@ -25,7 +25,7 @@ import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; -import org.apache.hudi.common.table.timeline.TimelineLayout; +import org.apache.hudi.common.table.timeline.InstantComparator; import org.apache.hudi.common.util.ClusteringUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; @@ -60,6 +60,8 @@ class ConcurrentSchemaEvolutionTableSchemaGetter { private final Lazy> tableSchemaCache; + private final InstantComparator instantComparator; + private Option latestCommitWithValidSchema = Option.empty(); @VisibleForTesting @@ -69,10 +71,18 @@ public ConcurrentHashMap getTableSchemaCache() { public ConcurrentSchemaEvolutionTableSchemaGetter(HoodieTableMetaClient metaClient) { this.metaClient = metaClient; + this.instantComparator = metaClient.getTimelineLayout().getInstantComparator(); // Unbounded sized map. Should replace with some caching library. this.tableSchemaCache = Lazy.lazily(ConcurrentHashMap::new); } + /** + * Returns the timestamp ordering the instant in the schema evolution timeline. + */ + String getOrderingTime(HoodieInstant instant) { + return instantComparator.getOrderingTime(instant); + } + /** * Handles partition column logic for a given schema. * @@ -160,9 +170,11 @@ Option> getLastCommitMetadataWithValidSchemaFr // the timeline finding a completed instant containing a valid schema. ConcurrentHashMap tableSchemaAtInstant = new ConcurrentHashMap<>(); Option instantWithTableSchema = Option.fromJavaOptional(reversedTimelineStream - // If a completion time is specified, find the first eligible instant in the schema evolution timeline. - // Should switch to completion time based. - .filter(s -> instant.isEmpty() || compareTimestamps(s.getCompletionTime(), LESSER_THAN_OR_EQUALS, instant.get().getCompletionTime())) + // Find the first eligible instant whose ordering time is no later than the target instant's; + // a target instant without an ordering time (not completed yet, on table version 8 and above) + // does not bound the lookup. + .filter(s -> instant.isEmpty() || StringUtils.isNullOrEmpty(getOrderingTime(instant.get())) + || compareTimestamps(getOrderingTime(s), LESSER_THAN_OR_EQUALS, getOrderingTime(instant.get()))) // Make sure the commit metadata has a valid schema inside. Same caching the result for expensive operation. .filter(s -> { try { @@ -193,6 +205,8 @@ Option> getLastCommitMetadataWithValidSchemaFr /** * Get timeline in REVERSE order that only contains completed instants which POTENTIALLY evolve the table schema. + * The stream follows the timeline layout's instant ordering, newest first (completion time for + * layout v2, requested time for v1). * For types of instants that are included and not reflecting table schema at their instant completion time please refer * comments inside the code. */ @@ -214,9 +228,7 @@ public Stream computeSchemaEvolutionTimelineInReverseOrder() { } // We only care committed instant when it comes to table schema. - TimelineLayout timelineLayout = metaClient.getTimelineLayout(); - // Table schema getter is completion time based ordering. - Comparator reversedComparator = timelineLayout.getInstantComparator().completionTimeOrderedComparator().reversed(); + Comparator reversedComparator = instantComparator.orderingComparator().reversed(); // The timeline still contains DELTA_COMMIT_ACTION/COMMIT_ACTION which might not contain a valid schema // field in their commit metadata. diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/DirectMarkerTransactionManager.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/DirectMarkerTransactionManager.java index 02b027f12d31f..90c5e963da1e2 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/DirectMarkerTransactionManager.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/DirectMarkerTransactionManager.java @@ -48,22 +48,20 @@ public DirectMarkerTransactionManager(HoodieWriteConfig config, HoodieStorage st public void beginTransaction(String newTxnOwnerInstantTime, InstantGenerator instantGenerator) { if (isLockRequired) { - LOG.info("Transaction starting for " + newTxnOwnerInstantTime + " and " + filePath); + LOG.info("Transaction starting for {} and {}", newTxnOwnerInstantTime, filePath); lockManager.lock(); reset(changeActionInstant, Option.of(getInstant(newTxnOwnerInstantTime, instantGenerator)), Option.empty()); - LOG.info("Transaction started for " + newTxnOwnerInstantTime + " and " + filePath); + LOG.info("Transaction started for {} and {}", newTxnOwnerInstantTime, filePath); } } public void endTransaction(String currentTxnOwnerInstantTime, InstantGenerator instantGenerator) { if (isLockRequired) { - LOG.info("Transaction ending with transaction owner " + currentTxnOwnerInstantTime - + " for " + filePath); + LOG.info("Transaction ending with transaction owner {} for {}", currentTxnOwnerInstantTime, filePath); if (reset(Option.of(getInstant(currentTxnOwnerInstantTime, instantGenerator)), Option.empty(), Option.empty())) { lockManager.unlock(); - LOG.info("Transaction ended with transaction owner " + currentTxnOwnerInstantTime - + " for " + filePath); + LOG.info("Transaction ended with transaction owner {} for {}", currentTxnOwnerInstantTime, filePath); } } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleConcurrentFileWritesConflictResolutionStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleConcurrentFileWritesConflictResolutionStrategy.java index e2eaa53103036..92c6f6f66ba54 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleConcurrentFileWritesConflictResolutionStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleConcurrentFileWritesConflictResolutionStrategy.java @@ -142,8 +142,7 @@ public boolean hasConflict(ConcurrentOperation thisOperation, ConcurrentOperatio Set> intersection = new HashSet<>(partitionAndFileIdsSetForFirstInstant); intersection.retainAll(partitionAndFileIdsSetForSecondInstant); if (!intersection.isEmpty()) { - log.info("Found conflicting writes between first operation = " + thisOperation - + ", second operation = " + otherOperation + " , intersecting file ids " + intersection); + log.info("Found conflicting writes between first operation = {}, second operation = {} , intersecting file ids {}", thisOperation, otherOperation, intersection); return true; } return false; @@ -163,8 +162,7 @@ private boolean isRollbackConflict(ConcurrentOperation thisOperation, Concurrent String rolledbackCommit = otherOperation.getRolledbackCommit(); String thisCommitTimestamp = thisOperation.getInstantTimestamp(); if (rolledbackCommit != null && rolledbackCommit.equals(thisCommitTimestamp)) { - log.error("Found rollback conflict: rollback operation " + otherOperation - + " is rolling back commit " + thisCommitTimestamp + " created by operation " + thisOperation); + log.error("Found rollback conflict: rollback operation {} is rolling back commit {} created by operation {}", otherOperation, thisCommitTimestamp, thisOperation); return true; } } @@ -202,8 +200,66 @@ public Option resolveConflict(HoodieTable table, return thisOperation.getCommitMetadataOption(); } // just abort the current write if conflicts are found (failed for rollback conflicts). - throw new HoodieWriteConflictException(new ConcurrentModificationException("Cannot resolve conflicts for overlapping writes between first operation = " + thisOperation - + ", second operation = " + otherOperation)); + throw new HoodieWriteConflictException(new ConcurrentModificationException(buildConflictErrorMessage(thisOperation, otherOperation))); + } + + /** + * Builds a detailed error message for write conflicts based on the operation types involved. + */ + private String buildConflictErrorMessage(ConcurrentOperation thisOperation, ConcurrentOperation otherOperation) { + boolean thisIsTableService = WriteOperationType.isTableService(thisOperation.getOperationType()); + boolean otherIsTableService = WriteOperationType.isTableService(otherOperation.getOperationType()); + String thisOperationDescription = formatOperationDescription(thisOperation); + String otherOperationDescription = formatOperationDescription(otherOperation); + // If either operation is a table service, provide specific retry guidance + if (thisIsTableService || otherIsTableService) { + ConcurrentOperation tableServiceOperation = thisIsTableService ? thisOperation : otherOperation; + String tableServiceDescription = thisIsTableService ? thisOperationDescription : otherOperationDescription; + String regularOperationDescription = thisIsTableService ? otherOperationDescription : thisOperationDescription; + String serviceType = getTableServiceDisplayName(tableServiceOperation.getOperationType()); + return String.format( + "Cannot resolve conflicts for overlapping writes. %s is currently running and has overlapping file groups with %s. " + + "Please retry the write operation after the %s completes.", + tableServiceDescription, regularOperationDescription, serviceType.toLowerCase() + ); + } + // For regular write operations conflicting with each other + return String.format( + "Cannot resolve conflicts for overlapping writes. %s has overlapping file groups with %s.", + thisOperationDescription, otherOperationDescription + ); + } + + /** + * Formats a description of an operation including its type, instant, and state. + */ + private String formatOperationDescription(ConcurrentOperation operation) { + String operationName = WriteOperationType.isTableService(operation.getOperationType()) + ? "Table " + getTableServiceDisplayName(operation.getOperationType()) + : operation.getOperationType().value() + " operation"; + + return String.format("%s (instant: %s, state: %s)", + operationName, + operation.getInstantTimestamp(), + operation.getInstantActionState()); + } + + /** + * Returns a user-friendly display name for table service operations. + */ + private String getTableServiceDisplayName(WriteOperationType operationType) { + switch (operationType) { + case COMPACT: + return "Compaction"; + case CLUSTER: + return "Clustering"; + case LOG_COMPACT: + return "Log Compaction"; + case INDEX: + return "Indexing"; + default: + return operationType.value(); + } } @Override diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java index cfcd26362552c..523b21356094c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java @@ -30,8 +30,6 @@ import lombok.extern.slf4j.Slf4j; -import java.util.stream.Stream; - import static org.apache.hudi.client.transaction.SchemaConflictResolutionStrategy.throwConcurrentSchemaEvolutionException; import static org.apache.hudi.common.table.timeline.HoodieTimeline.COMPACTION_ACTION; import static org.apache.hudi.common.table.timeline.InstantComparison.LESSER_THAN_OR_EQUALS; @@ -77,7 +75,7 @@ public Option resolveConcurrentSchemaEvolution( // schema and writer schema. HoodieInstant lastCompletedInstantAtTxnStart = lastCompletedTxnOwnerInstant.isPresent() ? getInstantInTimelineImmediatelyPriorToTimestamp( - lastCompletedTxnOwnerInstant.get().getCompletionTime(), schemaResolver.computeSchemaEvolutionTimelineInReverseOrder()).orElse(null) + schemaResolver.getOrderingTime(lastCompletedTxnOwnerInstant.get()), schemaResolver).orElse(null) : null; // If lastCompletedInstantAtTxnValidation is null there are 2 possibilities: // - No committed txn at validation starts @@ -157,9 +155,9 @@ public Option resolveConcurrentSchemaEvolution( } private Option getInstantInTimelineImmediatelyPriorToTimestamp( - String timestamp, Stream reverseOrderTimeline) { - return Option.fromJavaOptional(reverseOrderTimeline - .filter(s -> compareTimestamps(s.getCompletionTime(), LESSER_THAN_OR_EQUALS, timestamp)) + String timestamp, ConcurrentSchemaEvolutionTableSchemaGetter schemaResolver) { + return Option.fromJavaOptional(schemaResolver.computeSchemaEvolutionTimelineInReverseOrder() + .filter(s -> compareTimestamps(schemaResolver.getOrderingTime(s), LESSER_THAN_OR_EQUALS, timestamp)) .findFirst()); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/BaseZookeeperBasedLockProvider.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/BaseZookeeperBasedLockProvider.java index d5b04c15c005e..6cdee60e2d1b1 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/BaseZookeeperBasedLockProvider.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/BaseZookeeperBasedLockProvider.java @@ -68,6 +68,7 @@ public BaseZookeeperBasedLockProvider(final LockConfiguration lockConfiguration, this.lockConfiguration = lockConfiguration; zkBasePath = getZkBasePath(lockConfiguration); lockKey = getLockKey(lockConfiguration); + int connectionTimeoutMs = ConfigUtils.getIntWithAltKeys(lockConfiguration.getConfig(), ZK_CONNECTION_TIMEOUT_MS); this.curatorFrameworkClient = CuratorFrameworkFactory.builder() .connectString(ConfigUtils.getStringWithAltKeys(lockConfiguration.getConfig(), ZK_CONNECT_URL)) .retryPolicy(new BoundedExponentialBackoffRetry( @@ -75,10 +76,32 @@ public BaseZookeeperBasedLockProvider(final LockConfiguration lockConfiguration, ConfigUtils.getIntWithAltKeys(lockConfiguration.getConfig(), LOCK_ACQUIRE_RETRY_MAX_WAIT_TIME_IN_MILLIS), ConfigUtils.getIntWithAltKeys(lockConfiguration.getConfig(), LOCK_ACQUIRE_NUM_RETRIES))) .sessionTimeoutMs(ConfigUtils.getIntWithAltKeys(lockConfiguration.getConfig(), ZK_SESSION_TIMEOUT_MS)) - .connectionTimeoutMs(ConfigUtils.getIntWithAltKeys(lockConfiguration.getConfig(), ZK_CONNECTION_TIMEOUT_MS)) + .connectionTimeoutMs(connectionTimeoutMs) .build(); this.curatorFrameworkClient.start(); - createPathIfNotExists(); + // Once started, the Curator client owns background threads. If anything below throws, the + // constructor never returns the instance, so the caller can never invoke close() - clean up here. + try { + if (!this.curatorFrameworkClient.blockUntilConnected(connectionTimeoutMs, TimeUnit.MILLISECONDS)) { + throw new HoodieLockException("Failed to connect to ZooKeeper within " + connectionTimeoutMs + " ms"); + } + createPathIfNotExists(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + closeQuietly(); + throw new HoodieLockException("Interrupted while waiting to connect to ZooKeeper", e); + } catch (RuntimeException e) { + closeQuietly(); + throw e; + } + } + + private void closeQuietly() { + try { + this.curatorFrameworkClient.close(); + } catch (Exception ex) { + log.warn("Failed to close ZooKeeper client after failed initialization", ex); + } } protected abstract String getZkBasePath(LockConfiguration lockConfiguration); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/FileSystemBasedLockProvider.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/FileSystemBasedLockProvider.java index fa7fde5175083..7c9362d34e404 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/FileSystemBasedLockProvider.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/FileSystemBasedLockProvider.java @@ -169,17 +169,15 @@ private boolean checkIfExpired() { return true; } } catch (IOException | HoodieIOException e) { - log.error(generateLogStatement(LockState.ALREADY_RELEASED) + " failed to get lockFile's modification time", e); + log.error("{} failed to get lockFile's modification time", generateLogStatement(LockState.ALREADY_RELEASED), e); } return false; } private void acquireLock() { try (OutputStream os = storage.create(this.lockFile, false)) { - if (!storage.exists(this.lockFile)) { - initLockInfo(); - os.write(StringUtils.getUTF8Bytes(lockInfo.toString())); - } + initLockInfo(); + os.write(StringUtils.getUTF8Bytes(lockInfo.toString())); } catch (IOException e) { throw new HoodieIOException(generateLogStatement(LockState.FAILED_TO_ACQUIRE), e); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/LockManager.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/LockManager.java index 21eb5da615758..6be1ebd7c911a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/LockManager.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/LockManager.java @@ -109,7 +109,7 @@ public void unlock() { public synchronized LockProvider getLockProvider() { // Perform lazy initialization of lock provider only if needed if (lockProvider == null) { - log.info("LockProvider " + writeConfig.getLockProviderClass()); + log.info("LockProvider {}", writeConfig.getLockProviderClass()); // Try to load lock provider with HoodieLockMetrics constructor first Class[] metricsConstructorTypes = {LockConfiguration.class, StorageConfiguration.class, HoodieLockMetrics.class}; diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java index 2ba31e36897da..a2c6aec8afa79 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/StorageBasedLockProvider.java @@ -637,16 +637,22 @@ protected synchronized boolean renewLock() { hoodieLockMetrics.ifPresent(HoodieLockMetrics::updateLockThrottledMetric); // Let heartbeat retry later. return true; - case SUCCESS: - // Only positive outcome - this.setLock(currentLock.getRight().get()); - hoodieLockMetrics.ifPresent(metrics -> metrics.updateLockExpirationDeadlineMetric( - (int) (oldExpirationMs - getCurrentEpochMs()))); - logger.info("Owner {}: Lock renewal successful. The renewal completes {} ms before expiration for lock {}.", - ownerId, oldExpirationMs - getCurrentEpochMs(), lockFilePath); + case SUCCESS: { + // Only positive outcome. Source the deadline metric and log from the renewed lock file + // returned by the storage client (same as the acquisition path), not the locally + // computed expiration, so both callers agree on where the deadline comes from. + StorageLockFile renewedLock = currentLock.getRight().get(); + this.setLock(renewedLock); + // Read the clock once so the metric and the log line below report the same deadline. + long renewalCompletionMs = getCurrentEpochMs(); + long remainingLeaseMs = renewedLock.getValidUntilMs() - renewalCompletionMs; + hoodieLockMetrics.ifPresent(metrics -> metrics.updateLockExpirationDeadlineMetric((int) remainingLeaseMs)); + logger.info("Owner {}: Lock renewal successful. The renewal completes {} ms before old expiration. The lock will expire in {} ms for lock {}.", + ownerId, oldExpirationMs - renewalCompletionMs, remainingLeaseMs, lockFilePath); recordAuditOperation(AuditOperationState.RENEW, acquisitionTimestamp); // Let heartbeat continue to renew lock lease again later. return true; + } default: throw new HoodieLockException("Unexpected lock update result: " + currentLock.getLeft()); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java index b921c6ddfc813..64a92ee1ae8ca 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java @@ -28,7 +28,7 @@ * Provide a way to obtain a inputItr of type O (output), out of an inputItr of type I (input) *

* Things to remember: - Assumes Spark calls hasNext() to check for elements, before calling next() to obtain them - - * Assumes hasNext() gets called atleast once. - Concrete Implementation is responsible for calling inputIterator.next() + * Assumes hasNext() gets called at least once. - Concrete Implementation is responsible for calling inputIterator.next() * and doing the processing in computeNext() */ public abstract class LazyIterableIterator implements Iterable, Iterator { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/TransactionUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/TransactionUtils.java index 6b5ac8c575aa4..cca0486799fdd 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/TransactionUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/TransactionUtils.java @@ -89,8 +89,7 @@ public static Option resolveWriteConflictIfAny( try { ConcurrentOperation otherOperation = new ConcurrentOperation(instant, table.getMetaClient()); if (resolutionStrategy.hasConflict(thisOperation, otherOperation)) { - log.info("Conflict encountered between current instant = " + thisOperation + " and instant = " - + otherOperation + ", attempting to resolve it..."); + log.info("Conflict encountered between current instant = {} and instant = {}, attempting to resolve it...", thisOperation, otherOperation); resolutionStrategy.resolveConflict(table, thisOperation, otherOperation); } } catch (IOException io) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/validator/StreamingOffsetValidator.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/validator/StreamingOffsetValidator.java index ce577d84ca018..0313d57c30c71 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/validator/StreamingOffsetValidator.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/validator/StreamingOffsetValidator.java @@ -20,11 +20,13 @@ package org.apache.hudi.client.validator; import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.util.CheckpointUtils; import org.apache.hudi.common.util.CheckpointUtils.CheckpointFormat; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodiePreCommitValidatorConfig; import org.apache.hudi.config.HoodiePreCommitValidatorConfig.ValidationFailurePolicy; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieValidationException; import lombok.extern.slf4j.Slf4j; @@ -50,7 +52,11 @@ * * Subclasses specify: * - Checkpoint format (SPARK_KAFKA, FLINK_KAFKA, etc.) - * - Checkpoint metadata key + * - Checkpoint metadata key (optional — when omitted, the validator auto-resolves the + * active streamer key from commit metadata using + * {@link org.apache.hudi.common.table.checkpoint.CheckpointUtils#getCheckpoint(HoodieCommitMetadata)}, + * which prefers V2 and falls back to V1. Subclasses that read a custom non-streamer key + * (e.g. Flink's HOODIE_METADATA_KEY) must pass it explicitly.) * - Source-specific parsing logic (if needed) * * Configuration: @@ -66,7 +72,26 @@ public abstract class StreamingOffsetValidator extends BasePreCommitValidator { protected final CheckpointFormat checkpointFormat; /** - * Create a streaming offset validator. + * Create a streaming offset validator that auto-resolves the checkpoint key from commit + * metadata using {@link org.apache.hudi.common.table.checkpoint.CheckpointUtils#getCheckpoint(HoodieCommitMetadata)}. + * + *

Use this constructor for streamer pipelines (V1 or V2 checkpoint keys). The validator + * will prefer V2 (table version 8+) and fall back to V1 transparently, so subclasses don't + * need to know which key the writer used.

+ * + * @param config Validator configuration + * @param checkpointFormat Format of the checkpoint string + */ + protected StreamingOffsetValidator(TypedProperties config, + CheckpointFormat checkpointFormat) { + this(config, null, checkpointFormat); + } + + /** + * Create a streaming offset validator with an explicit checkpoint metadata key. + * + *

Use this constructor when the writer stores its checkpoint under a custom key that + * is not the standard streamer V1/V2 key (e.g. Flink's HOODIE_METADATA_KEY).

* * @param config Validator configuration * @param checkpointKey Key to extract checkpoint from extraMetadata @@ -95,10 +120,12 @@ public void validateWithMetadata(ValidationContext context) throws HoodieValidat return; } - // Extract current checkpoint - Option currentCheckpointOpt = context.getExtraMetadata(checkpointKey); + // Extract current checkpoint — either from the explicit key (custom writers like Flink) or + // by auto-resolving from commit metadata (streamer pipelines, V2-then-V1 fallback). + Option currentCheckpointOpt = resolveCheckpoint(context.getCommitMetadata()); if (!currentCheckpointOpt.isPresent()) { - log.warn("Current checkpoint not found with key: {}. Skipping validation.", checkpointKey); + log.warn("Current checkpoint not found (key: {}). Skipping validation.", + checkpointKey == null ? "" : checkpointKey); return; } String currentCheckpoint = currentCheckpointOpt.get(); @@ -110,8 +137,7 @@ public void validateWithMetadata(ValidationContext context) throws HoodieValidat } // Extract previous checkpoint - Option previousCheckpointOpt = context.getPreviousCommitMetadata() - .flatMap(metadata -> Option.ofNullable(metadata.getMetadata(checkpointKey))); + Option previousCheckpointOpt = resolveCheckpoint(context.getPreviousCommitMetadata()); if (!previousCheckpointOpt.isPresent()) { log.info("Previous checkpoint not found. May be first streaming commit. Skipping validation."); @@ -139,6 +165,10 @@ public void validateWithMetadata(ValidationContext context) throws HoodieValidat long recordsWritten = context.getTotalInsertRecordsWritten() + context.getTotalUpdateRecordsWritten(); + // Track write errors so callers can distinguish write-failure deviation (write errors > 0) + // from silent data loss (write errors == 0) when the validator fires. + long writeErrors = context.getTotalWriteErrors(); + // For empty commits (e.g., no new data from source), both offsetDiff and recordsWritten // can be zero. This is a valid scenario — skip validation to avoid false positives. if (offsetDifference == 0 && recordsWritten == 0) { @@ -147,7 +177,7 @@ public void validateWithMetadata(ValidationContext context) throws HoodieValidat } // Validate offset vs record consistency - validateOffsetConsistency(offsetDifference, recordsWritten, + validateOffsetConsistency(offsetDifference, recordsWritten, writeErrors, currentCheckpoint, previousCheckpoint); } @@ -155,12 +185,13 @@ public void validateWithMetadata(ValidationContext context) throws HoodieValidat * Validate that offset difference matches record count within tolerance. * * @param offsetDiff Expected records based on offset difference - * @param recordsWritten Actual records written + * @param recordsWritten Actual records written (inserts + updates) + * @param writeErrors Records that failed to write (tracked in write status errors) * @param currentCheckpoint Current checkpoint string (for error messages) * @param previousCheckpoint Previous checkpoint string (for error messages) * @throws HoodieValidationException if validation fails and policy is FAIL */ - protected void validateOffsetConsistency(long offsetDiff, long recordsWritten, + protected void validateOffsetConsistency(long offsetDiff, long recordsWritten, long writeErrors, String currentCheckpoint, String previousCheckpoint) throws HoodieValidationException { @@ -169,20 +200,23 @@ protected void validateOffsetConsistency(long offsetDiff, long recordsWritten, if (deviation > tolerancePercentage) { String errorMsg = String.format( "Streaming offset validation failed. " - + "Offset difference: %d, Records written: %d, Deviation: %.2f%%, Tolerance: %.2f%%. " - + "This may indicate data loss or filtering. " + + "Offset difference: %d, Records written: %d, Write errors: %d, Deviation: %.2f%%, Tolerance: %.2f%%. " + + "%s" + "Previous checkpoint: %s, Current checkpoint: %s", - offsetDiff, recordsWritten, deviation, tolerancePercentage, + offsetDiff, recordsWritten, writeErrors, deviation, tolerancePercentage, + writeErrors > 0 + ? "Non-zero write errors suggest records failed to write rather than silent data loss. " + : "This may indicate data loss or filtering. ", previousCheckpoint, currentCheckpoint); if (failurePolicy == ValidationFailurePolicy.WARN_LOG) { - log.warn(errorMsg + " (failure policy is WARN_LOG, commit will proceed)"); + log.warn("{} (failure policy is WARN_LOG, commit will proceed)", errorMsg); } else { throw new HoodieValidationException(errorMsg); } } else { - log.info("Offset validation passed. Offset diff: {}, Records: {}, Deviation: {}% (within {}%)", - offsetDiff, recordsWritten, String.format("%.2f", deviation), tolerancePercentage); + log.info("Offset validation passed. Offset diff: {}, Records: {}, Write errors: {}, Deviation: {}% (within {}%)", + offsetDiff, recordsWritten, writeErrors, String.format("%.2f", deviation), tolerancePercentage); } } @@ -210,4 +244,33 @@ private double calculateDeviation(long offsetDiff, long recordsWritten) { long difference = Math.abs(offsetDiff - recordsWritten); return (100.0 * difference) / offsetDiff; } + + /** + * Resolve the checkpoint string from commit metadata. + * + *

When the validator was constructed with an explicit {@code checkpointKey}, that key + * is read directly. Otherwise, {@link org.apache.hudi.common.table.checkpoint.CheckpointUtils#getCheckpoint(HoodieCommitMetadata)} + * is used to locate the active streamer checkpoint (V2 first, V1 fallback), so callers + * don't need to know which key the writer used.

+ * + * @param commitMetadataOpt Optional commit metadata containing extraMetadata + * @return Optional checkpoint string (empty if metadata is absent or no checkpoint key matches) + */ + private Option resolveCheckpoint(Option commitMetadataOpt) { + if (!commitMetadataOpt.isPresent()) { + return Option.empty(); + } + HoodieCommitMetadata metadata = commitMetadataOpt.get(); + if (checkpointKey != null) { + return Option.ofNullable(metadata.getMetadata(checkpointKey)); + } + try { + return Option.ofNullable( + org.apache.hudi.common.table.checkpoint.CheckpointUtils.getCheckpoint(metadata) + .getCheckpointKey()); + } catch (HoodieException e) { + // No V1 or V2 streamer checkpoint key present in extraMetadata. + return Option.empty(); + } + } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieArchivalConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieArchivalConfig.java index 8854c87edeaba..e97e268fa9f9f 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieArchivalConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieArchivalConfig.java @@ -88,6 +88,15 @@ public class HoodieArchivalConfig extends HoodieConfig { .withDocumentation("Archiving of instants is batched in best-effort manner, to pack more instants into a single" + " archive log. This config controls such archival batch size."); + public static final ConfigProperty MIGRATION_COMMITS_ARCHIVAL_BATCH_SIZE = ConfigProperty + .key("hoodie.timeline.migration.commits.archival.batch") + .defaultValue(500) + .markAdvanced() + .withDocumentation("Batch size used when migrating the legacy archived timeline to the LSM timeline during a" + + " table version upgrade. A larger batch size minimizes the number of parquet files (and the associated" + + " remote storage operations like exists check, parquet write and manifest update) created during the" + + " one-time migration, which significantly reduces the total migration time."); + public static final ConfigProperty TIMELINE_COMPACTION_BATCH_SIZE = ConfigProperty .key("hoodie.timeline.compaction.batch.size") .defaultValue(10) @@ -211,6 +220,11 @@ public HoodieArchivalConfig.Builder withCommitsArchivalBatchSize(int batchSize) return this; } + public HoodieArchivalConfig.Builder withMigrationCommitsArchivalBatchSize(int batchSize) { + archivalConfig.setValue(MIGRATION_COMMITS_ARCHIVAL_BATCH_SIZE, String.valueOf(batchSize)); + return this; + } + public Builder withArchiveBeyondSavepoint(boolean archiveBeyondSavepoint) { archivalConfig.setValue(ARCHIVE_BEYOND_SAVEPOINT, String.valueOf(archiveBeyondSavepoint)); return this; diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java index 9cedac1be74d0..1d2c6bb2b426b 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java @@ -29,6 +29,7 @@ import org.apache.hudi.exception.HoodieNotSupportedException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.bucket.partition.PartitionBucketIndexRule; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; import lombok.Getter; @@ -39,9 +40,8 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; -import java.util.Arrays; +import java.util.List; import java.util.Properties; -import java.util.stream.Collectors; import static org.apache.hudi.common.config.HoodieStorageConfig.BLOOM_FILTER_DYNAMIC_MAX_ENTRIES; import static org.apache.hudi.common.config.HoodieStorageConfig.BLOOM_FILTER_FPP_VALUE; @@ -777,10 +777,9 @@ private void validateBucketIndexConfig() { hoodieIndexConfig.setValue(BUCKET_INDEX_HASH_FIELD, hoodieIndexConfig.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME)); } else { - boolean valid = Arrays - .stream(hoodieIndexConfig.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME).split(",")) - .collect(Collectors.toSet()) - .containsAll(Arrays.asList(hoodieIndexConfig.getString(BUCKET_INDEX_HASH_FIELD).split(","))); + List recordKeyFields = KeyGenUtils.getRecordKeyFields(hoodieIndexConfig.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME)); + List indexKeyFields = KeyGenUtils.getIndexKeyFields(hoodieIndexConfig.getString(BUCKET_INDEX_HASH_FIELD)); + boolean valid = recordKeyFields.containsAll(indexKeyFields); if (!valid) { throw new HoodieIndexException("Bucket index key (if configured) must be subset of record key."); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodiePreCommitValidatorConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodiePreCommitValidatorConfig.java index f85cc44120d4e..f4999bc39e166 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodiePreCommitValidatorConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodiePreCommitValidatorConfig.java @@ -43,7 +43,12 @@ public class HoodiePreCommitValidatorConfig extends HoodieConfig { .key("hoodie.precommit.validators") .defaultValue("") .markAdvanced() - .withDocumentation("Comma separated list of class names that can be invoked to validate commit"); + .withDocumentation("Comma separated list of class names that can be invoked to validate commit. " + + "Available streaming offset validators: " + + "org.apache.hudi.sink.validator.FlinkKafkaOffsetValidator (Flink Kafka), " + + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator (Spark/HoodieStreamer Kafka). " + + "Available write-error validators: " + + "org.apache.hudi.utilities.streamer.validator.SparkWriteErrorValidator (Spark/HoodieStreamer write errors)."); public static final String VALIDATOR_TABLE_VARIABLE = ""; public static final ConfigProperty EQUALITY_SQL_QUERIES = ConfigProperty @@ -71,7 +76,8 @@ public class HoodiePreCommitValidatorConfig extends HoodieConfig { .markAdvanced() .withDocumentation("Tolerance percentage for streaming offset validation " + "(used by org.apache.hudi.client.validator.StreamingOffsetValidator " - + "and org.apache.hudi.sink.validator.FlinkKafkaOffsetValidator). " + + "and org.apache.hudi.sink.validator.FlinkKafkaOffsetValidator " + + "and org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator). " + "The validator compares the offset difference (expected records from source) " + "with actual records written. If the deviation exceeds this percentage, " + "the commit is rejected or warned depending on the validation failure policy. " diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java index 5df834121bf90..f5cc2bf67a3e3 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java @@ -74,6 +74,7 @@ import org.apache.hudi.exception.HoodieNotSupportedException; import org.apache.hudi.execution.bulkinsert.BulkInsertSortMode; import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils; import org.apache.hudi.io.FileGroupReaderBasedMergeHandle; import org.apache.hudi.io.HoodieConcatHandle; import org.apache.hudi.keygen.SimpleAvroKeyGenerator; @@ -680,9 +681,11 @@ public class HoodieWriteConfig extends HoodieConfig { public static final ConfigProperty CLIENT_HEARTBEAT_NUM_TOLERABLE_MISSES = ConfigProperty .key("hoodie.client.heartbeat.tolerable.misses") - .defaultValue(2) + .defaultValue(10) .markAdvanced() - .withDocumentation("Number of heartbeat misses, before a writer is deemed not alive and all pending writes are aborted."); + .withDocumentation("Number of heartbeat misses, before a writer is deemed not alive and all pending writes are aborted. " + + "A higher value tolerates transient driver pauses (e.g. GC) or storage-latency spikes that would otherwise " + + "delay a heartbeat and cause a still-healthy writer's commit to be aborted."); public static final ConfigProperty CLUSTERING_BLOCK_FOR_PENDING_INGESTION = ConfigProperty .key("hoodie.clustering.fail.on.pending.ingestion.during.conflict.resolution") @@ -768,21 +771,22 @@ public class HoodieWriteConfig extends HoodieConfig { .markAdvanced() .sinceVersion("1.2.0") .withDocumentation("Comma-separated list of extra metadata keys that should be automatically carried forward " - + "to every new commit. These keys will be read from recent commit metadata and included in new commits, " - + "ensuring they remain accessible without walking the timeline or worrying about archival. " - + "This is useful for tracking checkpoint information (e.g., Kafka offsets, Flink checkpoints) or any metadata " - + "that needs to persist across commits. New values override old ones. Only applies to data table commits."); + + "to every new commit and clean instant. These keys will be read from recent commit and clean metadata " + + "and included in new commits/cleans, ensuring they remain accessible without walking the timeline or " + + "worrying about archival. This is useful for tracking checkpoint information (e.g., Kafka offsets, " + + "Flink checkpoints) or any metadata that needs to persist across commits. New values override old ones. " + + "Only applies to data table commits and clean instants."); public static final ConfigProperty ROLLING_METADATA_TIMELINE_LOOKBACK_COMMITS = ConfigProperty .key("hoodie.write.rolling.metadata.timeline.lookback.commits") .defaultValue(10) .markAdvanced() .sinceVersion("1.2.0") - .withDocumentation("Maximum number of completed commits to walk back in the timeline when searching for " - + "rolling metadata keys. If a rolling metadata key is not found in the latest commit, the system will " - + "walk back up to this many commits to find the most recent value. This ensures rolling metadata is " - + "preserved even if some commits don't update all keys. Higher values provide more resilience but may " - + "impact performance. Only applies when hoodie.write.rolling.metadata.keys is configured."); + .withDocumentation("Maximum number of completed instants (commits and clean) to walk back in the timeline " + + "when searching for rolling metadata keys. If a rolling metadata key is not found in the latest instant, " + + "the system will walk back up to this many instants to find the most recent value. This ensures rolling " + + "metadata is preserved even if some instants don't carry all keys. Higher values provide more resilience " + + "but may impact performance. Only applies when hoodie.write.rolling.metadata.keys is configured."); public static final ConfigProperty ALLOW_OPERATION_METADATA_FIELD = ConfigProperty .key("hoodie.allow.operation.metadata.field") @@ -2018,6 +2022,10 @@ public int getCommitArchivalBatchSize() { return getInt(HoodieArchivalConfig.COMMITS_ARCHIVAL_BATCH_SIZE); } + public int getMigrationCommitArchivalBatchSize() { + return getInt(HoodieArchivalConfig.MIGRATION_COMMITS_ARCHIVAL_BATCH_SIZE); + } + public boolean shouldBlockArchivalOnCleanECTR() { return getBoolean(HoodieArchivalConfig.BLOCK_ARCHIVAL_ON_LATEST_CLEAN_ECTR); } @@ -3857,6 +3865,11 @@ private void validate() { + "schedule inline compaction (%s) can be enabled. Both can't be set to true at the same time. %s, %s", HoodieCompactionConfig.INLINE_COMPACT.key(), HoodieCompactionConfig.SCHEDULE_INLINE_COMPACT.key(), inlineCompact, inlineCompactSchedule)); + // Parse-and-discard so a malformed 'field:type' entry fails at client build time rather + // than deep inside deduceWriterSchema on the first commit. Empty (default) is a no-op. + SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + writeConfig.getStringOrDefault(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES)); + int lookbackCommits = writeConfig.getInt(ROLLING_METADATA_TIMELINE_LOOKBACK_COMMITS); checkArgument(lookbackCommits >= 0, String.format("%s must be non-negative, but was %d", @@ -3890,7 +3903,9 @@ private String getDefaultMarkersType(EngineType engineType) { } case FLINK: case JAVA: - // Timeline-server-based marker is not supported for Flink and Java engines + // Timeline-server-based markers are not the default for Flink and Java, but they are not + // unsupported either: setting hoodie.write.markers.type explicitly selects them, subject to the + // same gates WriteMarkersFactory applies to every engine. return MarkerType.DIRECT.toString(); default: throw new HoodieNotSupportedException("Unsupported engine " + engineType); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/execution/FileMetadataWriteStatusConverter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/execution/FileMetadataWriteStatusConverter.java index 51ac55cd5dc4d..8e96b28adc3b0 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/execution/FileMetadataWriteStatusConverter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/execution/FileMetadataWriteStatusConverter.java @@ -60,7 +60,7 @@ public FileMetadataWriteStatusConverter(HoodieTable hoodieTable, Hoo */ public WriteStatus convert(String parquetFile, String partitionPath, Map executionConfigs) throws IOException { - LOG.info("Creating write status for parquet file " + parquetFile); + LOG.info("Creating write status for parquet file {}", parquetFile); WriteStatus writeStatus = (WriteStatus) ReflectionUtils.loadClass(this.writeConfig.getWriteStatusClassName(), this.hoodieTable.shouldTrackSuccessRecords(), this.writeConfig.getWriteStatusFailureFraction(), this.hoodieTable.isMetadataTable()); StoragePath parquetFilePath = new StoragePath(parquetFile); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java index d9fe1068e4218..d2cc530295f2b 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java @@ -319,14 +319,16 @@ private static HoodieData> getExistingRecords( Option internalSchemaOption = SerDeHelper.fromJson(config.getInternalSchema()); FileSlice fileSlice = fileSliceOption.get(); HoodieReaderContext readerContext = readerContextFactory.getContext(); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(instantTime.get()) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(dataSchema) .withRequestedSchema(dataSchema) - .withInternalSchema(internalSchemaOption) + .withInternalSchemaOpt(internalSchemaOption) .withProps(metaClient.getTableConfig().getProps()) .build(); try { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java index eed3ab39599c1..2bde3aec815b4 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java @@ -42,7 +42,7 @@ public static int getBucketId(List hashKeyFields, int numBuckets) { } protected static List getHashKeys(String recordKey, String indexKeyFields) { - return getHashKeysUsingIndexFields(recordKey, Arrays.asList(indexKeyFields.split(","))); + return getHashKeysUsingIndexFields(recordKey, KeyGenUtils.getIndexKeyFields(indexKeyFields)); } protected static List getHashKeys(String recordKey, List indexKeyFields) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIndexUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIndexUtils.java index 5d02de2cbcfd3..b522a77af83c6 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIndexUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIndexUtils.java @@ -175,7 +175,7 @@ public static Option loadMetadata(HoodieTable t } catch (FileNotFoundException e) { return Option.empty(); } catch (IOException e) { - log.error("Error when loading hashing metadata, partition: " + partition, e); + log.error("Error when loading hashing metadata, partition: {}", partition, e); throw new HoodieIndexException("Error while loading hashing metadata", e); } } @@ -258,7 +258,7 @@ private static Option loadMetadataFromGivenFile } catch (FileNotFoundException e) { return Option.empty(); } catch (IOException e) { - log.error("Error when loading hashing metadata, for path: " + metaFile.getPath().getName(), e); + log.error("Error when loading hashing metadata, for path: {}", metaFile.getPath().getName(), e); throw new HoodieIndexException("Error while loading hashing metadata", e); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java index 38c7cb5319a3f..be61454fae36c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java @@ -29,13 +29,13 @@ import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieIndexException; import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.table.HoodieTable; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import java.io.Serializable; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -57,8 +57,8 @@ public HoodieBucketIndex(HoodieWriteConfig config) { super(config); this.numBuckets = config.getBucketIndexNumBuckets(); - this.indexKeyFields = Arrays.asList(config.getBucketIndexHashField().split(",")); - log.info("Use bucket index, numBuckets = " + numBuckets + ", indexFields: " + indexKeyFields); + this.indexKeyFields = KeyGenUtils.getIndexKeyFields(config.getBucketIndexHashField()); + log.info("Use bucket index, numBuckets = {}, indexFields: {}", numBuckets, indexKeyFields); } @Override diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java index 8144ae4c2f859..6eb999d74b550 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java @@ -30,6 +30,7 @@ import org.apache.hudi.common.model.MetadataValues; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieInsertException; @@ -117,7 +118,7 @@ protected void doWrite(HoodieRecord record, HoodieSchema schema, TypedProperties // record successful. record.deflate(); } catch (Throwable t) { - log.error("Error writing record " + record, t); + log.error("Error writing record {}", record, t); if (!config.getIgnoreWriteFailed()) { throw new HoodieException(t.getMessage(), t); } @@ -131,8 +132,10 @@ protected void doWrite(HoodieRecord record, HoodieSchema schema, TypedProperties public void write() { Iterator keyIterator; if (hoodieTable.requireSortedRecords()) { - // Sorting the keys limits the amount of extra memory required for writing sorted records - keyIterator = recordMap.keySet().stream().sorted().iterator(); + // Sorting the keys limits the amount of extra memory required for writing sorted records. + // requireSortedRecords() is true only for HFile base files, which order keys by UTF-8 bytes, + // not String (UTF-16) order, so sort with the matching comparator. + keyIterator = recordMap.keySet().stream().sorted(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR).iterator(); } else { keyIterator = recordMap.keySet().stream().iterator(); } @@ -178,7 +181,7 @@ public IOType getIOType() { */ @Override public List close() { - log.info("Closing the file " + writeStatus.getFileId() + " as we are done with all the records " + recordsWritten); + log.info("Closing the file {} as we are done with all the records {}", writeStatus.getFileId(), recordsWritten); try { if (isClosed()) { // Handle has already been closed diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/ExternalFileClusteringWriteHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/ExternalFileClusteringWriteHandle.java index 9c9a5a3f0ba1b..f947ff9bc2b96 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/ExternalFileClusteringWriteHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/ExternalFileClusteringWriteHandle.java @@ -62,7 +62,7 @@ public ExternalFileClusteringWriteHandle(HoodieWriteConfig config, String instan // Create inProgress marker file createMarkerFile(partitionPath, path.getName()); - LOG.info("New ExternalFileClusteringWriteHandle for partition :" + partitionPath + " with fileId " + fileId); + LOG.info("New ExternalFileClusteringWriteHandle for partition :{} with fileId {}", partitionPath, fileId); } /** diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedAppendHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedAppendHandle.java index a081709f6fc22..c40ce0158a3cd 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedAppendHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedAppendHandle.java @@ -82,10 +82,20 @@ public void doAppend() { new HoodieLogFile(new StoragePath(FSUtils.constructAbsolutePath( config.getBasePath(), operation.getPartitionPath()), logFileName))); // Initializes the record iterator, log compaction requires writing the deletes into the delete block of the resulting log file. - try (HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder().withReaderContext(readerContext).withHoodieTableMetaClient(hoodieTable.getMetaClient()) - .withLatestCommitTime(instantTime).withPartitionPath(partitionPath).withLogFiles(logFiles).withBaseFileOption(Option.empty()).withDataSchema(writeSchemaWithMetaFields) - .withRequestedSchema(writeSchemaWithMetaFields).withInternalSchema(internalSchemaOption).withProps(props).withEmitDelete(true) - .withShouldUseRecordPosition(usePosition).withSortOutput(hoodieTable.requireSortedRecords()) + try (HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() + .withReaderContext(readerContext) + .withHoodieTableMetaClient(hoodieTable.getMetaClient()) + .withLatestCommitTime(instantTime) + .withPartitionPath(partitionPath) + .withLogFiles(logFiles) + .withBaseFileOption(Option.empty()) + .withDataSchema(writeSchemaWithMetaFields) + .withRequestedSchema(writeSchemaWithMetaFields) + .withInternalSchemaOpt(internalSchemaOption) + .withProps(props) + .withEmitDelete(true) + .withShouldUseRecordPosition(usePosition) + .withSortOutput(hoodieTable.requireSortedRecords()) // instead of using config.enableOptimizedLogBlocksScan(), we set to true as log compaction blocks only supported in scanV2 .build()) { recordItr = new CloseableMappingIterator<>(fileGroupReader.getLogRecordsOnly(), record -> { @@ -96,7 +106,7 @@ public void doAppend() { header.put(HoodieLogBlock.HeaderMetadataType.COMPACTED_BLOCK_TIMES, StringUtils.join(fileGroupReader.getValidBlockInstants(), ",")); super.doAppend(); - this.readStats = fileGroupReader.getStats(); + this.readStats = fileGroupReader.getReadStats(); } catch (IOException e) { throw new HoodieIOException("Failed to initialize file group reader for " + fileId, e); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java index d708c15f33845..b9432b626cd51 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java @@ -51,6 +51,7 @@ import org.apache.hudi.exception.HoodieUpsertException; import org.apache.hudi.internal.schema.InternalSchema; import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils; +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils; import org.apache.hudi.internal.schema.utils.SerDeHelper; import org.apache.hudi.io.storage.HoodieFileWriterFactory; import org.apache.hudi.keygen.BaseKeyGenerator; @@ -257,8 +258,10 @@ public void doMerge() { } boolean usePosition = config.getBooleanOrDefault(MERGE_USE_RECORD_POSITIONS); Option internalSchemaOption = SerDeHelper.fromJson(config.getInternalSchema()) - .map(internalSchema -> AvroSchemaEvolutionUtils.reconcileSchema(writeSchemaWithMetaFields.toAvroSchema(), internalSchema, - config.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS))); + .map(internalSchema -> AvroSchemaEvolutionUtils.reconcileSchema(writeSchemaWithMetaFields, internalSchema, + config.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + config.getStringOrDefault(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES)))); long maxMemoryPerCompaction = getMaxMemoryForMerge(); props.put(HoodieMemoryConfig.MAX_MEMORY_FOR_MERGE.key(), String.valueOf(maxMemoryPerCompaction)); Option> logFilesStreamOpt = compactionOperation.map(op -> op.getDeltaFileNames().stream().map(logFileName -> @@ -301,7 +304,7 @@ public void doMerge() { // The stats of inserts, updates, and deletes are updated once at the end // These will be set in the write stat when closing the merge handle - this.readStats = fileGroupReader.getStats(); + this.readStats = fileGroupReader.getReadStats(); this.insertRecordsWritten = readStats.getNumInserts(); this.updatedRecordsWritten = readStats.getNumUpdates(); this.recordsDeleted = readStats.getNumDeletes(); @@ -318,10 +321,10 @@ protected long getMaxMemoryForMerge() { private HoodieFileGroupReader getFileGroupReader(boolean usePosition, Option internalSchemaOption, TypedProperties props, Option> logFileStreamOpt, Iterator> incomingRecordsItr) { - HoodieFileGroupReader.Builder fileGroupBuilder = HoodieFileGroupReader.newBuilder().withReaderContext(readerContext).withHoodieTableMetaClient(hoodieTable.getMetaClient()) + HoodieFileGroupReader.HoodieFileGroupReaderBuilder fileGroupBuilder = HoodieFileGroupReader.builder().withReaderContext(readerContext).withHoodieTableMetaClient(hoodieTable.getMetaClient()) .withLatestCommitTime(maxInstantTime).withPartitionPath(partitionPath).withBaseFileOption(Option.ofNullable(baseFileToMerge)) .withDataSchema(writeSchemaWithMetaFields).withRequestedSchema(writeSchemaWithMetaFields) - .withInternalSchema(internalSchemaOption).withProps(props) + .withInternalSchemaOpt(internalSchemaOption).withProps(props) .withShouldUseRecordPosition(usePosition).withSortOutput(hoodieTable.requireSortedRecords()) .withFileGroupUpdateCallback(createCallback()); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java index 5ea8ba460f873..e76d3dbd77bcd 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java @@ -39,7 +39,7 @@ import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.AppendResult; -import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; +import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieDeleteBlock; import org.apache.hudi.common.table.log.block.HoodieHFileDataBlock; @@ -54,6 +54,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.SizeEstimator; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieAppendException; @@ -105,7 +106,7 @@ public class HoodieAppendHandle extends HoodieWriteHandle> recordItr; // Writer to log into the file group's latest slice. - protected Writer writer; + protected HoodieLogFormat.Writer writer; protected final List statuses; // Total number of records written during appending @@ -260,7 +261,7 @@ private void init(HoodieRecord record) { ? getInstantTimeForLogFile(record) : deltaWriteStat.getPrevCommit(); this.writer = createLogWriter(instantTime, fileSliceOpt); } catch (Exception e) { - log.error("Error in update task at commit " + instantTime, e); + log.error("Error in update task at commit {}", instantTime, e); writeStatus.setGlobalError(e); throw new HoodieUpsertException("Failed to initialize HoodieAppendHandle for FileId: " + fileId + " on commit " + instantTime + " on storage path " + hoodieTable.getMetaClient().getBasePath() + "/" + partitionPath, e); @@ -558,14 +559,16 @@ public List close() { writer = null; } - // update final size, once for all log files - // TODO we can actually deduce file size purely from AppendResult (based on offset and size - // of the appended block) + // Set the final on-disk size of each log file. Appends within an append handle are contiguous, + // so a log file's length equals its start offset plus the total bytes appended to it. That is + // exactly what fs.getFileStatus().getLength() returns, and both values are already captured by + // the AppendResult stats (logOffset and the accumulated fileSizeInBytes). Deriving the size this + // way avoids a getPathInfo/HEAD per log file, which is a remote round trip per file group on + // object stores. for (WriteStatus status : statuses) { - long logFileSize = storage.getPathInfo( - new StoragePath(config.getBasePath(), status.getStat().getPath())) - .getLength(); - status.getStat().setFileSizeInBytes(logFileSize); + HoodieDeltaWriteStat stat = (HoodieDeltaWriteStat) status.getStat(); + long appendedBytes = stat.getFileSizeInBytes(); + stat.setFileSizeInBytes(stat.getLogOffset() + appendedBytes); } // generate Secondary index stats if streaming writes is enabled. @@ -725,7 +728,9 @@ protected HoodieLogBlock getDataBlock(HoodieWriteConfig writeConfig, case HFILE_DATA_BLOCK: // Not supporting positions in HFile data blocks header.remove(HeaderMetadataType.BASE_FILE_INSTANT_TIME_OF_RECORD_POSITIONS); - records.sort(Comparator.comparing(HoodieRecord::getRecordKey)); + // HFile orders keys by their raw UTF-8 bytes, so sort by UTF-8 bytes rather than + // String (UTF-16) order to keep non-ASCII / binary keys consistent with the writer. + records.sort(Comparator.comparing(HoodieRecord::getRecordKey, StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)); return new HoodieHFileDataBlock( records, header, writeConfig.getHFileCompressionAlgorithm(), new StoragePath(writeConfig.getBasePath())); case PARQUET_DATA_BLOCK: diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java index 94a86f1f94762..fa7baab2af150 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java @@ -70,7 +70,7 @@ private MessageType getWriteSchema(HoodieWriteConfig config, List i try { ParquetUtils parquetUtils = new ParquetUtils(); MessageType fileSchema = parquetUtils.readMessageType(table.getStorage(), inputFiles.get(0)); - log.info("Binary copy schema evolution disabled. Using schema from input file: " + inputFiles.get(0)); + log.info("Binary copy schema evolution disabled. Using schema from input file: {}", inputFiles.get(0)); return fileSchema; } catch (Exception e) { log.error("Failed to read schema from input file", e); @@ -109,8 +109,8 @@ public HoodieBinaryCopyHandle( } public void write() { - log.info("Start to merge source files " + this.inputFiles + " into target file: " + this.path - + ". Please pay attention that we will not rolling files based on max-file-size config during binary copy."); + log.info("Start to merge source files {} into target file: {}. Please pay attention that we will not rolling files based on max-file-size config during binary copy.", + this.inputFiles, this.path); HoodieTimer timer = HoodieTimer.start(); long records = 0; try { @@ -123,12 +123,12 @@ public void write() { this.recordsWritten = records; this.insertRecordsWritten = records; } - log.info("Finish rewriting " + this.path + ". Using " + timer.endTimer() + " mills"); + log.info("Finish rewriting {}. Using {} mills", this.path, timer.endTimer()); } @Override public List close() { - log.info("Closing the file " + writeStatus.getFileId() + " as we are done with all the records " + recordsWritten); + log.info("Closing the file {} as we are done with all the records {}", writeStatus.getFileId(), recordsWritten); try { this.writer.close(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java index 9456d5ce586bb..7cc74c40afeeb 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieUpsertException; import org.apache.hudi.keygen.BaseKeyGenerator; @@ -47,7 +48,7 @@ @NotThreadSafe public class HoodieSortedMergeHandle extends HoodieWriteMergeHandle { - private final Queue newRecordKeysSorted = new PriorityQueue<>(); + private final Queue newRecordKeysSorted = new PriorityQueue<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); public HoodieSortedMergeHandle(HoodieWriteConfig config, String instantTime, HoodieTable hoodieTable, Iterator> recordItr, String partitionPath, String fileId, TaskContextSupplier taskContextSupplier, @@ -78,7 +79,7 @@ public void write(HoodieRecord oldRecord) { // To maintain overall sorted order across updates and inserts, write any new inserts whose keys are less than // the oldRecord's key. - while (!newRecordKeysSorted.isEmpty() && newRecordKeysSorted.peek().compareTo(key) <= 0) { + while (!newRecordKeysSorted.isEmpty() && StringUtils.compareUtf8Bytes(newRecordKeysSorted.peek(), key) <= 0) { String keyToPreWrite = newRecordKeysSorted.remove(); if (keyToPreWrite.equals(key)) { // will be handled as an update later diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieWriteHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieWriteHandle.java index cd22186aa8e09..cc8d9c7e927a7 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieWriteHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieWriteHandle.java @@ -37,6 +37,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.LogFileCreationCallback; import org.apache.hudi.common.table.read.DeleteContext; import org.apache.hudi.common.util.ConfigUtils; @@ -288,9 +289,9 @@ protected HoodieLogFormat.Writer createLogWriter(String instantTime, Option fileSliceOpt) { try { if (config.getWriteVersion().greaterThanOrEquals(HoodieTableVersion.EIGHT)) { - return HoodieLogFormat.newWriterBuilder() - .onParentPath(FSUtils.constructAbsolutePath(hoodieTable.getMetaClient().getBasePath(), partitionPath)) - .withFileId(fileId) + return HoodieLogFormatWriter.builder() + .withParentPath(FSUtils.constructAbsolutePath(hoodieTable.getMetaClient().getBasePath(), partitionPath)) + .withLogFileId(fileId) .withInstantTime(instantTime) .withFileSize(0L) .withSizeThreshold(config.getLogFileMaxSize()) @@ -305,9 +306,9 @@ protected HoodieLogFormat.Writer createLogWriter(String instantTime, String file Option latestLogFile = fileSliceOpt.isPresent() ? fileSliceOpt.get().getLatestLogFile() : Option.empty(); - return HoodieLogFormat.newWriterBuilder() - .onParentPath(FSUtils.constructAbsolutePath(hoodieTable.getMetaClient().getBasePath(), partitionPath)) - .withFileId(fileId) + return HoodieLogFormatWriter.builder() + .withParentPath(FSUtils.constructAbsolutePath(hoodieTable.getMetaClient().getBasePath(), partitionPath)) + .withLogFileId(fileId) .withInstantTime(instantTime) .withLogVersion(latestLogFile.map(HoodieLogFile::getLogVersion).orElse(HoodieLogFile.LOGFILE_BASE_VERSION)) .withFileSize(latestLogFile.map(HoodieLogFile::getFileSize).orElse(0L)) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/IOUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/IOUtils.java index e67cc98e50176..778feffa51f88 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/IOUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/IOUtils.java @@ -122,7 +122,7 @@ public static Iterator> runMerge(HoodieMergeHandle // TODO(vc): This needs to be revisited if (mergeHandle.getPartitionPath() == null) { - log.info("Upsert Handle has partition path as null " + mergeHandle.getOldFilePath() + ", " + mergeHandle.getWriteStatuses()); + log.info("Upsert Handle has partition path as null {}, {}", mergeHandle.getOldFilePath(), mergeHandle.getWriteStatuses()); } return Collections.singletonList(mergeHandle.close()).iterator(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/CustomAvroKeyGenerator.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/CustomAvroKeyGenerator.java index beeb4947acd42..6ab6378367ae7 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/CustomAvroKeyGenerator.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/CustomAvroKeyGenerator.java @@ -62,11 +62,7 @@ public enum PartitionKeyType { public CustomAvroKeyGenerator(TypedProperties props) { super(props); - this.recordKeyFields = Option.ofNullable(props.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), null)) - .map(recordKeyConfigValue -> - Arrays.stream(recordKeyConfigValue.split(FIELD_SEPARATOR)) - .map(String::trim).collect(Collectors.toList()) - ).orElse(Collections.emptyList()); + this.recordKeyFields = KeyGenUtils.getRecordKeyFields(props); this.partitionPathFields = Arrays.stream(props.getString(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key()).split(FIELD_SEPARATOR)).map(String::trim).collect(Collectors.toList()); this.recordKeyGenerator = getRecordKeyFieldNames().size() == 1 ? new SimpleAvroKeyGenerator(config) : new ComplexAvroKeyGenerator(config); this.partitionKeyGenerators = getPartitionKeyGenerators(this.partitionPathFields, config); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java index 292f261c2223c..81faa0cdb2ba0 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java @@ -39,11 +39,9 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; -import java.util.stream.Collectors; import static org.apache.hudi.config.HoodieWriteConfig.COMPLEX_KEYGEN_NEW_ENCODING; import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; @@ -72,7 +70,7 @@ public class KeyGenUtils { */ public static KeyGeneratorType inferKeyGeneratorType( Option recordsKeyFields, String partitionFields) { - int numRecordKeyFields = recordsKeyFields.map(fields -> fields.split(",").length).orElse(0); + int numRecordKeyFields = recordsKeyFields.map(keyStr -> getRecordKeyFields(keyStr).size()).orElse(0); KeyGeneratorType partitionKeyGeneratorType = inferKeyGeneratorTypeFromPartitionFields(partitionFields); if (numRecordKeyFields <= 1) { return partitionKeyGeneratorType; @@ -331,13 +329,21 @@ public static KeyGenerator createKeyGeneratorByClassName(TypedProperties props) } public static List getRecordKeyFields(TypedProperties props) { - return Option.ofNullable(props.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), null)) - .map(recordKeyConfigValue -> - Arrays.stream(recordKeyConfigValue.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toList()) - ).orElse(Collections.emptyList()); + return getRecordKeyFields(props.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), null)); + } + + public static List getRecordKeyFields(String recordKeys) { + return getKeyFields(recordKeys); + } + + public static List getIndexKeyFields(String indexKeys) { + return getKeyFields(indexKeys); + } + + private static List getKeyFields(String keys) { + return Option.ofNullable(keys) + .map(value -> StringUtils.split(value, ",")) + .orElse(Collections.emptyList()); } /** diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java index db30f301d97a5..93be569667fa1 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java @@ -58,7 +58,9 @@ import org.apache.hudi.common.schema.HoodieSchemaCache; import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieDeleteBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType; import org.apache.hudi.common.table.read.HoodieFileGroupReader; @@ -170,7 +172,7 @@ public abstract class HoodieBackedTableMetadataWriter implements HoodieTab // Average size of a record saved within the record index. // Record index has a fixed size schema. This has been calculated based on experiments with default settings // for block size (1MB), compression (GZ) and disabling the hudi metadata fields. - private static final int RECORD_INDEX_AVERAGE_RECORD_SIZE = 48; + public static final int RECORD_INDEX_AVERAGE_RECORD_SIZE = 48; private transient BaseHoodieWriteClient writeClient; protected HoodieWriteConfig metadataWriteConfig; @@ -429,7 +431,8 @@ private boolean initializeFromFilesystem(String dataTableInstantTime, List baseFilePaths) { .sum(); } + /** + * Resolves the data schema (with metadata fields added) for use during record index bootstrap. + * When the write config does not carry a schema (e.g. table-service operations such as clean), + * falls back to resolving the schema from the table's commit history / data files. + */ + static HoodieSchema resolveDataSchemaForRLIBootstrap(HoodieTableMetaClient metaClient, HoodieWriteConfig dataWriteConfig) { + String writeSchemaStr = dataWriteConfig.getWriteSchema(); + HoodieSchema rawSchema; + if (writeSchemaStr != null) { + rawSchema = HoodieSchema.parse(writeSchemaStr); + } else { + try { + rawSchema = new TableSchemaResolver(metaClient).getTableSchema(false); + } catch (Exception e) { + throw new HoodieException( + String.format("Could not resolve schema for table %s for record index bootstrap", metaClient.getBasePath()), e); + } + } + return HoodieSchemaCache.intern(HoodieSchemaUtils.addMetadataFields(rawSchema, dataWriteConfig.allowOperationMetadataField())); + } + /** * Fetch record locations from FileSlice snapshot. * @@ -971,26 +995,28 @@ private static HoodieData readRecordKeysFromFileSliceSnapshot( final FileSlice fileSlice = partitionAndFileSlice.getValue(); final String fileId = fileSlice.getFileId(); HoodieReaderContext readerContext = readerContextFactory.getContext(); - HoodieSchema dataSchema = HoodieSchemaCache.intern(HoodieSchemaUtils.addMetadataFields(HoodieSchema.parse(dataWriteConfig.getWriteSchema()), dataWriteConfig.allowOperationMetadataField())); + HoodieSchema dataSchema = resolveDataSchemaForRLIBootstrap(metaClient, dataWriteConfig); HoodieSchema requestedSchema = metaClient.getTableConfig().populateMetaFields() ? getRecordKeySchema() : HoodieSchemaUtils.projectSchema(dataSchema, Arrays.asList(metaClient.getTableConfig().getRecordKeyFields().orElse(new String[0]))); Option internalSchemaOption = SerDeHelper.fromJson(dataWriteConfig.getInternalSchema()); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withLatestCommitTime(instantTime.get()) .withDataSchema(dataSchema) .withRequestedSchema(requestedSchema) - .withInternalSchema(internalSchemaOption) + .withInternalSchemaOpt(internalSchemaOption) .withShouldUseRecordPosition(false) .withProps(metaClient.getTableConfig().getProps()) .build(); - String baseFileInstantTime = fileSlice.getBaseInstantTime(); + long baseFileInstantTimeMillis = HoodieMetadataPayload.parseRecordIndexInstantTime(fileSlice.getBaseInstantTime()); return new CloseableMappingIterator<>(fileGroupReader.getClosableIterator(), record -> { String recordKey = readerContext.getRecordContext().getRecordKey(record, requestedSchema); return HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partition, fileId, - baseFileInstantTime, 0); + baseFileInstantTimeMillis, 0); }); }); } @@ -1064,9 +1090,13 @@ private HoodieTableMetaClient initializeMetaClient() throws IOException { * * @param initializationTime Files which have a timestamp after this are neglected * @param pendingDataInstants Pending instants on data set + * @param skipZeroSizeFiles Whether zero-size data files should be skipped during listing, per + * hoodie.metadata.skip.zero.size.files.on.initialize. Both the initialize path and the + * restore-sync relisting pass the config so zero-size files are treated consistently; + * otherwise restore would re-add to the metadata table the files skipped at initialization. * @return List consisting of {@code DirectoryInfo} for each partition found. */ - private List listAllPartitionsFromFilesystem(String initializationTime, Set pendingDataInstants) { + private List listAllPartitionsFromFilesystem(String initializationTime, Set pendingDataInstants, boolean skipZeroSizeFiles) { if (dataMetaClient.getActiveTimeline().countInstants() == 0) { return Collections.emptyList(); } @@ -1078,6 +1108,7 @@ private List listAllPartitionsFromFilesystem(String initializatio StorageConfiguration storageConf = dataMetaClient.getStorageConf(); final String dirFilterRegex = dataWriteConfig.getMetadataConfig().getDirectoryFilterRegex(); StoragePath storageBasePath = dataMetaClient.getBasePath(); + long totalZeroSizeFiles = 0; while (!pathsToList.isEmpty()) { // In each round we will list a section of directories @@ -1091,12 +1122,13 @@ private List listAllPartitionsFromFilesystem(String initializatio List processedDirectories = engineContext.map(pathsToProcess, path -> { HoodieStorage storage = HoodieStorageUtils.getStorage(path, storageConf); String relativeDirPath = FSUtils.getRelativePartitionPath(storageBasePath, path); - return new DirectoryInfo(relativeDirPath, storage.listDirectEntries(path), initializationTime, pendingDataInstants); + return new DirectoryInfo(relativeDirPath, storage.listDirectEntries(path), initializationTime, pendingDataInstants, true, skipZeroSizeFiles); }, numDirsToList); // If the listing reveals a directory, add it to queue. If the listing reveals a hoodie partition, add it to // the results. for (DirectoryInfo dirInfo : processedDirectories) { + totalZeroSizeFiles += dirInfo.getZeroSizeFileCount(); if (!dirFilterRegex.isEmpty()) { final String relativePath = dirInfo.getRelativePath(); if (!relativePath.isEmpty() && relativePath.matches(dirFilterRegex)) { @@ -1115,6 +1147,10 @@ private List listAllPartitionsFromFilesystem(String initializatio } } + if (totalZeroSizeFiles > 0) { + final long zeroSizeCount = totalZeroSizeFiles; + metrics.ifPresent(m -> m.incrementMetric(HoodieMetadataMetrics.SKIPPED_ZERO_SIZE_FILES_ON_INITIALIZE_STR, zeroSizeCount)); + } return partitionsToBootstrap; } @@ -1173,9 +1209,9 @@ private void initializeFileGroups(HoodieTableMetaClient dataMetaClient, Metadata final HoodieDeleteBlock block = new HoodieDeleteBlock(Collections.emptyList(), blockHeader); - try (HoodieLogFormat.Writer writer = HoodieLogFormat.newWriterBuilder() - .onParentPath(FSUtils.constructAbsolutePath(metadataWriteConfig.getBasePath(), relativePartitionPath)) - .withFileId(fileGroupFileId) + try (HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(FSUtils.constructAbsolutePath(metadataWriteConfig.getBasePath(), relativePartitionPath)) + .withLogFileId(fileGroupFileId) .withInstantTime(instantTime) .withLogVersion(HoodieLogFile.LOGFILE_BASE_VERSION) .withFileSize(0L) @@ -1444,6 +1480,12 @@ public O secondaryWriteToMetadataTablePartitions(I preppedRecords, String instan @Override public void completeStreamingCommit(String instantTime, HoodieEngineContext context, List partialWriteStats, HoodieCommitMetadata metadata) { + if (metadataMetaClient.getActiveTimeline().filterCompletedInstants().containsInstant(instantTime)) { + LOG.info("Skipping streaming metadata commit completion for already completed instant {}", instantTime); + getWriteClient().postCommit(instantTime); + return; + } + List allWriteStats = new ArrayList<>(partialWriteStats); // update metadata for left over partitions which does not have streaming writes support. allWriteStats.addAll(prepareAndWriteToNonStreamingPartitions(metadata, instantTime).map(WriteStatus::getStat).collectAsList()); @@ -1741,7 +1783,8 @@ public void update(HoodieRestoreMetadata restoreMetadata, String instantTime) { // Restore requires the existing pipelines to be shutdown. So we can safely scan the dataset to find the current // list of files in the filesystem. - List dirInfoList = listAllPartitionsFromFilesystem(instantTime, Collections.emptySet()); + List dirInfoList = listAllPartitionsFromFilesystem(instantTime, Collections.emptySet(), + dataWriteConfig.getMetadataConfig().shouldSkipZeroSizeFilesOnInitialize()); Map dirInfoMap = dirInfoList.stream().collect(Collectors.toMap(DirectoryInfo::getRelativePath, Function.identity())); dirInfoList.clear(); @@ -1882,6 +1925,9 @@ && compareTimestamps(commitToRollbackInstant.getCompletionTime(), LESSER_THAN_OR public void close() throws Exception { if (metadata != null) { metadata.close(); + // Keep the closed reader reference: guarded update paths use its presence to proceed and + // mayBeReinitMetadataReader() detects the closed file-system view and reopens the reader. + // Nullifying it here would silently skip subsequent metadata updates, such as rollbacks. } if (writeClient != null) { writeClient.close(); @@ -1959,7 +2005,11 @@ protected void commitInternal(String instantTime, Map m.updateSizeMetrics(metadataMetaClient, metadata, dataMetaClient.getTableConfig().getMetadataPartitions())); + metrics.ifPresent(m -> { + if (m.isDetailedMetricsEnabled()) { + m.updateSizeMetrics(metadataMetaClient, metadata, dataMetaClient.getTableConfig().getMetadataPartitions()); + } + }); } protected abstract void bulkInsertAndCommit(BaseHoodieWriteClient writeClient, String instantTime, I preppedRecordInputs, Option bulkInsertPartitioner); @@ -2026,7 +2076,7 @@ protected Pair, List> tagRecordsWith // scheduling of INDEX only initializes the file group and not add commit // so if there are no committed file slices, look for inflight slices if (isNonGlobalRLI) { - // For isNonGlobalRLI, new partitions added to the data table will cause new filegroups that are not yet commited + // For isNonGlobalRLI, new partitions added to the data table will cause new filegroups that are not yet committed // therefore, we always need to look for inflight filegroups fileSlices = getPartitionLatestFileSlicesIncludingInflight(metadataMetaClient, Option.ofNullable(fsView), partitionPath); } else if (fileSlices.isEmpty()) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriterTableVersionSix.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriterTableVersionSix.java index 2d4717a80054b..eb09e9a12b7ee 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriterTableVersionSix.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriterTableVersionSix.java @@ -237,7 +237,7 @@ public void update(HoodieRollbackMetadata rollbackMetadata, String instantTime) String rollbackInstantTime = createRollbackTimestamp(instantTime); if (metadataMetaClient.getActiveTimeline().containsInstant(deltaCommitInstant)) { - LOG.info("Rolling back MDT deltacommit " + commitToRollbackInstantTime); + LOG.info("Rolling back MDT deltacommit {}", commitToRollbackInstantTime); if (!getWriteClient().rollback(commitToRollbackInstantTime, rollbackInstantTime)) { throw new HoodieMetadataException("Failed to rollback deltacommit at " + commitToRollbackInstantTime); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/RecordIndexMapper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/RecordIndexMapper.java index 329e74e60676e..4b736768ebc25 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/RecordIndexMapper.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/RecordIndexMapper.java @@ -30,7 +30,9 @@ import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Mapper for Record Level Index (RLI). @@ -45,6 +47,8 @@ public RecordIndexMapper(HoodieWriteConfig dataWriteConfig) { @Override protected List generateRecords(WriteStatus writeStatus) { List allRecords = new ArrayList<>(); + // delegates of one write status share at most a few distinct instants, so memoize the parse + Map instantTimeMillisCache = new HashMap<>(); for (HoodieRecordDelegate recordDelegate : writeStatus.getIndexStats().getWrittenRecordDelegates()) { if (!writeStatus.isErrored(recordDelegate.getHoodieKey())) { if (recordDelegate.isIgnoreIndexUpdate()) { @@ -68,7 +72,10 @@ protected List generateRecords(WriteStatus writeStatus) { // Insert new record case hoodieRecord = HoodieMetadataPayload.createRecordIndexUpdate( recordDelegate.getRecordKey(), recordDelegate.getPartitionPath(), - newLocation.get().getFileId(), newLocation.get().getInstantTime(), dataWriteConfig.getWritesFileIdEncoding()); + newLocation.get().getFileId(), + instantTimeMillisCache.computeIfAbsent( + newLocation.get().getInstantTime(), HoodieMetadataPayload::parseRecordIndexInstantTime), + dataWriteConfig.getWritesFileIdEncoding()); allRecords.add(hoodieRecord); } } else { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/SecondaryIndexRecordGenerationUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/SecondaryIndexRecordGenerationUtils.java index 9cb9b8ca7df5f..b86688b81e609 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/SecondaryIndexRecordGenerationUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/SecondaryIndexRecordGenerationUtils.java @@ -123,7 +123,7 @@ public static HoodieData convertWriteStatsToSecondaryIndexReco // validate that for a given fileId, either we have 1 parquet file or N log files. AtomicInteger totalParquetFiles = new AtomicInteger(); AtomicInteger totalLogFiles = new AtomicInteger(); - writeStats.stream().forEach(writeStat -> { + writeStats.forEach(writeStat -> { if (FSUtils.isLogFile(new StoragePath(basePath, writeStat.getPath()))) { totalLogFiles.getAndIncrement(); } else { @@ -156,7 +156,7 @@ public static HoodieData convertWriteStatsToSecondaryIndexReco } else { // log files are added in current commit // add new log files to existing latest file slice and compute the secondary index to primary key mapping. FileSlice latestFileSlice = fileSliceOption.get(); - writeStats.stream().forEach(writeStat -> { + writeStats.forEach(writeStat -> { StoragePathInfo logFile = new StoragePathInfo(new StoragePath(basePath, writeStat.getPath()), writeStat.getFileSizeInBytes(), false, (short) 0, 0, 0); latestFileSlice.addLogFile(new HoodieLogFile(logFile)); }); @@ -286,9 +286,11 @@ private static ClosableIterator> createSecondaryIndexRe boolean allowInflightInstants) throws IOException { String secondaryKeyField = indexDefinition.getSourceFieldsKey(); HoodieSchema requestedSchema = getRequestedSchemaForSecondaryIndex(metaClient, tableSchema, secondaryKeyField); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withHoodieTableMetaClient(metaClient) .withProps(props) .withLatestCommitTime(instantTime) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/HoodieMetrics.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/HoodieMetrics.java index c777e842bbd39..941085f451bd2 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/HoodieMetrics.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/HoodieMetrics.java @@ -266,7 +266,7 @@ public Timer.Context getSourceReadAndIndexTimerCtx() { } public Timer.Context getConflictResolutionCtx() { - if (config.isLockingMetricsEnabled() && conflictResolutionTimer == null) { + if (config.isMetricsOn() && config.isLockingMetricsEnabled() && conflictResolutionTimer == null) { conflictResolutionTimer = createTimer(conflictResolutionTimerName); } return conflictResolutionTimer == null ? null : conflictResolutionTimer.time(); @@ -555,6 +555,9 @@ private void updateMetric(final String action, final String metricName, final lo * Given a commit action, metrics name and value this method reports custom metrics. */ public void reportMetrics(String commitAction, String metricName, long value) { + if (!config.isMetricsOn()) { + return; + } metrics.registerGauge(getMetricsName(commitAction, metricName), value); } @@ -566,7 +569,7 @@ public long getDurationInMs(long ctxDuration) { } public void emitConflictResolutionSuccessful() { - if (config.isLockingMetricsEnabled()) { + if (config.isMetricsOn() && config.isLockingMetricsEnabled()) { log.info("Sending conflict resolution success metric"); conflictResolutionSuccessCounter = getCounter(conflictResolutionSuccessCounter, conflictResolutionSuccessCounterName); conflictResolutionSuccessCounter.inc(); @@ -574,7 +577,7 @@ public void emitConflictResolutionSuccessful() { } public void emitConflictResolutionFailed() { - if (config.isLockingMetricsEnabled()) { + if (config.isMetricsOn() && config.isLockingMetricsEnabled()) { log.info("Sending conflict resolution failure metric"); conflictResolutionFailureCounter = getCounter(conflictResolutionFailureCounter, conflictResolutionFailureCounterName); conflictResolutionFailureCounter.inc(); @@ -582,7 +585,7 @@ public void emitConflictResolutionFailed() { } public void emitConflictResolutionByCategory(HoodieWriteConflictException.ConflictCategory category) { - if (config.isLockingMetricsEnabled()) { + if (config.isMetricsOn() && config.isLockingMetricsEnabled()) { switch (category) { case INGESTION_VS_INGESTION: conflictResolutionIngestionVsIngestionCounter = getCounter( diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/BucketIndexBulkInsertPartitioner.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/BucketIndexBulkInsertPartitioner.java index 7d8d7c6900edd..5fd3909f05620 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/BucketIndexBulkInsertPartitioner.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/BucketIndexBulkInsertPartitioner.java @@ -25,12 +25,12 @@ import org.apache.hudi.io.AppendHandleFactory; import org.apache.hudi.io.SingleFileHandleCreateFactory; import org.apache.hudi.io.WriteHandleFactory; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -50,7 +50,7 @@ public abstract class BucketIndexBulkInsertPartitioner extends BucketSortBulk public BucketIndexBulkInsertPartitioner(HoodieTable table, String sortString, boolean preserveHoodieMetadata) { super(table, sortString); - this.indexKeyFields = Arrays.asList(table.getConfig().getBucketIndexHashField().split(",")); + this.indexKeyFields = KeyGenUtils.getIndexKeyFields(table.getConfig().getBucketIndexHashField()); this.consistentLogicalTimestampEnabled = table.getConfig().isConsistentLogicalTimestampEnabled(); this.preserveHoodieMetadata = preserveHoodieMetadata; // Multiple bulk inserts into COW using `BucketIndexBulkInsertPartitioner` is restricted, otherwise AppendHandleFactory will produce MOR log files diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java index a857ef4417d6b..f0d6d746bd3f6 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java @@ -757,7 +757,7 @@ private void deleteInvalidFilesByPartitions(HoodieEngineContext context, Map { final HoodieStorage storage = metaClient.getStorage(); - log.info("Deleting invalid data file=" + partitionFilePair); + log.info("Deleting invalid data file={}", partitionFilePair); // Delete try { StoragePath pathToDelete = new StoragePath(partitionFilePair.getValue()); @@ -828,7 +828,7 @@ void reconcileAgainstMarkers(HoodieEngineContext context, throw new HoodieDuplicateDataFileDetectedException("Duplicate data files detected " + invalidDataPaths); } - log.info("Removing duplicate files created due to task retries before committing. Paths=" + invalidDataPaths); + log.info("Removing duplicate files created due to task retries before committing. Paths={}", invalidDataPaths); Map>> invalidPathsByPartition = invalidDataPaths.stream() .map(dp -> Pair.of(new StoragePath(basePath, dp).getParent().toString(), @@ -1146,7 +1146,7 @@ public void deleteMetadataIndexIfNecessary() { Stream.of(MetadataPartitionType.getValidValues()).forEach(partitionType -> { if (shouldDeleteMetadataPartition(partitionType)) { try { - log.info("Deleting metadata partition because it is disabled in writer: " + partitionType.name()); + log.info("Deleting metadata partition because it is disabled in writer: {}", partitionType.name()); if (metadataPartitionExists(metaClient.getBasePath(), context, partitionType.getPartitionPath())) { deleteMetadataPartition(metaClient.getBasePath(), context, partitionType.getPartitionPath()); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java index 3fad30b22bdf0..0d27aa2a9f293 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java @@ -21,6 +21,7 @@ import org.apache.hudi.avro.model.HoodieActionInstant; import org.apache.hudi.avro.model.HoodieCleanMetadata; import org.apache.hudi.avro.model.HoodieCleanerPlan; +import org.apache.hudi.client.BaseHoodieClient; import org.apache.hudi.client.transaction.TransactionManager; import org.apache.hudi.common.HoodieCleanStat; import org.apache.hudi.common.engine.HoodieEngineContext; @@ -67,38 +68,45 @@ public CleanActionExecutor(HoodieEngineContext context, HoodieWriteConfig config this.txnManager = new TransactionManager(config, table.getStorage()); } - private static boolean deleteFileAndGetResult(HoodieStorage storage, String deletePathStr) { + /** + * Deletes the given path and returns whether it is gone afterwards. Cleaner plan file entries + * are always base/log/bootstrap file paths and partition deletions are always directories, so + * the caller passes the path type explicitly and no getPathInfo probe is needed. + * + * @param isDirectory true for a partition directory (deleted recursively), false for a file + */ + private static boolean deleteAndGetResult(HoodieStorage storage, String deletePathStr, boolean isDirectory) { StoragePath deletePath = new StoragePath(deletePathStr); - log.debug("Working on delete path: {}", deletePath); + String pathType = isDirectory ? "directory" : "file"; + log.debug("Working on deleting {}: {}", pathType, deletePath); try { - boolean deleteResult = storage.getPathInfo(deletePath).isDirectory() - ? storage.deleteDirectory(deletePath) - : storage.deleteFile(deletePath); + boolean deleteResult = isDirectory ? storage.deleteDirectory(deletePath) : storage.deleteFile(deletePath); if (deleteResult) { - log.debug("Cleaned file at path: {}", deletePath); - } else { - if (storage.exists(deletePath)) { - throw new HoodieIOException("Failed to delete path during clean execution " + deletePath); - } else { - log.debug("Already cleaned up file at path: {}", deletePath); - } + log.debug("Cleaned {}: {}", pathType, deletePath); + return true; + } + if (storage.exists(deletePath)) { + throw new HoodieIOException("Failed to delete " + pathType + " during clean execution " + deletePath); } - return deleteResult; + // Hadoop file systems report a missing path by returning false from delete instead of + // throwing FileNotFoundException, so this is the regular retried-clean case below. + log.debug("Already cleaned up {}: {}", pathType, deletePath); + return true; } catch (FileNotFoundException fio) { - // With cleanPlan being used for retried cleaning operations, its possible to clean a file twice if a file to be + // With cleanPlan being used for retried cleaning operations, its possible to clean a path twice if a path to be // deleted is not found, treat it as a success. In other words, there is nothing else to be cleaned up on the // FileSystem, except for updating the MDT. By returning success, we would remove the entry from MDT. return true; } catch (IOException e) { try { if (storage.exists(deletePath)) { - log.error("Delete file failed: {} and file still exists", deletePath, e); + log.error("Delete {} failed: {} and it still exists", pathType, deletePath, e); throw new HoodieIOException(e.getMessage(), e); } - log.warn("Delete file failed: {} but file does not exist", deletePath, e); + log.warn("Delete {} failed: {} but it does not exist", pathType, deletePath, e); return false; } catch (IOException ex) { - log.error("Delete file failed: {} with exception: {} and existence check also failed", deletePath, e, ex); + log.error("Delete {} failed: {} with exception: {} and existence check also failed", pathType, deletePath, e, ex); throw new HoodieIOException(ex.getMessage(), ex); } } @@ -112,7 +120,7 @@ private static Stream> deleteFilesFunc(Iterator String partitionPath = partitionDelFileTuple.getLeft(); StoragePath deletePath = new StoragePath(partitionDelFileTuple.getRight().getFilePath()); String deletePathStr = deletePath.toString(); - boolean deletedFileResult = deleteFileAndGetResult(storage, deletePathStr); + boolean deletedFileResult = deleteAndGetResult(storage, deletePathStr, false); final PartitionCleanStat partitionCleanStat = partitionCleanStatMap.computeIfAbsent(partitionPath, k -> new PartitionCleanStat(partitionPath)); boolean isBootstrapBasePathFile = partitionDelFileTuple.getRight().isBootstrapBaseFile(); @@ -160,7 +168,7 @@ List clean(HoodieEngineContext context, HoodieCleanerPlan clean : Collections.emptyList(); partitionsToBeDeleted.forEach(entry -> { if (!isNullOrEmpty(entry)) { - deleteFileAndGetResult(table.getStorage(), table.getMetaClient().getBasePath() + "/" + entry); + deleteAndGetResult(table.getStorage(), table.getMetaClient().getBasePath() + "/" + entry, true); } }); @@ -170,20 +178,18 @@ List clean(HoodieEngineContext context, HoodieCleanerPlan clean ? partitionCleanStatsMap.get(partitionPath) : new PartitionCleanStat(partitionPath); HoodieActionInstant actionInstant = cleanerPlan.getEarliestInstantToRetain(); - return HoodieCleanStat.newBuilder().withPolicy(config.getCleanerPolicy()).withPartitionPath(partitionPath) - .withEarliestCommitRetained(Option.ofNullable( - actionInstant != null - ? instantGenerator.createNewInstant(HoodieInstant.State.valueOf(actionInstant.getState()), - actionInstant.getAction(), actionInstant.getTimestamp()) - : null)) + return HoodieCleanStat.builder() + .withPolicy(config.getCleanerPolicy()) + .withPartitionPath(partitionPath) + .withEarliestCommitToRetain(actionInstant != null ? actionInstant.getTimestamp() : "") .withLastCompletedCommitTimestamp(cleanerPlan.getLastCompletedCommitTimestamp()) - .withDeletePathPattern(partitionCleanStat.deletePathPatterns()) - .withSuccessfulDeletes(partitionCleanStat.successDeleteFiles()) - .withFailedDeletes(partitionCleanStat.failedDeleteFiles()) + .withDeletePathPatterns(partitionCleanStat.deletePathPatterns()) + .withSuccessDeleteFiles(partitionCleanStat.successDeleteFiles()) + .withFailedDeleteFiles(partitionCleanStat.failedDeleteFiles()) .withDeleteBootstrapBasePathPatterns(partitionCleanStat.getDeleteBootstrapBasePathPatterns()) - .withSuccessfulDeleteBootstrapBaseFiles(partitionCleanStat.getSuccessfulDeleteBootstrapBaseFiles()) + .withSuccessDeleteBootstrapBaseFiles(partitionCleanStat.getSuccessfulDeleteBootstrapBaseFiles()) .withFailedDeleteBootstrapBaseFiles(partitionCleanStat.getFailedDeleteBootstrapBaseFiles()) - .isPartitionDeleted(partitionsToBeDeleted.contains(partitionPath)) + .withPartitionDeleted(partitionsToBeDeleted.contains(partitionPath)) .build(); }).collect(Collectors.toList()); } @@ -215,7 +221,6 @@ private HoodieCleanMetadata runClean(HoodieTable table, HoodieInstan } List cleanStats = clean(context, cleanerPlan); - table.getMetaClient().reloadActiveTimeline(); HoodieCleanMetadata metadata; if (cleanStats.isEmpty()) { metadata = createEmptyCleanMetadata(cleanerPlan, inflightInstant, timer.endTimer()); @@ -228,6 +233,10 @@ private HoodieCleanMetadata runClean(HoodieTable table, HoodieInstan ); } this.txnManager.beginStateChange(Option.of(inflightInstant), Option.empty()); + // Reload inside the lock so mergeRollingMetadata reads the latest timeline, + // matching the same contract as mergeRollingMetadata for commit metadata. + table.getMetaClient().reloadActiveTimeline(); + BaseHoodieClient.mergeRollingMetadata(table, config, metadata); writeTableMetadata(metadata, inflightInstant.requestedTime()); table.getActiveTimeline().transitionCleanInflightToComplete( false, diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringExecutionStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringExecutionStrategy.java index d34a85a97bd41..9610f728f8c77 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringExecutionStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringExecutionStrategy.java @@ -107,7 +107,11 @@ protected ClosableIterator> getRecordIterator(ReaderContextFacto protected TypedProperties getReaderProperties(long maxMemory) { HoodieWriteConfig config = getWriteConfig(); - TypedProperties props = new TypedProperties(); + // Seed from the full write config so that merge-related properties (e.g. the custom record + // merger impl classes, merge mode and strategy id) are propagated to the file group reader. + // Without this, reading source file groups with a CUSTOM merge mode fails to resolve the + // configured merger (HUDI-18980). This mirrors the compaction read path. + TypedProperties props = TypedProperties.copy(config.getProps()); props.setProperty(SPILLABLE_MAP_BASE_PATH.key(), config.getSpillableMapBasePath()); props.setProperty(SPILLABLE_DISK_MAP_TYPE.key(), config.getCommonConfig().getSpillableDiskMapType().toString()); props.setProperty(DISK_MAP_BITCASK_COMPRESSION_ENABLED.key(), Boolean.toString(config.getCommonConfig().isBitCaskDiskMapCompressionEnabled())); @@ -142,9 +146,18 @@ protected static HoodieFileGroupReader getFileGroupReader(HoodieTableMeta ReaderContextFactory readerContextFactory, String instantTime, TypedProperties properties, boolean usePosition) { HoodieReaderContext readerContext = readerContextFactory.getContext(); - return HoodieFileGroupReader.newBuilder() - .withReaderContext(readerContext).withHoodieTableMetaClient(metaClient).withLatestCommitTime(instantTime) - .withFileSlice(fileSlice).withDataSchema(readerSchema).withRequestedSchema(readerSchema).withInternalSchema(internalSchemaOption) - .withShouldUseRecordPosition(usePosition).withProps(properties).build(); + return HoodieFileGroupReader.builder() + .withReaderContext(readerContext) + .withHoodieTableMetaClient(metaClient) + .withLatestCommitTime(instantTime) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) + .withDataSchema(readerSchema) + .withRequestedSchema(readerSchema) + .withInternalSchemaOpt(internalSchemaOption) + .withShouldUseRecordPosition(usePosition) + .withProps(properties) + .build(); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringPlanStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringPlanStrategy.java index 353f590c3f971..a2a32825d12ba 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringPlanStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/ClusteringPlanStrategy.java @@ -41,8 +41,11 @@ import lombok.extern.slf4j.Slf4j; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -137,6 +140,20 @@ protected Stream getFileSlicesEligibleForClustering(String partition) */ protected abstract Map getStrategyParams(); + /** + * Keep partitions from the current scheduling window that are not scheduled in this plan as missing + * partitions so that they can be picked up by later incremental clustering schedules. + */ + protected List getMissingPartitionsFromCurrentWindow(List partitionsToSchedule, + List partitionsInCurrentWindow) { + if (!getWriteConfig().isIncrementalTableServiceEnabled()) { + return new ArrayList<>(); + } + Set missingPartitions = new LinkedHashSet<>(partitionsInCurrentWindow); + missingPartitions.removeAll(new HashSet<>(partitionsToSchedule)); + return new ArrayList<>(missingPartitions); + } + /** * Returns any specific parameters to be stored as part of clustering metadata. */ diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/CommitBasedClusteringPlanStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/CommitBasedClusteringPlanStrategy.java index 1c1a7d7ccb3ab..d825ac471e683 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/CommitBasedClusteringPlanStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/CommitBasedClusteringPlanStrategy.java @@ -86,7 +86,7 @@ public Option generateClusteringPlan() { LOG.error("earliest commit to cluster is not specified"); return Option.empty(); } - LOG.info("Earliest commit to cluster (exclusive): " + earliestCommit); + LOG.info("Earliest commit to cluster (exclusive): {}", earliestCommit); HoodieTimeline commitTimeline = metaClient.getCommitsTimeline().findInstantsAfter(earliestCommit).filterCompletedInstants(); // For each completed commit, invoke getFileSlicesEligibleForCommitBasedClustering @@ -247,7 +247,7 @@ private CommitFiles getFileSlicesEligibleForCommitBasedClustering(HoodieInstant try { commitMetadata = TimelineUtils.getCommitMetadata(instant, metaClient.getActiveTimeline()); } catch (IOException e) { - LOG.error("Failed to read commit metadata for instant: " + instant, e); + LOG.error("Failed to read commit metadata for instant: {}", instant, e); throw new HoodieException("Failed to read commit metadata for instant: " + instant, e); } } else { @@ -285,7 +285,7 @@ private CommitFiles getFileSlicesEligibleForCommitBasedClustering(HoodieInstant try { pathInfo = storage.getPathInfo(path); } catch (Exception e) { - LOG.error("Could not get PathInfo for file path: " + path, e); + LOG.error("Could not get PathInfo for file path: {}", path, e); throw new HoodieException("Could not get PathInfo for file path: " + path, e); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/PartitionAwareClusteringPlanStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/PartitionAwareClusteringPlanStrategy.java index 077f1bb77e5fd..fc24e7be26a6a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/PartitionAwareClusteringPlanStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/PartitionAwareClusteringPlanStrategy.java @@ -24,7 +24,6 @@ import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.engine.HoodieLocalEngineContext; import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.TableServiceType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; @@ -41,6 +40,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -82,15 +82,15 @@ protected Pair, Boolean> buildClusteringGroupsForP // check if max size is reached and create new group, if needed. if (totalSizeSoFar + currentSize > writeConfig.getClusteringMaxBytesInGroup() && !currentGroup.isEmpty()) { int numOutputGroups = getNumberOfOutputFileGroups(totalSizeSoFar, writeConfig.getClusteringTargetFileMaxBytes()); - log.info("Adding one clustering group " + totalSizeSoFar + " max bytes: " - + writeConfig.getClusteringMaxBytesInGroup() + " num input slices: " + currentGroup.size() + " output groups: " + numOutputGroups); + log.info("Adding one clustering group {} max bytes: {} num input slices: {} output groups: {}", + totalSizeSoFar, writeConfig.getClusteringMaxBytesInGroup(), currentGroup.size(), numOutputGroups); fileSliceGroups.add(Pair.of(currentGroup, numOutputGroups)); currentGroup = new ArrayList<>(); totalSizeSoFar = 0; // if fileSliceGroups's size reach the max group, stop loop if (fileSliceGroups.size() >= writeConfig.getClusteringMaxNumGroups()) { - log.info("Having generated the maximum number of groups : " + writeConfig.getClusteringMaxNumGroups()); + log.info("Having generated the maximum number of groups : {}", writeConfig.getClusteringMaxNumGroups()); partialScheduled = true; break; } @@ -104,8 +104,8 @@ protected Pair, Boolean> buildClusteringGroupsForP if (!currentGroup.isEmpty()) { int numOutputGroups = getNumberOfOutputFileGroups(totalSizeSoFar, writeConfig.getClusteringTargetFileMaxBytes()); - log.info("Adding final clustering group " + totalSizeSoFar + " max bytes: " - + writeConfig.getClusteringMaxBytesInGroup() + " num input slices: " + currentGroup.size() + " output groups: " + numOutputGroups); + log.info("Adding final clustering group {} max bytes: {} num input slices: {} output groups: {}", + totalSizeSoFar, writeConfig.getClusteringMaxBytesInGroup(), currentGroup.size(), numOutputGroups); fileSliceGroups.add(Pair.of(currentGroup, numOutputGroups)); } @@ -169,13 +169,15 @@ public Option generateClusteringPlan(ClusteringPlanActionE if (StringUtils.isNullOrEmpty(partitionSelected)) { // get matched partitions if set - partitionPaths = getRegexPatternMatchedPartitions(config, partitions.get()); + // partitionsInCurrentWindow = incremental partitions + missing partitions in last plan + List partitionsInCurrentWindow = partitions.get(); + partitionPaths = getRegexPatternMatchedPartitions(config, partitionsInCurrentWindow); + missingPartitions = getMissingPartitionsFromCurrentWindow(partitionPaths, partitionsInCurrentWindow); // filter the partition paths if needed to reduce list status } else { partitionPaths = Arrays.asList(partitionSelected.split(",")); - // Users may temporarily set specific partitions for clustering. - // Ensure the coherence of the missing partitions. - missingPartitions = (List)executor.fetchMissingPartitions(TableServiceType.CLUSTER).getRight(); + missingPartitions = getMissingPartitionsFromCurrentWindow(partitionPaths, + config.isIncrementalTableServiceEnabled() ? partitions.get() : Collections.emptyList()); } Pair, List> partitionsPair = filterPartitionPaths(getWriteConfig(), partitionPaths); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java index 1c61db4b572e5..281aa97acfa57 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/cluster/strategy/UpdateStrategy.java @@ -24,6 +24,7 @@ import org.apache.hudi.table.HoodieTable; import java.io.Serializable; +import java.util.Collections; import java.util.Set; /** @@ -34,11 +35,25 @@ public abstract class UpdateStrategy implements Serializable { protected final transient HoodieEngineContext engineContext; protected HoodieTable table; protected Set fileGroupsInPendingClustering; + protected Set fileGroupsToBeReplaced; - public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, Set fileGroupsInPendingClustering) { + public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { this.engineContext = engineContext; this.table = table; this.fileGroupsInPendingClustering = fileGroupsInPendingClustering; + this.fileGroupsToBeReplaced = fileGroupsToBeReplaced; + } + + /** + * Backward-compatible 3-arg constructor for custom {@code hoodie.clustering.updates.strategy} + * classes that pre-date the addition of {@code fileGroupsToBeReplaced}. Delegates to the 4-arg + * form with an empty replaced set so the existing reflection lookup keeps working. + */ + public UpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, + Set fileGroupsInPendingClustering) { + this(engineContext, table, fileGroupsInPendingClustering, Collections.emptySet()); } /** diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java index ff9fcb8666926..c7e7f0287ac17 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java @@ -188,7 +188,7 @@ protected void completeCommit(HoodieWriteMetadata result) { initializeLastCompletedTnxAndPendingInstants(); } autoCommit(result); - log.info("Completed commit for " + instantTime); + log.info("Completed commit for {}", instantTime); } protected void autoCommit(HoodieWriteMetadata result) { @@ -216,7 +216,7 @@ protected void autoCommit(HoodieWriteMetadata result) { protected void commit(HoodieWriteMetadata result, List writeStats) { String actionType = getCommitActionType(); - log.info("Committing " + instantTime + ", action Type " + actionType + ", operation Type " + operationType); + log.info("Committing {}, action Type {}, operation Type {}", instantTime, actionType, operationType); result.setCommitted(true); result.setWriteStats(writeStats); // Finalize write @@ -234,7 +234,7 @@ protected void commit(HoodieWriteMetadata result, List write activeTimeline.saveAsComplete(false, table.getMetaClient().createNewInstant(State.INFLIGHT, actionType, instantTime), Option.of(metadata), completedInstant -> table.getMetaClient().getTableFormat().commit(metadata, completedInstant, table.getContext(), table.getMetaClient(), table.getViewManager())); - log.info("Committed " + instantTime); + log.info("Committed {}", instantTime); result.setCommitMetadata(Option.of(metadata)); // update cols to Index as applicable HoodieColumnStatsIndexUtils.updateColsToIndex(table, config, metadata, actionType, @@ -308,7 +308,7 @@ protected HoodieWriteMetadata> executeClustering(HoodieC writeMetadata.setWriteStatuses(statuses); - log.debug("Create place holder commit metadata for clustering with instant time " + instantTime); + log.debug("Create place holder commit metadata for clustering with instant time {}", instantTime); HoodieCommitMetadata commitMetadata = CommitUtils.buildMetadata(Collections.emptyList(), Collections.emptyMap(), extraMetadata, operationType, schema.get().toString(), getCommitActionType()); writeMetadata.setCommitMetadata(Option.of(commitMetadata)); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java index f887e4d5ac746..79bfa975e953c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java @@ -36,6 +36,7 @@ import org.apache.hudi.internal.schema.convert.InternalSchemaConverter; import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils; import org.apache.hudi.internal.schema.utils.InternalSchemaUtils; +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils; import org.apache.hudi.internal.schema.utils.SerDeHelper; import org.apache.hudi.io.HoodieWriteMergeHandle; import org.apache.hudi.io.storage.HoodieFileReader; @@ -170,8 +171,10 @@ private Option> composeSchemaEvolutionTrans // TODO support bootstrap if (querySchemaOpt.isPresent() && !baseFile.getBootstrapBaseFile().isPresent()) { // check implicitly add columns, and position reorder(spark sql may change cols order) - InternalSchema querySchema = AvroSchemaEvolutionUtils.reconcileSchema(writerSchema.toAvroSchema(), - querySchemaOpt.get(), writeConfig.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS)); + InternalSchema querySchema = AvroSchemaEvolutionUtils.reconcileSchema(writerSchema, + querySchemaOpt.get(), writeConfig.getBooleanOrDefault(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + writeConfig.getStringOrDefault(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES))); long commitInstantTime = Long.parseLong(baseFile.getCommitTime()); InternalSchema fileSchema = InternalSchemaCache.getInternalSchemaByVersionId(commitInstantTime, metaClient); if (fileSchema.isEmptySchema() && writeConfig.getBoolean(HoodieCommonConfig.RECONCILE_SCHEMA)) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java index 298beb15b7e0b..6f03b69dcb04a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java @@ -124,11 +124,11 @@ public Option execute() { @Nullable private HoodieCompactionPlan scheduleCompaction() { - log.info("Checking if compaction needs to be run on " + config.getBasePath()); + log.info("Checking if compaction needs to be run on {}", config.getBasePath()); // judge if we need to compact according to num delta commits and time elapsed boolean compactable = needCompact(config.getInlineCompactTriggerStrategy()); if (compactable) { - log.info("Generating compaction plan for merge on read table " + config.getBasePath()); + log.info("Generating compaction plan for merge on read table {}", config.getBasePath()); try { context.setJobStatus(this.getClass().getSimpleName(), "Compaction: generating compaction plan"); return planGenerator.generateCompactionPlan(instantTime); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/plan/generators/HoodieCompactionPlanGenerator.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/plan/generators/HoodieCompactionPlanGenerator.java index 381cf86f28a07..657b82da7a9bd 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/plan/generators/HoodieCompactionPlanGenerator.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/plan/generators/HoodieCompactionPlanGenerator.java @@ -47,7 +47,7 @@ public HoodieCompactionPlanGenerator(HoodieTable table, HoodieEngineContext engi BaseTableServicePlanActionExecutor executor) { super(table, engineContext, writeConfig, executor); this.compactionStrategy = writeConfig.getCompactionStrategy(); - log.info("Compaction Strategy used is: " + compactionStrategy.toString()); + log.info("Compaction Strategy used is: {}", compactionStrategy); } @Override diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java index c14df6312ecc8..2ab39cd305342 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java @@ -107,7 +107,7 @@ public void run() { try { // we need take a lock here as inflight writer could also try to update the timeline transactionManager.beginStateChange(Option.of(instant), Option.empty()); - log.info("Updating metadata table for instant: " + instant); + log.info("Updating metadata table for instant: {}", instant); switch (instant.getAction()) { case HoodieTimeline.COMMIT_ACTION: case HoodieTimeline.DELTA_COMMIT_ACTION: diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java index 6bb15573c8298..c0a29695bf455 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java @@ -98,7 +98,7 @@ public RunIndexActionExecutor(HoodieEngineContext context, HoodieWriteConfig con super(context, config, table, instantTime); this.txnManager = new TransactionManager(config, table.getStorage()); if (config.getMetadataConfig().isMetricsEnabled()) { - this.metrics = Option.of(new HoodieMetadataMetrics(config.getMetricsConfig(), table.getStorage())); + this.metrics = Option.of(new HoodieMetadataMetrics(config.getMetricsConfig(), table.getStorage(), config.getMetadataConfig().isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/restore/BaseRestoreActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/restore/BaseRestoreActionExecutor.java index 9ad9159ecef64..1be128c5bde78 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/restore/BaseRestoreActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/restore/BaseRestoreActionExecutor.java @@ -86,7 +86,7 @@ public HoodieRestoreMetadata execute() { instantsToRollback.forEach(instant -> { instantToMetadata.put(instant.requestedTime(), Collections.singletonList(rollbackInstant(instant))); - log.info("Deleted instant " + instant); + log.info("Deleted instant {}", instant); }); return finishRestore(instantToMetadata, @@ -143,7 +143,7 @@ private HoodieRestoreMetadata finishRestore(Map doRollbackAndGetStats(HoodieRollbackPlan hoodieRollbackPlan) { @@ -235,7 +235,7 @@ public List doRollbackAndGetStats(HoodieRollbackPlan hoodieR try { List stats = executeRollback(hoodieRollbackPlan); - log.info("Rolled back inflight instant " + instantTimeToRollback); + log.info("Rolled back inflight instant {}", instantTimeToRollback); if (!isPendingCompaction) { rollBackIndex(); } @@ -289,7 +289,7 @@ protected void finishRollback(HoodieInstant inflightInstant, HoodieRollbackMetad // when skipLocking is true, the caller should have already held the lock. table.getActiveTimeline().transitionRollbackInflightToComplete(false, inflightInstant, rollbackMetadata, completedInstant -> table.getMetaClient().getTableFormat().completedRollback(completedInstant, table.getContext(), table.getMetaClient(), table.getViewManager())); - log.info("Rollback of Commits " + rollbackMetadata.getCommitsRollback() + " is complete"); + log.info("Rollback of Commits {} is complete", rollbackMetadata.getCommitsRollback()); } } finally { if (enableLocking) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/CopyOnWriteRollbackActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/CopyOnWriteRollbackActionExecutor.java index d3f3662896965..5b97a4ef9e153 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/CopyOnWriteRollbackActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/CopyOnWriteRollbackActionExecutor.java @@ -66,7 +66,7 @@ protected List executeRollback(HoodieRollbackPlan hoodieRoll HoodieActiveTimeline activeTimeline = table.getActiveTimeline(); if (instantToRollback.isCompleted()) { - log.info("Unpublishing instant " + instantToRollback); + log.info("Unpublishing instant {}", instantToRollback); table.getMetaClient().getTableFormat().rollback(instantToRollback, table.getContext(), table.getMetaClient(), table.getViewManager()); // Revert the completed instant to inflight in native format. resolvedInstant = activeTimeline.revertToInflight(instantToRollback); @@ -88,13 +88,13 @@ protected List executeRollback(HoodieRollbackPlan hoodieRoll // deleting the timeline file if (!resolvedInstant.isRequested()) { // delete all the data files for this commit - log.info("Clean out all base files generated for commit: " + resolvedInstant); + log.info("Clean out all base files generated for commit: {}", resolvedInstant); stats = executeRollback(resolvedInstant, hoodieRollbackPlan); } dropBootstrapIndexIfNeeded(instantToRollback); - log.info("Time(in ms) taken to finish rollback " + rollbackTimer.endTimer()); + log.info("Time(in ms) taken to finish rollback {}", rollbackTimer.endTimer()); return stats; } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java index ff28dc99c5862..433c769381dbb 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java @@ -215,7 +215,7 @@ public List getRollbackRequests(HoodieInstant instantToRo return hoodieRollbackRequests.stream(); }, numPartitions); } catch (Exception e) { - log.error("Generating rollback requests failed for " + instantToRollback.requestedTime(), e); + log.error("Generating rollback requests failed for {}", instantToRollback.requestedTime(), e); throw new HoodieRollbackException("Generating rollback requests failed for " + instantToRollback.requestedTime(), e); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/MergeOnReadRollbackActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/MergeOnReadRollbackActionExecutor.java index 1373d9679e02b..bfe783779746a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/MergeOnReadRollbackActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/MergeOnReadRollbackActionExecutor.java @@ -60,11 +60,11 @@ public MergeOnReadRollbackActionExecutor(HoodieEngineContext context, protected List executeRollback(HoodieRollbackPlan hoodieRollbackPlan) { HoodieTimer rollbackTimer = HoodieTimer.start(); - log.info("Rolling back instant " + instantToRollback); + log.info("Rolling back instant {}", instantToRollback); // Atomically un-publish all non-inflight commits if (instantToRollback.isCompleted()) { - log.info("Un-publishing instant " + instantToRollback + ", deleteInstants=" + deleteInstants); + log.info("Un-publishing instant {}, deleteInstants={}", instantToRollback, deleteInstants); resolvedInstant = table.getActiveTimeline().revertToInflight(instantToRollback); // reload meta-client to reflect latest timeline status table.getMetaClient().reloadActiveTimeline(); @@ -81,13 +81,13 @@ protected List executeRollback(HoodieRollbackPlan hoodieRoll // For Requested State (like failure during index lookup), there is nothing to do rollback other than // deleting the timeline file if (!resolvedInstant.isRequested()) { - log.info("Unpublished " + resolvedInstant); + log.info("Unpublished {}", resolvedInstant); allRollbackStats = executeRollback(instantToRollback, hoodieRollbackPlan); } dropBootstrapIndexIfNeeded(resolvedInstant); - log.info("Time(in ms) taken to finish rollback " + rollbackTimer.endTimer()); + log.info("Time(in ms) taken to finish rollback {}", rollbackTimer.endTimer()); return allRollbackStats; } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java index 79a28421751ef..b24d85dd9e8d0 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java @@ -30,6 +30,7 @@ import org.apache.hudi.common.model.IOType; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.LogFileCreationCallback; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; @@ -140,7 +141,12 @@ Map> preComputeLogVersions( } } - Pair sentinel = Pair.of(HoodieLogFile.LOGFILE_BASE_VERSION, HoodieLogFormat.UNKNOWN_WRITE_TOKEN); + // Insert a sentinel for file groups with no existing log files so the caller can skip a + // redundant per-request FS listing. The sentinel uses a null write-token to distinguish + // it from a real entry whose token happens to equal UNKNOWN_WRITE_TOKEN; otherwise tests + // (and any callers that previously wrote logs with the legacy "1-0-1" token) would be + // indistinguishable from "no log file present". + Pair sentinel = Pair.of(HoodieLogFile.LOGFILE_BASE_VERSION, null); for (String expectedKey : expectedKeys) { logVersionMap.putIfAbsent(expectedKey, sentinel); } @@ -311,9 +317,9 @@ List> maybeDeleteAndCollectStats(HoodieEngineCo // Let's emit markers for rollback as well. markers are emitted under rollback instant time. WriteMarkers writeMarkers = WriteMarkersFactory.get(config.getMarkersType(), table, instantTime); - HoodieLogFormat.WriterBuilder writerBuilder = HoodieLogFormat.newWriterBuilder() - .onParentPath(FSUtils.constructAbsolutePath(metaClient.getBasePath(), partitionPath)) - .withFileId(fileId) + HoodieLogFormatWriter.HoodieLogFormatWriterBuilder writerBuilder = HoodieLogFormatWriter.builder() + .withParentPath(FSUtils.constructAbsolutePath(metaClient.getBasePath(), partitionPath)) + .withLogFileId(fileId) .withLogWriteToken(CommonClientUtils.generateWriteToken(taskContextSupplier)) .withInstantTime(tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) ? instantToRollback.requestedTime() : rollbackRequest.getLatestBaseInstant() @@ -323,16 +329,30 @@ List> maybeDeleteAndCollectStats(HoodieEngineCo .withTableVersion(tableVersion) .withFileExtension(HoodieLogFile.DELTA_EXTENSION); - // Supply the pre-computed latest log version and its write token so that - // WriterBuilder.build() skips the per-request FSUtils.getLatestLogVersion() listing. - // This produces the same result: build() would discover (N, T_existing), construct - // path (N, T_existing), find it exists, and roll over to N+1. Pre-computation - // feeds the same (N, T_existing), triggering the identical rollover in getOutputStream(). + // Apply pre-computed log version if available. Always keep the per-task write token + // generated above (via CommonClientUtils.generateWriteToken) so that retried/repeated + // rollbacks do not collide on UNKNOWN_WRITE_TOKEN or inherit a prior log's write token. + // + // When doDelete=true, we actually create a new rollback log file: explicitly bump the + // version (latest + 1) so the new file is written with the per-task write token instead + // of rolling over and inheriting the existing log file's token. When doDelete=false we + // are only collecting stats (no append), so we let WriterBuilder.build() discover the + // existing version itself — bumping here would point to a non-existent path and break + // the downstream storage.getPathInfo lookup. + // + // The sentinel value (right == null) means "partition listed but no log file for this + // file group" — write at the base version. String logVersionKey = logVersionLookupKey(partitionPath, fileId, rollbackRequest.getLatestBaseInstant()); Pair preComputedVersion = logVersionMap.get(logVersionKey); if (preComputedVersion != null) { - writerBuilder.withLogVersion(preComputedVersion.getLeft()) - .withLogWriteToken(preComputedVersion.getRight()); + if (preComputedVersion.getRight() == null) { + writerBuilder.withLogVersion(HoodieLogFile.LOGFILE_BASE_VERSION); + } else if (doDelete) { + writerBuilder.withLogVersion(preComputedVersion.getLeft() + 1); + } else { + writerBuilder.withLogVersion(preComputedVersion.getLeft()) + .withLogWriteToken(preComputedVersion.getRight()); + } } writer = writerBuilder.build(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/KeepByTimeStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/KeepByTimeStrategy.java index 7f07c4b735d2b..8968d69f70bde 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/KeepByTimeStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/KeepByTimeStrategy.java @@ -31,6 +31,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator.fixInstantTimeCompatibility; @@ -46,7 +47,7 @@ public class KeepByTimeStrategy extends PartitionTTLStrategy { public KeepByTimeStrategy(HoodieTable hoodieTable, String instantTime) { super(hoodieTable, instantTime); - this.ttlInMilis = writeConfig.getPartitionTTLStrategyDaysRetain() * 1000 * 3600 * 24; + this.ttlInMilis = TimeUnit.DAYS.toMillis(writeConfig.getPartitionTTLStrategyDaysRetain()); } @Override @@ -80,6 +81,10 @@ protected List getExpiredPartitionsForTimeStrategy(List partitio * @param partitionPaths Partitions to collect stats. */ private Map> getLastCommitTimeForPartitions(List partitionPaths) { + if (partitionPaths.isEmpty()) { + log.info("Candidate partition paths list is empty, skip TTL stats collection"); + return Collections.emptyMap(); + } int statsParallelism = Math.min(partitionPaths.size(), 200); return hoodieTable.getContext().map(partitionPaths, partitionPath -> { Option partitionLastModifiedTime = hoodieTable.getHoodieView() diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/PartitionTTLStrategyType.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/PartitionTTLStrategyType.java index 5dcf1e8bda38e..6a7ed12b3c059 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/PartitionTTLStrategyType.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/PartitionTTLStrategyType.java @@ -19,7 +19,6 @@ package org.apache.hudi.table.action.ttl.strategy; import org.apache.hudi.common.config.HoodieConfig; -import org.apache.hudi.keygen.constant.KeyGeneratorType; import lombok.Getter; @@ -67,7 +66,7 @@ public static String getPartitionTTLStrategyClassName(HoodieConfig config) { if (config.contains(PARTITION_TTL_STRATEGY_CLASS_NAME)) { return config.getString(PARTITION_TTL_STRATEGY_CLASS_NAME); } else if (config.contains(PARTITION_TTL_STRATEGY_TYPE)) { - return KeyGeneratorType.valueOf(config.getString(PARTITION_TTL_STRATEGY_TYPE)).getClassName(); + return PartitionTTLStrategyType.valueOf(config.getString(PARTITION_TTL_STRATEGY_TYPE)).getClassName(); } return null; } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/MarkerBasedRollbackUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/MarkerBasedRollbackUtils.java index e7e539fa88990..562f6bc9221bb 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/MarkerBasedRollbackUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/MarkerBasedRollbackUtils.java @@ -70,9 +70,18 @@ public static List getAllMarkerPaths(HoodieTable table, HoodieEngineCont WriteMarkers writeMarkers = WriteMarkersFactory.get(DIRECT, table, instant); try { return new ArrayList<>(writeMarkers.allMarkerFilePaths()); - } catch (IOException | IllegalArgumentException e) { - log.warn("{} not present and {} marker failed with error: {}. Falling back to {} marker", - MARKER_TYPE_FILENAME, DIRECT, e.getMessage(), TIMELINE_SERVER_BASED); + } catch (IOException e) { + // Do NOT fall back to TIMELINE_SERVER_BASED on transient IO failures (e.g., HDFS throttling). + // The timeline server looks in a different location and would return 0 markers, causing the + // rollback to skip deleting data files and leaving orphan files on the table. + log.warn("{} not present and {} marker listing failed with IO error. " + + "Propagating exception, rollback will retry rather than fall back to {}.", + MARKER_TYPE_FILENAME, DIRECT, TIMELINE_SERVER_BASED, e); + throw e; + } catch (IllegalArgumentException e) { + // IllegalArgumentException indicates a marker path format mismatch, fall back to timeline server. + log.warn("{} not present and {} marker failed. Falling back to {} marker", + MARKER_TYPE_FILENAME, DIRECT, TIMELINE_SERVER_BASED, e); return getTimelineServerBasedMarkers(context, parallelism, markerDir, storage); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/TimelineServerBasedWriteMarkers.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/TimelineServerBasedWriteMarkers.java index b31fdc94178af..1b98803274737 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/TimelineServerBasedWriteMarkers.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/TimelineServerBasedWriteMarkers.java @@ -133,8 +133,7 @@ protected Option create(String partitionPath, String fileName, IOTy Map paramsMap = getConfigMap(partitionPath, markerFileName, false); boolean success = executeCreateMarkerRequest(paramsMap, partitionPath, markerFileName); - log.info("[timeline-server-based] Created marker file " + partitionPath + "/" + markerFileName - + " in " + timer.endTimer() + " ms"); + log.info("[timeline-server-based] Created marker file {}/{} in {} ms", partitionPath, markerFileName, timer.endTimer()); if (success) { return Option.of(new StoragePath(FSUtils.constructAbsolutePath(markerDirPath, partitionPath), markerFileName)); } else { @@ -151,8 +150,7 @@ public Option createWithEarlyConflictDetection(String partitionPath boolean success = executeCreateMarkerRequest(paramsMap, partitionPath, markerFileName); - log.info("[timeline-server-based] Created marker file with early conflict detection " + partitionPath + "/" + markerFileName - + " in " + timer.endTimer() + " ms"); + log.info("[timeline-server-based] Created marker file with early conflict detection {}/{} in {} ms", partitionPath, markerFileName, timer.endTimer()); if (success) { return Option.of(new StoragePath(FSUtils.constructAbsolutePath(markerDirPath, partitionPath), markerFileName)); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkersFactory.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkersFactory.java index 2765ffdd6286a..8191e6d04cba4 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkersFactory.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkersFactory.java @@ -53,7 +53,7 @@ public static WriteMarkers get(MarkerType markerType, HoodieTable table, String } String basePath = table.getMetaClient().getBasePath().toString(); if (StorageSchemes.HDFS.getScheme().equals( - HadoopFSUtils.getFs(basePath, table.getContext().getStorageConf(), true).getScheme())) { + HadoopFSUtils.getScheme(HadoopFSUtils.getFs(basePath, table.getContext().getStorageConf(), true)))) { log.warn("Timeline-server-based markers are not supported for HDFS: " + "base path {}. Falling back to direct markers.", basePath); return getDirectWriteMarkers(table, instantTime); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/FiveToSixUpgradeHandler.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/FiveToSixUpgradeHandler.java index 62a668a1acc6a..c7089175d664a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/FiveToSixUpgradeHandler.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/FiveToSixUpgradeHandler.java @@ -64,12 +64,12 @@ private void deleteCompactionRequestedFileFromAuxiliaryFolder(HoodieTable table) compactionTimeline.getInstantsAsStream().forEach( deleteInstant -> { - log.info("Deleting instant " + deleteInstant + " in auxiliary meta path " + metaClient.getMetaAuxiliaryPath()); + log.info("Deleting instant {} in auxiliary meta path {}", deleteInstant, metaClient.getMetaAuxiliaryPath()); StoragePath metaFile = new StoragePath(metaClient.getMetaAuxiliaryPath(), factory.getFileName(deleteInstant)); try { if (metaClient.getStorage().exists(metaFile)) { metaClient.getStorage().deleteFile(metaFile); - log.info("Deleted instant file in auxiliary meta path : " + metaFile); + log.info("Deleted instant file in auxiliary meta path : {}", metaFile); } } catch (IOException e) { throw new HoodieUpgradeDowngradeException(HoodieTableVersion.FIVE.versionCode(), HoodieTableVersion.SIX.versionCode(), true, e); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/SevenToEightUpgradeHandler.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/SevenToEightUpgradeHandler.java index 7f7338e3da30b..533e443539929 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/SevenToEightUpgradeHandler.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/SevenToEightUpgradeHandler.java @@ -137,7 +137,7 @@ && isComplexKeyGeneratorWithSingleRecordKeyField(tableConfig)) { }, instants.size()); } - upgradeToLSMTimeline(table, context, config); + upgradeToLSMTimeline(table, config); return new UpgradeDowngrade.TableConfigChangeSet(tablePropsToAdd, Collections.emptySet()); } @@ -249,7 +249,7 @@ static void upgradeKeyGeneratorType(HoodieTableConfig tableConfig, Map ValidationUtils.checkState(TimelineLayoutVersion.LAYOUT_VERSION_1.equals(timelineLayoutVersion), "Upgrade to LSM timeline is only supported for layout version 1. Given version: " + timelineLayoutVersion)); @@ -257,7 +257,12 @@ static void upgradeToLSMTimeline(HoodieTable table, HoodieEngineContext engineCo LegacyArchivedMetaEntryReader reader = new LegacyArchivedMetaEntryReader(table.getMetaClient()); StoragePath archivePath = new StoragePath(table.getMetaClient().getMetaPath(), "timeline/history"); LSMTimelineWriter lsmTimelineWriter = LSMTimelineWriter.getInstance(config, table, Option.of(archivePath)); - int batchSize = config.getCommitArchivalBatchSize(); + // Use a dedicated, larger batch size for the one-time migration to minimize the number of parquet + // files created on remote storage. Each write() call involves multiple remote storage operations + // (exists check, parquet write, manifest update); the regular archival batch size is much smaller + // than what migration needs, so with hundreds of actions it creates excessive I/O that + // significantly increases the migration time. + int batchSize = config.getMigrationCommitArchivalBatchSize(); List activeActionsBatch = new ArrayList<>(batchSize); try (ClosableIterator iterator = reader.getActiveActionsIterator()) { while (iterator.hasNext()) { @@ -265,7 +270,6 @@ static void upgradeToLSMTimeline(HoodieTable table, HoodieEngineContext engineCo // If the batch is full, write it to the LSM timeline if (activeActionsBatch.size() == batchSize) { lsmTimelineWriter.write(new ArrayList<>(activeActionsBatch), Option.empty(), Option.empty()); - lsmTimelineWriter.compactAndClean(engineContext); activeActionsBatch.clear(); } } @@ -273,7 +277,6 @@ static void upgradeToLSMTimeline(HoodieTable table, HoodieEngineContext engineCo // Write any remaining actions in the final batch if (!activeActionsBatch.isEmpty()) { lsmTimelineWriter.write(new ArrayList<>(activeActionsBatch), Option.empty(), Option.empty()); - lsmTimelineWriter.compactAndClean(engineContext); } } } catch (Exception e) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/UpgradeDowngrade.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/UpgradeDowngrade.java index 0d17a75a7da4b..febb815ad34fd 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/UpgradeDowngrade.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/UpgradeDowngrade.java @@ -208,7 +208,7 @@ public void run(HoodieTableVersion toVersion, String instantTime) { // Perform the actual upgrade/downgrade; this has to be idempotent, for now. - log.info("Attempting to move table from version " + fromVersion + " to " + toVersion); + log.info("Attempting to move table from version {} to {}", fromVersion, toVersion); Map tablePropsToAdd = new Hashtable<>(); Set tablePropsToRemove = new HashSet<>(); if (isUpgrade) { diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/common/TestHoodieWriteCommitCallbackMessage.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/common/TestHoodieWriteCommitCallbackMessage.java new file mode 100644 index 0000000000000..1b27ef50000e8 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/common/TestHoodieWriteCommitCallbackMessage.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.hudi.callback.common; + +import org.apache.hudi.callback.util.HoodieWriteCommitCallbackUtil; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage.PrevFilePaths; +import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.HoodieBaseFile; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView; +import org.apache.hudi.common.util.Option; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the {@link HoodieWriteCommitCallbackMessage} contract: default (never-null) + * collections, lazy one-shot resolution of {@code prevFilePaths} off the supplied file-system + * view, and the Java-serialization round trip (the view cannot be shipped, so the resolved + * paths must be materialized at the boundary and carried across in its place). + */ +public class TestHoodieWriteCommitCallbackMessage { + + private static final String COMMIT_TIME = "002"; + private static final String PARTITION = "2024/01/01"; + private static final String PREV_COMMIT = "001"; + private static final String PREV_PATH = "/tbl/" + PARTITION + "/f0_0-1-1_" + PREV_COMMIT + ".parquet"; + private static final String BOOTSTRAP_PATH = "/bootstrap/source/f0.parquet"; + + private static List updateStat() { + HoodieWriteStat writeStat = new HoodieWriteStat(); + writeStat.setFileId("f0"); + writeStat.setPartitionPath(PARTITION); + writeStat.setPrevCommit(PREV_COMMIT); + return Collections.singletonList(writeStat); + } + + private static BaseFileOnlyView viewResolving(String prevBaseFilePath) { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "f0")) + .thenReturn(Option.of(new HoodieBaseFile(prevBaseFilePath))); + return view; + } + + private static BaseFileOnlyView viewResolvingWithBootstrap(String prevBaseFilePath, String bootstrapPath) { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "f0")) + .thenReturn(Option.of(new HoodieBaseFile(prevBaseFilePath, new BaseFile(bootstrapPath)))); + return view; + } + + @Test + public void callbackMessageDefaultsCollectionsToEmpty() { + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", Collections.emptyList()); + + assertFalse(message.getCommitActionType().isPresent()); + assertFalse(message.getExtraMetadata().isPresent()); + assertTrue(message.getPrevFilePaths().isEmpty(), "prevFilePaths must default to an empty map, never null"); + assertTrue(message.getExtraContext().isEmpty(), "extraContext must default to an empty map, never null"); + } + + @Test + public void callbackMessageResolvesPrevFilePathsFromViewAndRetainsContext() { + Map extraContext = Collections.singletonMap("file_id", "f0"); + + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", updateStat(), + Option.of("commit"), Option.empty(), () -> viewResolving(PREV_PATH), extraContext); + + assertEquals("commit", message.getCommitActionType().get()); + PrevFilePaths resolved = message.getPrevFilePaths().get("f0"); + assertEquals(PREV_PATH, resolved.getBaseFilePath()); + assertEquals(extraContext, message.getExtraContext()); + } + + @Test + public void nullFileSystemViewSupplierYieldsEmptyPrevFilePaths() { + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", updateStat(), + Option.of("commit"), Option.empty(), null, Collections.emptyMap()); + + assertTrue(message.getPrevFilePaths().isEmpty(), + "a message built without a file-system view must yield an empty map, never null"); + } + + @Test + public void prevFilePathsAreResolvedLazilyAndMemoized() { + AtomicInteger viewLookups = new AtomicInteger(); + BaseFileOnlyView view = viewResolving(PREV_PATH); + Supplier viewSupplier = () -> { + viewLookups.incrementAndGet(); + return view; + }; + + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", updateStat(), + Option.empty(), Option.empty(), viewSupplier, Collections.emptyMap()); + + // Constructing the message must not touch the file-system view. + assertEquals(0, viewLookups.get(), + "prevFilePaths must not be resolved until a consumer reads them"); + verify(view, never()).getBaseFileOn(anyString(), anyString(), anyString()); + + assertEquals(PREV_PATH, message.getPrevFilePaths().get("f0").getBaseFilePath()); + // A second read must reuse the memoized result rather than resolve again. + assertEquals(PREV_PATH, message.getPrevFilePaths().get("f0").getBaseFilePath()); + assertEquals(1, viewLookups.get(), "prevFilePaths must be resolved at most once and memoized"); + verify(view).getBaseFileOn(PARTITION, PREV_COMMIT, "f0"); + } + + @Test + public void javaSerializationResolvesAndPreservesPrevFilePaths() throws IOException, ClassNotFoundException { + AtomicInteger viewLookups = new AtomicInteger(); + // The view supplier cannot be shipped, so writeObject has to materialize the paths first. + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", updateStat(), + Option.of("commit"), Option.empty(), + () -> { + viewLookups.incrementAndGet(); + return viewResolvingWithBootstrap(PREV_PATH, BOOTSTRAP_PATH); + }, + Collections.emptyMap()); + + assertEquals(0, viewLookups.get(), "building the message must not touch the file-system view"); + + HoodieWriteCommitCallbackMessage roundTripped = serializeAndDeserialize(message); + + assertEquals(1, viewLookups.get(), "serialization must force resolution exactly once"); + assertEquals(COMMIT_TIME, roundTripped.getCommitTime()); + assertEquals("commit", roundTripped.getCommitActionType().get()); + assertEquals(1, roundTripped.getHoodieWriteStat().size()); + assertEquals(PREV_PATH, roundTripped.getPrevFilePaths().get("f0").getBaseFilePath()); + assertEquals(BOOTSTRAP_PATH, roundTripped.getPrevFilePaths().get("f0").getBootstrapBaseFilePath(), + "the bootstrap source path must survive the round trip too"); + } + + @Test + public void jsonPayloadExposesPrevFilePathsAndNotTheResolver() { + HoodieWriteCommitCallbackMessage message = new HoodieWriteCommitCallbackMessage( + COMMIT_TIME, "table", "/base", updateStat(), + Option.of("commit"), Option.empty(), () -> viewResolving(PREV_PATH), Collections.emptyMap()); + + // This is the payload the built-in HTTP/Kafka/Pulsar callbacks put on the wire. + String json = HoodieWriteCommitCallbackUtil.convertToJsonString(message); + + assertTrue(json.contains("\"prevFilePaths\""), "prevFilePaths must be part of the callback payload"); + assertTrue(json.contains(PREV_PATH), "Jackson must see the resolved paths, not the lazy holder"); + assertFalse(json.contains("prevFilePathsResolver"), + "the lazy resolver is an implementation detail and must never reach the payload"); + } + + private static HoodieWriteCommitCallbackMessage serializeAndDeserialize( + HoodieWriteCommitCallbackMessage message) throws IOException, ClassNotFoundException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(message); + } + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + return (HoodieWriteCommitCallbackMessage) in.readObject(); + } + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/util/TestHoodieWriteCommitCallbackUtil.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/util/TestHoodieWriteCommitCallbackUtil.java new file mode 100644 index 0000000000000..cb05b378461d6 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/callback/util/TestHoodieWriteCommitCallbackUtil.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.hudi.callback.util; + +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage.PrevFilePaths; +import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.HoodieBaseFile; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView; +import org.apache.hudi.common.util.Option; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for + * {@link HoodieWriteCommitCallbackUtil#resolvePrevFilePaths(List, BaseFileOnlyView)}, which + * pre-resolves the previous base file (and bootstrap source, if any) for each updated file + * group from a cached {@link BaseFileOnlyView}, so callback implementations receive the + * read/write file pairing without rebuilding a file-system view. + */ +public class TestHoodieWriteCommitCallbackUtil { + + private static final String PARTITION = "2024/01/01"; + private static final String PREV_COMMIT = "001"; + + private static HoodieWriteStat stat(String fileId, String partitionPath, String prevCommit) { + HoodieWriteStat writeStat = new HoodieWriteStat(); + writeStat.setFileId(fileId); + writeStat.setPartitionPath(partitionPath); + writeStat.setPrevCommit(prevCommit); + return writeStat; + } + + @Test + public void resolvePrevFilePathsReturnsEmptyForNullInputs() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + assertTrue(HoodieWriteCommitCallbackUtil.resolvePrevFilePaths(null, view).isEmpty(), + "null stats must yield an empty map"); + assertTrue(HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + Collections.singletonList(stat("f0", PARTITION, PREV_COMMIT)), null).isEmpty(), + "null file-system view must yield an empty map"); + } + + @Test + public void resolvePrevFilePathsSkipsStatsWithoutAPrevCommit() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + List inserts = Arrays.asList( + stat("f-null", PARTITION, null), + stat("f-empty", PARTITION, ""), + stat("f-nullcommit", PARTITION, HoodieWriteStat.NULL_COMMIT)); + + Map resolved = + HoodieWriteCommitCallbackUtil.resolvePrevFilePaths(inserts, view); + + assertTrue(resolved.isEmpty(), "inserts (no prevCommit) must not resolve a prev base file"); + // The view must not even be consulted for inserts. + verify(view, never()).getBaseFileOn(anyString(), anyString(), anyString()); + } + + @Test + public void resolvePrevFilePathsResolvesUpdatePrevBaseFile() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + HoodieBaseFile prevBase = new HoodieBaseFile("/tbl/" + PARTITION + "/f0_0-1-1_" + PREV_COMMIT + ".parquet"); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "f0")).thenReturn(Option.of(prevBase)); + + Map resolved = HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + Collections.singletonList(stat("f0", PARTITION, PREV_COMMIT)), view); + + assertEquals(1, resolved.size()); + assertEquals(prevBase.getPath(), resolved.get("f0").getBaseFilePath()); + assertNull(resolved.get("f0").getBootstrapBaseFilePath(), "non-bootstrap update has no bootstrap path"); + } + + @Test + public void resolvePrevFilePathsCapturesBootstrapBaseFile() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + BaseFile bootstrap = new BaseFile("/bootstrap/source/f0.parquet"); + HoodieBaseFile prevBase = new HoodieBaseFile("/tbl/" + PARTITION + "/f0_0-1-1_" + PREV_COMMIT + ".parquet", bootstrap); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "f0")).thenReturn(Option.of(prevBase)); + + Map resolved = HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + Collections.singletonList(stat("f0", PARTITION, PREV_COMMIT)), view); + + assertEquals(prevBase.getPath(), resolved.get("f0").getBaseFilePath()); + assertEquals(bootstrap.getPath(), resolved.get("f0").getBootstrapBaseFilePath(), + "bootstrap source path must be carried through for bootstrapped file groups"); + } + + @Test + public void resolvePrevFilePathsSkipsWhenBaseFileAbsent() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "f0")).thenReturn(Option.empty()); + + Map resolved = HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + Collections.singletonList(stat("f0", PARTITION, PREV_COMMIT)), view); + + assertTrue(resolved.isEmpty(), "a missing prev base file must be skipped, not mapped to null"); + } + + @Test + public void resolvePrevFilePathsIsBestEffortOnViewFailure() { + BaseFileOnlyView view = mock(BaseFileOnlyView.class); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "boom")) + .thenThrow(new RuntimeException("stale or remote view error")); + HoodieBaseFile prevBase = new HoodieBaseFile("/tbl/" + PARTITION + "/ok_0-1-1_" + PREV_COMMIT + ".parquet"); + when(view.getBaseFileOn(PARTITION, PREV_COMMIT, "ok")).thenReturn(Option.of(prevBase)); + + Map resolved = HoodieWriteCommitCallbackUtil.resolvePrevFilePaths( + Arrays.asList(stat("boom", PARTITION, PREV_COMMIT), stat("ok", PARTITION, PREV_COMMIT)), view); + + // The failing file group is dropped; resolution continues for the rest (must not fail the commit). + assertFalse(resolved.containsKey("boom")); + assertEquals(prevBase.getPath(), resolved.get("ok").getBaseFilePath()); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java index 377a54eedcd8a..9190dde5ab1e1 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java @@ -46,6 +46,7 @@ import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.simple.HoodieSimpleIndex; import org.apache.hudi.keygen.ComplexAvroKeyGenerator; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; import org.apache.hudi.table.BulkInsertPartitioner; import org.apache.hudi.table.HoodieTable; @@ -242,7 +243,7 @@ void testWithComplexKeyGeneratorValidation(String keyGeneratorClass, if (tableVersion <= 8 && enableComplexKeyGeneratorValidation && (ComplexAvroKeyGenerator.class.getCanonicalName().equals(keyGeneratorClass) || "org.apache.hudi.keygen.ComplexKeyGenerator".equals(keyGeneratorClass)) - && recordKeyFields.split(",").length == 1) { + && KeyGenUtils.getRecordKeyFields(recordKeyFields).size() == 1) { assertComplexKeyGeneratorValidationThrows(() -> writeClient.initTable(WriteOperationType.INSERT, Option.empty()), "ingestion"); } else { writeClient.initTable(WriteOperationType.INSERT, Option.empty()); @@ -399,4 +400,4 @@ protected void updateColumnsToIndexWithColStats(HoodieTableMetaClient metaClient } } -} \ No newline at end of file +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestClientStatsPojos.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestClientStatsPojos.java new file mode 100644 index 0000000000000..f971c0369568b --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestClientStatsPojos.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.hudi.client; + +import org.apache.hudi.common.model.HoodieWriteStat; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the plain client-side stat holders {@link SecondaryIndexStats} and {@link TableWriteStats}. + */ +public class TestClientStatsPojos { + + @Test + void secondaryIndexStatsExposesConstructorValues() { + SecondaryIndexStats deleted = new SecondaryIndexStats("rk1", "sk1", true); + assertEquals("rk1", deleted.getRecordKey()); + assertEquals("sk1", deleted.getSecondaryKeyValue()); + assertTrue(deleted.isDeleted()); + + SecondaryIndexStats live = new SecondaryIndexStats("rk2", "sk2", false); + assertEquals("rk2", live.getRecordKey()); + assertEquals("sk2", live.getSecondaryKeyValue()); + assertFalse(live.isDeleted()); + } + + @Test + void secondaryIndexStatsSetterMutatesRecordKey() { + SecondaryIndexStats stats = new SecondaryIndexStats("rk1", "sk1", false); + stats.setRecordKey("rk2"); + stats.setSecondaryKeyValue("sk2"); + assertEquals("rk2", stats.getRecordKey()); + assertEquals("sk2", stats.getSecondaryKeyValue()); + } + + @Test + void secondaryIndexStatsEqualityUsesAllFields() { + SecondaryIndexStats a = new SecondaryIndexStats("rk", "sk", false); + SecondaryIndexStats same = new SecondaryIndexStats("rk", "sk", false); + SecondaryIndexStats deleteDiffers = new SecondaryIndexStats("rk", "sk", true); + assertEquals(a, same); + assertEquals(a.hashCode(), same.hashCode()); + assertNotEquals(a, deleteDiffers); + } + + @Test + void tableWriteStatsSingleArgDefaultsMetadataToEmpty() { + List dataStats = Collections.singletonList(new HoodieWriteStat()); + TableWriteStats stats = new TableWriteStats(dataStats); + assertEquals(dataStats, stats.getDataTableWriteStats()); + assertTrue(stats.getMetadataTableWriteStats().isEmpty()); + assertFalse(stats.isEmptyDataTableWriteStats()); + } + + @Test + void tableWriteStatsReportsEmptyDataStats() { + TableWriteStats empty = new TableWriteStats(Collections.emptyList()); + assertTrue(empty.isEmptyDataTableWriteStats()); + } + + @Test + void tableWriteStatsRetainsBothLists() { + List dataStats = Arrays.asList(new HoodieWriteStat(), new HoodieWriteStat()); + List metadataStats = Collections.singletonList(new HoodieWriteStat()); + TableWriteStats stats = new TableWriteStats(dataStats, metadataStats); + assertEquals(2, stats.getDataTableWriteStats().size()); + assertEquals(1, stats.getMetadataTableWriteStats().size()); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestCommitMetadataProperties.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestCommitMetadataProperties.java new file mode 100644 index 0000000000000..acd83424ff3ea --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestCommitMetadataProperties.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.hudi.client; + +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import static org.apache.hudi.client.CommitMetadataProperties.CONFIG_KEY_PREFIX; +import static org.apache.hudi.client.CommitMetadataProperties.EMBED_ENGINE_PROPERTIES_IN_COMMIT_METADATA; +import static org.apache.hudi.client.CommitMetadataProperties.ENGINE_KEY; +import static org.apache.hudi.client.CommitMetadataProperties.HUDI_VERSION_KEY; +import static org.apache.hudi.client.CommitMetadataProperties.WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TestCommitMetadataProperties { + + /** Always-emitted keys (hudi.version, engine) are present even when input is empty. */ + @Test + void enrich_emptyInput_emitsVersionAndEngine() { + HoodieWriteConfig config = newConfig(new Properties()); + HoodieEngineContext context = newContext(Collections.emptyMap()); + + Map result = CommitMetadataProperties.enrich(Option.empty(), config, context).get(); + + assertNotNull(result.get(HUDI_VERSION_KEY)); + assertEquals(EngineType.SPARK.name(), result.get(ENGINE_KEY)); + } + + /** Passing an immutable map must not throw — yihua's defensive-copy fix. */ + @Test + void enrich_immutableInputMap_doesNotThrow() { + HoodieWriteConfig config = newConfig(new Properties()); + HoodieEngineContext context = newContext(Collections.emptyMap()); + Map input = Collections.unmodifiableMap( + Collections.singletonMap("caller.key", "caller.value")); + + Map result = CommitMetadataProperties.enrich(Option.of(input), config, context).get(); + + assertEquals("caller.value", result.get("caller.key")); + assertTrue(input.equals(Collections.singletonMap("caller.key", "caller.value")), + "Input map must not be mutated"); + } + + /** Default (opt-in flag = false) suppresses engine-supplied keys. */ + @Test + void enrich_engineEmbedFlagOff_omitsEngineSuppliedKeys() { + HoodieWriteConfig config = newConfig(new Properties()); + Map engineProps = new HashMap<>(); + engineProps.put("spark.application.id", "app-123"); + HoodieEngineContext context = newContext(engineProps); + + Map result = CommitMetadataProperties.enrich(Option.empty(), config, context).get(); + + assertFalse(result.containsKey("spark.application.id"), + "Engine-supplied keys must be omitted when embed flag is off"); + assertNotNull(result.get(HUDI_VERSION_KEY)); + assertEquals(EngineType.SPARK.name(), result.get(ENGINE_KEY)); + } + + /** Opt-in flag = true emits engine-supplied keys. */ + @Test + void enrich_engineEmbedFlagOn_emitsEngineSuppliedKeys() { + Properties props = new Properties(); + props.put(EMBED_ENGINE_PROPERTIES_IN_COMMIT_METADATA.key(), "true"); + HoodieWriteConfig config = newConfig(props); + Map engineProps = new HashMap<>(); + engineProps.put("spark.application.id", "app-123"); + engineProps.put("spark.user", "sivabalan"); + HoodieEngineContext context = newContext(engineProps); + + Map result = CommitMetadataProperties.enrich(Option.empty(), config, context).get(); + + assertEquals("app-123", result.get("spark.application.id")); + assertEquals("sivabalan", result.get("spark.user")); + } + + /** Config-key allowlist serializes present values; skips truly absent keys (no default). */ + @Test + void enrich_writeConfigKeyAllowlist_emitsPresentNonEmptyValues() { + Properties props = new Properties(); + props.put(WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA.key(), + "hoodie.metadata.enable,hoodie.does.not.exist"); + props.put("hoodie.metadata.enable", "true"); + HoodieWriteConfig config = newConfig(props); + HoodieEngineContext context = newContext(Collections.emptyMap()); + + Map result = CommitMetadataProperties.enrich(Option.empty(), config, context).get(); + + assertEquals("true", result.get(CONFIG_KEY_PREFIX + "hoodie.metadata.enable")); + assertFalse(result.containsKey(CONFIG_KEY_PREFIX + "hoodie.does.not.exist"), + "Keys absent from the config must not be serialized"); + } + + /** Empty allowlist disables config-key serialization entirely. */ + @Test + void enrich_emptyConfigKeyList_emitsNoConfigKeys() { + Properties props = new Properties(); + props.put(WRITE_CONFIG_KEYS_TO_SERIALIZE_TO_COMMIT_METADATA.key(), ""); + props.put("hoodie.metadata.enable", "true"); + HoodieWriteConfig config = newConfig(props); + HoodieEngineContext context = newContext(Collections.emptyMap()); + + Map result = CommitMetadataProperties.enrich(Option.empty(), config, context).get(); + + long configKeyCount = result.keySet().stream().filter(k -> k.startsWith(CONFIG_KEY_PREFIX)).count(); + assertEquals(0L, configKeyCount, "No config.* keys when allowlist is empty"); + // hudi.version and engine still always emitted + assertNotNull(result.get(HUDI_VERSION_KEY)); + assertEquals(EngineType.SPARK.name(), result.get(ENGINE_KEY)); + } + + /** Caller-provided extra metadata is preserved alongside enrichment. */ + @Test + void enrich_preservesCallerProvidedKeys() { + HoodieWriteConfig config = newConfig(new Properties()); + HoodieEngineContext context = newContext(Collections.emptyMap()); + Map input = new HashMap<>(); + input.put("caller.key1", "caller.value1"); + input.put("caller.key2", "caller.value2"); + + Map result = CommitMetadataProperties.enrich(Option.of(input), config, context).get(); + + assertEquals("caller.value1", result.get("caller.key1")); + assertEquals("caller.value2", result.get("caller.key2")); + assertNotNull(result.get(HUDI_VERSION_KEY)); + } + + private static HoodieWriteConfig newConfig(Properties overrides) { + Properties props = new Properties(); + props.put(HoodieWriteConfig.BASE_PATH.key(), "/tmp/test-commit-metadata-properties"); + props.putAll(overrides); + return HoodieWriteConfig.newBuilder().withProperties(props).build(); + } + + private static HoodieEngineContext newContext(Map engineProperties) { + HoodieEngineContext context = mock(HoodieEngineContext.class); + when(context.getEngineProperties()).thenReturn(engineProperties); + return context; + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestHoodieTableServiceManagerClient.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestHoodieTableServiceManagerClient.java new file mode 100644 index 0000000000000..6fb8b29ed782a --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestHoodieTableServiceManagerClient.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.hudi.client; + +import org.apache.hudi.common.config.HoodieTableServiceManagerConfig; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.exception.HoodieRemoteException; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link HoodieTableServiceManagerClient} with the HTTP transport mocked by a + * local in-process {@link HttpServer}. Assertions cover the request path, the query + * parameters sent to the service, and retry/error handling. + */ +public class TestHoodieTableServiceManagerClient { + + private static final String DB_NAME = "test_db"; + private static final String TABLE_NAME = "test_table"; + + @TempDir + Path tempDir; + + private HttpServer server; + + @AfterEach + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + private HoodieTableMetaClient initMetaClient() throws IOException { + Properties props = new Properties(); + props.setProperty(HoodieTableConfig.NAME.key(), TABLE_NAME); + props.setProperty(HoodieTableConfig.DATABASE_NAME.key(), DB_NAME); + return HoodieTestUtils.init( + HoodieTestUtils.getDefaultStorageConf(), + tempDir.resolve("table").toString(), + HoodieTableType.COPY_ON_WRITE, + props); + } + + private HoodieTableServiceManagerConfig configFor(String uri) { + Properties props = new Properties(); + // Keep retry cheap so the error-path test stays fast. + props.setProperty(HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_RETRIES.key(), "2"); + props.setProperty(HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_RETRY_DELAY_SEC.key(), "1"); + props.setProperty(HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_TIMEOUT_SEC.key(), "5"); + return HoodieTableServiceManagerConfig.newBuilder().fromProperties(props).setURIs(uri).build(); + } + + /** + * Parses a raw query string of the form {@code a=1&b=2} into a decoded map. + */ + private static Map parseQuery(String rawQuery) throws IOException { + Map params = new HashMap<>(); + if (rawQuery == null || rawQuery.isEmpty()) { + return params; + } + for (String pair : rawQuery.split("&")) { + int idx = pair.indexOf('='); + String key = idx >= 0 ? pair.substring(0, idx) : pair; + String value = idx >= 0 ? pair.substring(idx + 1) : ""; + params.put( + URLDecoder.decode(key, StandardCharsets.UTF_8.name()), + URLDecoder.decode(value, StandardCharsets.UTF_8.name())); + } + return params; + } + + /** + * Starts a local HTTP server that records the request path and query params of the last + * request and replies 200. Returns the base URI (scheme + host + port). + */ + private String startRecordingServer(Map captured) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + captured.put("path", exchange.getRequestURI().getPath()); + captured.put("query", parseQuery(exchange.getRequestURI().getRawQuery())); + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @Test + public void testExecuteCompactionSendsExpectedRequest() throws IOException { + Map captured = new HashMap<>(); + String uri = startRecordingServer(captured); + HoodieTableServiceManagerClient client = + new HoodieTableServiceManagerClient(initMetaClient(), configFor(uri)); + + Option result = client.executeCompaction(); + + // With no pending compaction the instant range is empty, but the request is still sent. + assertTrue(result.isPresent()); + assertEquals("", result.get()); + assertEquals(HoodieTableServiceManagerClient.EXECUTE_COMPACTION, captured.get("path")); + + @SuppressWarnings("unchecked") + Map query = (Map) captured.get("query"); + assertEquals(HoodieTableServiceManagerClient.Action.REQUEST.name(), + query.get(HoodieTableServiceManagerClient.ACTION)); + assertEquals(DB_NAME, query.get(HoodieTableServiceManagerClient.DATABASE_NAME_PARAM)); + assertEquals(TABLE_NAME, query.get(HoodieTableServiceManagerClient.TABLE_NAME_PARAM)); + assertTrue(query.containsKey(HoodieTableServiceManagerClient.BASEPATH_PARAM)); + assertTrue(query.containsKey(HoodieTableServiceManagerClient.INSTANT_PARAM)); + assertTrue(query.containsKey(HoodieTableServiceManagerClient.EXECUTION_ENGINE)); + assertTrue(query.containsKey(HoodieTableServiceManagerClient.PARALLELISM)); + } + + @Test + public void testExecuteCleanTargetsCleanEndpoint() throws IOException { + Map captured = new HashMap<>(); + String uri = startRecordingServer(captured); + HoodieTableServiceManagerClient client = + new HoodieTableServiceManagerClient(initMetaClient(), configFor(uri)); + + client.executeClean(); + assertEquals(HoodieTableServiceManagerClient.EXECUTE_CLEAN, captured.get("path")); + } + + @Test + public void testExecuteClusteringTargetsClusterEndpoint() throws IOException { + Map captured = new HashMap<>(); + String uri = startRecordingServer(captured); + HoodieTableServiceManagerClient client = + new HoodieTableServiceManagerClient(initMetaClient(), configFor(uri)); + + client.executeClustering(); + assertEquals(HoodieTableServiceManagerClient.EXECUTE_CLUSTERING, captured.get("path")); + } + + @Test + public void testServerErrorRetriesAndSurfacesRemoteException() throws IOException { + AtomicInteger requestCount = new AtomicInteger(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requestCount.incrementAndGet(); + // Always fail so the retry limit is exhausted and an exception propagates. + byte[] body = "boom".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(500, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + String uri = "http://127.0.0.1:" + server.getAddress().getPort(); + + HoodieTableServiceManagerClient client = + new HoodieTableServiceManagerClient(initMetaClient(), configFor(uri)); + + assertThrows(HoodieRemoteException.class, client::executeCompaction); + // Two retries configured means the request is attempted more than once. + assertTrue(requestCount.get() >= 2, + "expected at least 2 attempts, got " + requestCount.get()); + } + + @Test + public void testUnreachableServerSurfacesRemoteException() throws IOException { + // Port 1 is a privileged, unbound port: the connection will be refused. + HoodieTableServiceManagerClient client = + new HoodieTableServiceManagerClient(initMetaClient(), configFor("http://127.0.0.1:1")); + assertThrows(HoodieRemoteException.class, client::executeCompaction); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/bootstrap/translator/TestBootstrapPartitionPathTranslators.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/bootstrap/translator/TestBootstrapPartitionPathTranslators.java new file mode 100644 index 0000000000000..9598b581aa686 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/bootstrap/translator/TestBootstrapPartitionPathTranslators.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.hudi.client.bootstrap.translator; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests the two built-in bootstrap partition-path translators. + */ +public class TestBootstrapPartitionPathTranslators { + + @Test + void identityTranslatorReturnsInputUnchanged() { + IdentityBootstrapPartitionPathTranslator translator = new IdentityBootstrapPartitionPathTranslator(); + assertEquals("2024/01/01", translator.getBootstrapTranslatedPath("2024/01/01")); + // Even already-encoded input is passed through verbatim. + assertEquals("2024%2F01", translator.getBootstrapTranslatedPath("2024%2F01")); + } + + @Test + void decodedTranslatorUriDecodesEscapedPath() { + DecodedBootstrapPartitionPathTranslator translator = new DecodedBootstrapPartitionPathTranslator(); + // %2F decodes to a slash. + assertEquals("2024/01", translator.getBootstrapTranslatedPath("2024%2F01")); + // Paths without escape sequences are unaffected. + assertEquals("region=us", translator.getBootstrapTranslatedPath("region=us")); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java index c7ea5fa87bbd6..5feb79ee83bc0 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/heartbeat/TestHoodieHeartbeatClient.java @@ -21,12 +21,18 @@ import org.apache.hudi.common.testutils.HoodieCommonTestHarness; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import org.apache.hudi.storage.hadoop.HoodieHadoopStorage; +import org.apache.hadoop.fs.FileSystem; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.io.OutputStream; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static java.util.concurrent.TimeUnit.SECONDS; import static org.awaitility.Awaitility.await; @@ -113,4 +119,100 @@ public void testStopHeartbeatTimers() throws IOException { assertFalse(hoodieHeartbeatClient.isHeartbeatExpired(instantTime1)); assertTrue(hoodieHeartbeatClient.getHeartbeat(instantTime1).isHeartbeatStopped()); } + + /** + * Regression test for the heartbeat-expiry incident: a single slow/hung storage write must not + * block (freeze) the heartbeat scheduler thread. The first heartbeat write blocks (simulating a hung + * cloud-storage call); we assert the scheduler keeps producing heartbeats on fresh threads once that + * write times out, proving the scheduler thread was not blocked by the synchronous storage call (#1). + * A high tolerable-misses is used so that recovery after the blocked write does not itself trip the + * expiry path (which intentionally stops refresh on a genuine lapse). + */ + @Test + public void testSlowHeartbeatWriteDoesNotBlockScheduler() { + CountDownLatch releaseFirstWrite = new CountDownLatch(1); + SlowCreateStorage slowStorage = + new SlowCreateStorage((FileSystem) metaClient.getStorage().getFileSystem(), releaseFirstWrite); + // interval 1s, write timeout = 1s; high tolerable-misses so the ~1s recovery gap stays well within + // the allowable window and the scheduler keeps beating rather than treating it as a lapse. + HoodieHeartbeatClient hoodieHeartbeatClient = + new HoodieHeartbeatClient(slowStorage, metaClient.getBasePath().toString(), + heartBeatInterval, 10); + try { + hoodieHeartbeatClient.start(instantTime1); + // Despite the first write hanging, the scheduler must keep generating heartbeats on fresh threads. + await().atMost(15, SECONDS) + .until(() -> hoodieHeartbeatClient.getHeartbeat(instantTime1).getNumHeartbeats() >= 2); + } finally { + releaseFirstWrite.countDown(); + hoodieHeartbeatClient.close(); + } + } + + @Test + public void testScheduledHeartbeatRetriesAfterWriteFailure() { + FailOnceAfterInitialCreateStorage storage = + new FailOnceAfterInitialCreateStorage((FileSystem) metaClient.getStorage().getFileSystem()); + HoodieHeartbeatClient hoodieHeartbeatClient = + new HoodieHeartbeatClient(storage, metaClient.getBasePath().toString(), heartBeatInterval, 10); + try { + hoodieHeartbeatClient.start(instantTime1); + await().atMost(10, SECONDS).until(storage::hasInjectedFailure); + await().atMost(10, SECONDS) + .until(() -> hoodieHeartbeatClient.getHeartbeat(instantTime1).getNumHeartbeats() >= 2); + } finally { + hoodieHeartbeatClient.close(); + } + } + + /** + * A storage wrapper whose first {@code create()} call blocks until released, simulating a hung + * storage write. All subsequent calls delegate normally. + */ + private static class SlowCreateStorage extends HoodieHadoopStorage { + + private final AtomicBoolean firstCall = new AtomicBoolean(true); + private final CountDownLatch releaseFirstWrite; + + SlowCreateStorage(FileSystem fs, CountDownLatch releaseFirstWrite) { + super(fs); + this.releaseFirstWrite = releaseFirstWrite; + } + + @Override + public OutputStream create(StoragePath path, boolean overwrite) throws IOException { + if (firstCall.getAndSet(false)) { + try { + releaseFirstWrite.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while simulating a hung heartbeat write", e); + } + } + return super.create(path, overwrite); + } + } + + private static class FailOnceAfterInitialCreateStorage extends HoodieHadoopStorage { + + private final AtomicInteger createCalls = new AtomicInteger(0); + private final AtomicBoolean injectedFailure = new AtomicBoolean(false); + + FailOnceAfterInitialCreateStorage(FileSystem fs) { + super(fs); + } + + @Override + public OutputStream create(StoragePath path, boolean overwrite) throws IOException { + int currentCall = createCalls.incrementAndGet(); + if (currentCall == 2 && injectedFailure.compareAndSet(false, true)) { + throw new IOException("Injected scheduled heartbeat write failure"); + } + return super.create(path, overwrite); + } + + private boolean hasInjectedFailure() { + return injectedFailure.get(); + } + } } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java index 5046b1bf7028d..048ef727dc80c 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java @@ -127,6 +127,59 @@ void testReadCompletionTimeWithCornerCase() throws Exception { } } + /** + * The {@code completionTime} field of {@code HoodieLSMTimelineInstant} is declared + * {@code ["null","string"]} with a null default, and instants archived before the field existed carry + * no value for it. Reading such an instant must fall back to the instant time rather than throwing. + * + *

See HUDI-9655: upgrading a table written by 0.x produced + * {@code NullPointerException: Cannot invoke "Object.toString()" because the return value of + * "org.apache.avro.generic.GenericRecord.get(String)" is null} while loading the archived timeline. + */ + @Test + void testReadCompletionTimeWithoutCompletionTime() throws Exception { + String tableName = "testTable"; + String tablePath = tempFile.getAbsolutePath() + StoragePath.SEPARATOR + tableName; + HoodieTableMetaClient metaClient = HoodieTestUtils.init( + HoodieTestUtils.getDefaultStorageConf(), tablePath, HoodieTableType.COPY_ON_WRITE, tableName); + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder().withPath(tablePath) + .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build()) + .withMarkersType("DIRECT") + .build(); + HoodieTestTable testTable = HoodieTestTable.of(metaClient); + + // instant 1 only ever exists on the LSM timeline, as an instant archived by an older writer would. + String archivedInstantTime = String.format("%08d", 1); + HoodieCommitMetadata archivedMetadata = testTable.createCommitMetadata( + archivedInstantTime, WriteOperationType.INSERT, Arrays.asList("par1", "par2"), 10, false); + // instants 2..4 stay active, so that the query for instant 1 falls through to the archive. + for (int i = 2; i < 5; i++) { + String instantTime = String.format("%08d", i); + HoodieCommitMetadata metadata = testTable.createCommitMetadata( + instantTime, WriteOperationType.INSERT, Arrays.asList("par1", "par2"), 10, false); + testTable.addCommit(instantTime, Option.of(String.format("%08d", i + 1000)), Option.of(metadata)); + } + + // archive instant 1 with no completion time at all + ActiveAction activeAction = new DummyActiveAction( + INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, "commit", archivedInstantTime, null), + convertMetadataToByteArray(archivedMetadata)); + List archiveFailures = new ArrayList<>(); + // LSMTimelineWriter#write swallows per-instant failures, so surface them rather than + // silently archiving nothing and leaving the assertion below to pass vacuously. + LSMTimelineWriter.getInstance(writeConfig, getMockHoodieTable(metaClient)) + .write(Collections.singletonList(activeAction), Option.empty(), Option.of(archiveFailures::add)); + assertTrue(archiveFailures.isEmpty(), + "Archiving an instant without a completion time should not fail: " + archiveFailures); + + metaClient.reloadActiveTimeline(); + try (CompletionTimeQueryView view = + metaClient.getTableFormat().getTimelineFactory().createCompletionTimeQueryView(metaClient)) { + assertThat("An archived instant without a completion time should fall back to its instant time", + view.getCompletionTime(archivedInstantTime).orElse(""), is(archivedInstantTime)); + } + } + @Test void testReadStartTime() throws Exception { String tableName = "testTable"; diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java index 909ca863bfecb..6178a9847469d 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java @@ -36,6 +36,7 @@ import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; import org.apache.hudi.common.table.timeline.versioning.clean.CleanPlanV2MigrationHandler; import org.apache.hudi.common.testutils.HoodieCommonTestHarness; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; @@ -52,10 +53,17 @@ import org.mockito.Mockito; import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.Map; import java.util.Properties; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.apache.hudi.common.table.HoodieTableConfig.PARTITION_FIELDS; @@ -70,6 +78,7 @@ import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_SCHEMA; import static org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf; import static org.apache.hudi.common.util.CommitUtils.buildMetadata; +import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -403,6 +412,82 @@ void testGetTableSchema(HoodieSchema inputSchema, boolean includeMetadataFields, includeMetadataFields, Option.of(instant)).get()); } + @Test + void testTableVersionEightAndAboveOrdersByCompletionTime() throws Exception { + metaClient = HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, new Properties(), "") + .initTable(getDefaultStorageConf(), basePath); + // The ordering is driven by the timeline layout version. + assertEquals(TimelineLayoutVersion.VERSION_2, metaClient.getTimelineLayoutVersion().getVersion()); + testTable = HoodieTestTable.of(metaClient); + + // Completion order inverts requested order: requested 001 completes last (at 100) with + // schema 2, requested 009 completes first (at 050) with schema 1. + testTable.addCommit("001", Option.of("100"), Option.of(buildMetadata( + Collections.emptyList(), Collections.emptyMap(), Option.empty(), WriteOperationType.UNKNOWN, + SCHEMA_WITHOUT_METADATA_STR2, COMMIT_ACTION))); + testTable.addCommit("009", Option.of("050"), Option.of(buildMetadata( + Collections.emptyList(), Collections.emptyMap(), Option.empty(), WriteOperationType.UNKNOWN, + SCHEMA_WITHOUT_METADATA_STR, COMMIT_ACTION))); + + ConcurrentSchemaEvolutionTableSchemaGetter resolver = new ConcurrentSchemaEvolutionTableSchemaGetter(metaClient); + // The latest table schema follows completion time: schema 2 of requested 001, completed 100. + assertEquals(SCHEMA_WITHOUT_METADATA2.toString(), + resolver.getTableSchemaIfPresent(false, Option.empty()).get().toString()); + // A target completed at 075 only sees the commit completed at 050 (requested 009, schema 1). + assertEquals(SCHEMA_WITHOUT_METADATA.toString(), + resolver.getTableSchemaIfPresent(false, + Option.of(metaClient.getInstantGenerator().createNewInstant( + HoodieInstant.State.COMPLETED, COMMIT_ACTION, "005", "075"))).get().toString()); + } + + @Test + void testTableVersionSixOrdersByRequestedTime() throws Exception { + Properties properties = new Properties(); + properties.setProperty(WRITE_TABLE_VERSION.key(), "6"); + metaClient = HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, properties, "") + .initTable(getDefaultStorageConf(), basePath); + // The ordering is driven by the timeline layout version. + assertEquals(TimelineLayoutVersion.VERSION_1, metaClient.getTimelineLayoutVersion().getVersion()); + testTable = HoodieTestTable.of(metaClient); + + // Same layout as the table-version-8 test: requested 001 carries schema 2, requested 009 + // carries schema 1. The completion times below are ignored by the table-version-6 + // (timeline layout v1) instant file naming. + testTable.addCommit("001", Option.of("100"), Option.of(buildMetadata( + Collections.emptyList(), Collections.emptyMap(), Option.empty(), WriteOperationType.UNKNOWN, + SCHEMA_WITHOUT_METADATA_STR2, COMMIT_ACTION))); + testTable.addCommit("009", Option.of("050"), Option.of(buildMetadata( + Collections.emptyList(), Collections.emptyMap(), Option.empty(), WriteOperationType.UNKNOWN, + SCHEMA_WITHOUT_METADATA_STR, COMMIT_ACTION))); + // Invert the file modification times so that the mtime-derived completion order disagrees + // with the requested order, mirroring the table-version-8 fixture above. + Path timelinePath = Paths.get(metaClient.getTimelinePath().makeQualified(new URI("file:///")).toUri()); + Files.setLastModifiedTime(timelinePath.resolve("001.commit"), FileTime.fromMillis(2_000_000_000_000L)); + Files.setLastModifiedTime(timelinePath.resolve("009.commit"), FileTime.fromMillis(1_000_000_000_000L)); + metaClient.reloadActiveTimeline(); + + // The mtime inversion must stick, otherwise the assertions below also hold under completion-time + // ordering and the test would pass against unfixed code. + Map completionTimeByRequestedTime = metaClient.getActiveTimeline().getInstantsAsStream() + .collect(Collectors.toMap(HoodieInstant::requestedTime, HoodieInstant::getCompletionTime)); + assertTrue(completionTimeByRequestedTime.get("001").compareTo(completionTimeByRequestedTime.get("009")) > 0); + + ConcurrentSchemaEvolutionTableSchemaGetter resolver = new ConcurrentSchemaEvolutionTableSchemaGetter(metaClient); + // The latest table schema follows requested time (schema 1 of requested 009), not the + // mtime-derived completion order which would pick schema 2 of requested 001. + assertEquals(SCHEMA_WITHOUT_METADATA.toString(), + resolver.getTableSchemaIfPresent(false, Option.empty()).get().toString()); + // An inflight target bounds the lookup by its requested time. + assertEquals(SCHEMA_WITHOUT_METADATA2.toString(), + resolver.getTableSchemaIfPresent(false, + Option.of(metaClient.getInstantGenerator().createNewInstant( + HoodieInstant.State.INFLIGHT, COMMIT_ACTION, "005"))).get().toString()); + assertEquals(SCHEMA_WITHOUT_METADATA.toString(), + resolver.getTableSchemaIfPresent(false, + Option.of(metaClient.getInstantGenerator().createNewInstant( + HoodieInstant.State.INFLIGHT, COMMIT_ACTION, "999"))).get().toString()); + } + private static Stream partitionColumnSchemaTestParams() { return Stream.of( Arguments.of(false, SCHEMA_WITHOUT_METADATA), // Schema with metadata fields, don't include metadata @@ -528,6 +613,18 @@ void testGetTableSchemaInternalWithSpecificInstant(HoodieTableType tableType) th assertTrue(schema2Option.isPresent()); assertEquals(schema2.toString(), schema2Option.get().toString()); + // A target instant without a completion time (e.g., an inflight instant at pre-commit time) + // does not bound the lookup; the latest table schema is returned. + String inflightTimestamp = padWithLeadingZeros(Integer.toString(startCommitTime), REQUEST_TIME_LENGTH); + Option schemaAtInstantWithoutCompletionTime = resolver.getTableSchemaIfPresent( + false, + Option.of(metaClient.getInstantGenerator().createNewInstant( + HoodieInstant.State.INFLIGHT, + tableType.equals(HoodieTableType.COPY_ON_WRITE) ? COMMIT_ACTION : DELTA_COMMIT_ACTION, + inflightTimestamp))); + assertTrue(schemaAtInstantWithoutCompletionTime.isPresent()); + assertEquals(schema2.toString(), schemaAtInstantWithoutCompletionTime.get().toString()); + // Now follow with more disqualified instants and try to get table schema with their request time, we should back track to instant 2. int endCommitTime = createExhaustiveDisqualifiedInstants(startCommitTime, tableType); metaClient.reloadActiveTimeline(); diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleConcurrentFileWritesConflictResolutionStrategy.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleConcurrentFileWritesConflictResolutionStrategy.java index 4e11940686ca6..5232dc444b448 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleConcurrentFileWritesConflictResolutionStrategy.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleConcurrentFileWritesConflictResolutionStrategy.java @@ -515,4 +515,175 @@ public void testConcurrentWritesWithPendingInstants() throws Exception { } } } + + @Test + public void testErrorMessageForConflictWithCompaction() throws Exception { + initMetaClient(true, HoodieTableType.MERGE_ON_READ); + createCommit(WriteClientTestUtils.createNewInstantTime(), metaClient); + HoodieActiveTimeline timeline = metaClient.getActiveTimeline(); + Option lastSuccessfulInstant = timeline.getCommitsTimeline().filterCompletedInstants().lastInstant(); + + // writer 1 starts + String currentWriterInstant = WriteClientTestUtils.createNewInstantTime(); + createInflightCommit(currentWriterInstant, metaClient); + + // compaction gets scheduled and runs + String compactionInstant = WriteClientTestUtils.createNewInstantTime(); + createCompactionRequested(compactionInstant, metaClient); + + Option currentInstant = Option.of(INSTANT_GENERATOR.createNewInstant(State.INFLIGHT, HoodieTimeline.COMMIT_ACTION, currentWriterInstant)); + SimpleConcurrentFileWritesConflictResolutionStrategy strategy = new SimpleConcurrentFileWritesConflictResolutionStrategy(); + HoodieCommitMetadata currentMetadata = createCommitMetadata(currentWriterInstant); + metaClient.reloadActiveTimeline(); + + List candidateInstants = strategy.getCandidateInstants(metaClient, currentInstant.get(), lastSuccessfulInstant) + .collect(Collectors.toList()); + Assertions.assertEquals(1, candidateInstants.size()); + + ConcurrentOperation thatCompactionOperation = new ConcurrentOperation(candidateInstants.get(0), metaClient); + ConcurrentOperation thisCommitOperation = new ConcurrentOperation(currentInstant.get(), currentMetadata); + Assertions.assertTrue(strategy.hasConflict(thisCommitOperation, thatCompactionOperation)); + + HoodieWriteConflictException exception = Assertions.assertThrows(HoodieWriteConflictException.class, + () -> strategy.resolveConflict(null, thisCommitOperation, thatCompactionOperation)); + + String errorMessage = exception.getMessage(); + Assertions.assertTrue(errorMessage.contains("Table Compaction"), + "Error message should mention 'Table Compaction', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("is currently running"), + "Error message should contain 'is currently running', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("Please retry the write operation after the compaction completes"), + "Error message should contain retry guidance, but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains(compactionInstant), + "Error message should contain compaction instant time, but was: " + errorMessage); + } + + @Test + public void testErrorMessageForConflictWithClustering() throws Exception { + initMetaClient(); + createCommit(WriteClientTestUtils.createNewInstantTime(), metaClient); + HoodieActiveTimeline timeline = metaClient.getActiveTimeline(); + Option lastSuccessfulInstant = timeline.getCommitsTimeline().filterCompletedInstants().lastInstant(); + + // writer 1 starts + String currentWriterInstant = WriteClientTestUtils.createNewInstantTime(); + createInflightCommit(currentWriterInstant, metaClient); + + // clustering gets scheduled + String clusteringInstant = WriteClientTestUtils.createNewInstantTime(); + createClusterRequested(clusteringInstant, metaClient); + + Option currentInstant = Option.of(INSTANT_GENERATOR.createNewInstant(State.INFLIGHT, HoodieTimeline.COMMIT_ACTION, currentWriterInstant)); + SimpleConcurrentFileWritesConflictResolutionStrategy strategy = new SimpleConcurrentFileWritesConflictResolutionStrategy(); + HoodieCommitMetadata currentMetadata = createCommitMetadata(currentWriterInstant); + metaClient.reloadActiveTimeline(); + + List candidateInstants = strategy.getCandidateInstants(metaClient, currentInstant.get(), lastSuccessfulInstant) + .collect(Collectors.toList()); + Assertions.assertEquals(1, candidateInstants.size()); + + ConcurrentOperation thatClusteringOperation = new ConcurrentOperation(candidateInstants.get(0), metaClient); + ConcurrentOperation thisCommitOperation = new ConcurrentOperation(currentInstant.get(), currentMetadata); + Assertions.assertTrue(strategy.hasConflict(thisCommitOperation, thatClusteringOperation)); + + HoodieWriteConflictException exception = Assertions.assertThrows(HoodieWriteConflictException.class, + () -> strategy.resolveConflict(null, thisCommitOperation, thatClusteringOperation)); + + String errorMessage = exception.getMessage(); + Assertions.assertTrue(errorMessage.contains("Table Clustering"), + "Error message should mention 'Table Clustering', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("is currently running"), + "Error message should contain 'is currently running', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("Please retry the write operation after the clustering completes"), + "Error message should contain retry guidance, but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains(clusteringInstant), + "Error message should contain clustering instant time, but was: " + errorMessage); + } + + @Test + public void testErrorMessageForConflictBetweenRegularWrites() throws Exception { + initMetaClient(); + createCommit(WriteClientTestUtils.createNewInstantTime(), metaClient); + HoodieActiveTimeline timeline = metaClient.getActiveTimeline(); + Option lastSuccessfulInstant = timeline.getCommitsTimeline().filterCompletedInstants().lastInstant(); + + // writer 1 starts + String currentWriterInstant = WriteClientTestUtils.createNewInstantTime(); + createInflightCommit(currentWriterInstant, metaClient); + + // writer 2 starts and finishes + String writer2Instant = WriteClientTestUtils.createNewInstantTime(); + createCommit(writer2Instant, metaClient); + + Option currentInstant = Option.of(INSTANT_GENERATOR.createNewInstant(State.INFLIGHT, HoodieTimeline.COMMIT_ACTION, currentWriterInstant)); + SimpleConcurrentFileWritesConflictResolutionStrategy strategy = new SimpleConcurrentFileWritesConflictResolutionStrategy(); + HoodieCommitMetadata currentMetadata = createCommitMetadata(currentWriterInstant); + metaClient.reloadActiveTimeline(); + + List candidateInstants = strategy.getCandidateInstants(metaClient, currentInstant.get(), lastSuccessfulInstant) + .collect(Collectors.toList()); + Assertions.assertEquals(1, candidateInstants.size()); + + ConcurrentOperation thatCommitOperation = new ConcurrentOperation(candidateInstants.get(0), metaClient); + ConcurrentOperation thisCommitOperation = new ConcurrentOperation(currentInstant.get(), currentMetadata); + Assertions.assertTrue(strategy.hasConflict(thisCommitOperation, thatCommitOperation)); + + HoodieWriteConflictException exception = Assertions.assertThrows(HoodieWriteConflictException.class, + () -> strategy.resolveConflict(null, thisCommitOperation, thatCommitOperation)); + + String errorMessage = exception.getMessage(); + Assertions.assertTrue(errorMessage.contains("Cannot resolve conflicts for overlapping writes"), + "Error message should mention overlapping writes, but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("has overlapping file groups"), + "Error message should contain 'has overlapping file groups', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains(currentWriterInstant), + "Error message should contain current writer instant time, but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains(writer2Instant), + "Error message should contain other writer instant time, but was: " + errorMessage); + // Should NOT contain table service specific messaging + Assertions.assertFalse(errorMessage.contains("Table Compaction"), + "Error message should not mention table services for regular writes, but was: " + errorMessage); + Assertions.assertFalse(errorMessage.contains("Please retry"), + "Error message should not contain retry guidance for regular writes, but was: " + errorMessage); + } + + @Test + public void testErrorMessageForConflictWithCompletedClustering() throws Exception { + initMetaClient(); + createCommit(WriteClientTestUtils.createNewInstantTime(), metaClient); + HoodieActiveTimeline timeline = metaClient.getActiveTimeline(); + Option lastSuccessfulInstant = timeline.getCommitsTimeline().filterCompletedInstants().lastInstant(); + + // writer 1 starts + String currentWriterInstant = WriteClientTestUtils.createNewInstantTime(); + createInflightCommit(currentWriterInstant, metaClient); + + // clustering completes + String clusteringInstant = WriteClientTestUtils.createNewInstantTime(); + createCluster(clusteringInstant, WriteOperationType.CLUSTER, metaClient); + + Option currentInstant = Option.of(INSTANT_GENERATOR.createNewInstant(State.INFLIGHT, HoodieTimeline.COMMIT_ACTION, currentWriterInstant)); + SimpleConcurrentFileWritesConflictResolutionStrategy strategy = new SimpleConcurrentFileWritesConflictResolutionStrategy(); + HoodieCommitMetadata currentMetadata = createCommitMetadata(currentWriterInstant); + metaClient.reloadActiveTimeline(); + + List candidateInstants = strategy.getCandidateInstants(metaClient, currentInstant.get(), lastSuccessfulInstant) + .collect(Collectors.toList()); + Assertions.assertEquals(1, candidateInstants.size()); + + ConcurrentOperation thatClusteringOperation = new ConcurrentOperation(candidateInstants.get(0), metaClient); + ConcurrentOperation thisCommitOperation = new ConcurrentOperation(currentInstant.get(), currentMetadata); + Assertions.assertTrue(strategy.hasConflict(thisCommitOperation, thatClusteringOperation)); + + HoodieWriteConflictException exception = Assertions.assertThrows(HoodieWriteConflictException.class, + () -> strategy.resolveConflict(null, thisCommitOperation, thatClusteringOperation)); + + String errorMessage = exception.getMessage(); + Assertions.assertTrue(errorMessage.contains("Table Clustering"), + "Error message should mention 'Table Clustering', but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains(clusteringInstant), + "Error message should contain clustering instant time, but was: " + errorMessage); + Assertions.assertTrue(errorMessage.contains("COMPLETED"), + "Error message should indicate the state of the clustering operation, but was: " + errorMessage); + } } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java index 44759a5743ddd..2af94c07bdbd6 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java @@ -34,6 +34,7 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.view.FileSystemViewManager; import org.apache.hudi.common.testutils.HoodieTestTable; @@ -46,12 +47,17 @@ 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; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Properties; +import java.util.stream.Stream; import static org.apache.hudi.common.table.timeline.HoodieTimeline.CLUSTERING_ACTION; import static org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION; @@ -61,6 +67,7 @@ import static org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf; import static org.apache.hudi.common.util.CommitUtils.buildMetadata; import static org.apache.hudi.config.HoodieWriteConfig.ENABLE_SCHEMA_CONFLICT_RESOLUTION; +import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -93,9 +100,25 @@ public class TestSimpleSchemaConflictResolutionStrategy { private static final String NULL_SCHEMA = "{\"type\":\"null\"}"; private void setupInstants(String tableSchemaAtTxnStart, String tableSchemaAtTxnValidation, - String writerSchemaOfTxn, Boolean enableResolution, boolean setupLegacyClustering) throws Exception { - metaClient = HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, new Properties(), "") - .setTableCreateSchema(SCHEMA1) + String writerSchemaOfTxn, boolean enableResolution, boolean setupLegacyClustering) throws Exception { + setupInstants(SCHEMA1, tableSchemaAtTxnStart, tableSchemaAtTxnValidation, writerSchemaOfTxn, + enableResolution, setupLegacyClustering, HoodieTableVersion.current().versionCode()); + } + + private void setupInstants(String tableSchemaAtTxnStart, String tableSchemaAtTxnValidation, + String writerSchemaOfTxn, boolean enableResolution, boolean setupLegacyClustering, + int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, tableSchemaAtTxnStart, tableSchemaAtTxnValidation, writerSchemaOfTxn, + enableResolution, setupLegacyClustering, writeTableVersion); + } + + private void setupInstants(String tableCreateSchema, String tableSchemaAtTxnStart, String tableSchemaAtTxnValidation, + String writerSchemaOfTxn, boolean enableResolution, boolean setupLegacyClustering, + int writeTableVersion) throws Exception { + Properties tableProperties = new Properties(); + tableProperties.setProperty(WRITE_TABLE_VERSION.key(), String.valueOf(writeTableVersion)); + metaClient = HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, tableProperties, "") + .setTableCreateSchema(tableCreateSchema) .initTable(getDefaultStorageConf(), basePath.toString()); dummyInstantGenerator = HoodieTestTable.of(metaClient); @@ -134,71 +157,119 @@ private void setupInstants(String tableSchemaAtTxnStart, String tableSchemaAtTxn strategy = new SimpleSchemaConflictResolutionStrategy(); } - @Test - void testNoConflictFirstCommit() throws Exception { - setupInstants(null, null, SCHEMA1, true, false); + // The schema evolution timeline ordering follows the table version (requested time for table + // version 6, completion time for 8 and above), so the RFC-82 resolution cases run on both. The + // fixture's requested and completion orders agree, so the outcomes are the same on both versions. + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNoConflictFirstCommit(int writeTableVersion) throws Exception { + setupInstants(null, null, SCHEMA1, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, Option.empty(), nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA1), result); } - @Test - void testNullWriterSchema() throws Exception { - setupInstants(SCHEMA1, SCHEMA1, "", true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNullWriterSchema(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA1, "", true, false, writeTableVersion); assertFalse(strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).isPresent()); } - @Test - void testNullTypeWriterSchema() throws Exception { - setupInstants(SCHEMA1, SCHEMA1, NULL_SCHEMA, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNullTypeWriterSchema(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA1, NULL_SCHEMA, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA1), result); } @Test - void testConflictSecondCommitDifferentSchema() throws Exception { - setupInstants(null, SCHEMA1, SCHEMA2, true, false); + void testNullTypeWriterSchemaCurrTxnInstantWithoutCompletionTime() throws Exception { + setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 8); + // At pre-commit time the curr txn owner instant is inflight and has no completion time; + // on table version 8 and above the resolution falls back to the latest table schema. + Option currTxnOwnerInstant = Option.of( + metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, COMMIT_ACTION, "0040")); + HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( + table, config, lastCompletedTxnOwnerInstant, currTxnOwnerInstant).get(); + assertEquals(HoodieSchema.parse(SCHEMA2), result); + } + + @ParameterizedTest + @MethodSource("tableVersionSixNullSchemaCases") + void testNullTypeWriterSchemaTableVersionSix(String currTxnInstantTime, String expectedSchema) throws Exception { + // Table version 6 orders the schema evolution timeline by requested time, so the curr txn owner + // instant's requested time bounds the null-writer-schema lookup. A create schema (SCHEMA3) + // distinct from the commit schemas makes the before-all-commits fallback observable. + setupInstants(SCHEMA3, SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 6); + Option currTxnOwnerInstant = Option.of( + metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, COMMIT_ACTION, currTxnInstantTime)); + HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( + table, config, lastCompletedTxnOwnerInstant, currTxnOwnerInstant).get(); + assertEquals(HoodieSchema.parse(expectedSchema), result); + } + + private static Stream tableVersionSixNullSchemaCases() { + return Stream.of( + // curr txn requested between the two commits: adopt the earlier commit's schema + Arguments.of("0015", SCHEMA1), + // curr txn requested after all commits: adopt the latest commit's schema + Arguments.of("0040", SCHEMA2), + // curr txn requested before all commits: fall back to the table create schema + Arguments.of("0005", SCHEMA3)); + } + + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testConflictSecondCommitDifferentSchema(int writeTableVersion) throws Exception { + setupInstants(null, SCHEMA1, SCHEMA2, true, false, writeTableVersion); assertThrows(HoodieSchemaEvolutionConflictException.class, () -> strategy.resolveConcurrentSchemaEvolution(table, config, Option.empty(), nonTableCompactionInstant)); } - @Test - void testConflictSecondCommitSameSchema() throws Exception { - setupInstants(null, SCHEMA1, SCHEMA1, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testConflictSecondCommitSameSchema(int writeTableVersion) throws Exception { + setupInstants(null, SCHEMA1, SCHEMA1, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, Option.empty(), nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA1), result); } - @Test - void testNoConflictSameSchema() throws Exception { - setupInstants(SCHEMA1, SCHEMA1, SCHEMA1, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNoConflictSameSchema(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA1, SCHEMA1, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA1), result); } - @Test - void testNoConflictBackwardsCompatible1() throws Exception { - setupInstants(SCHEMA1, SCHEMA2, SCHEMA1, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNoConflictBackwardsCompatible1(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA2, SCHEMA1, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA2), result); } - @Test - void testNoConflictBackwardsCompatible2() throws Exception { - setupInstants(SCHEMA1, SCHEMA1, SCHEMA2, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNoConflictBackwardsCompatible2(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA1, SCHEMA2, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA2), result); } - @Test - void testNoConflictConcurrentEvolutionSameSchema() throws Exception { - setupInstants(SCHEMA1, SCHEMA2, SCHEMA2, true, false); + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testNoConflictConcurrentEvolutionSameSchema(int writeTableVersion) throws Exception { + setupInstants(SCHEMA1, SCHEMA2, SCHEMA2, true, false, writeTableVersion); HoodieSchema result = strategy.resolveConcurrentSchemaEvolution( table, config, lastCompletedTxnOwnerInstant, nonTableCompactionInstant).get(); assertEquals(HoodieSchema.parse(SCHEMA2), result); diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java index fab3dee8f8f6e..fa1a8329b65fc 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java @@ -42,6 +42,7 @@ import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; +import java.time.Duration; import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -194,4 +195,24 @@ public void testUnlockWithoutLock() { ZookeeperBasedLockProvider zookeeperBasedLockProvider = new ZookeeperBasedLockProvider(zkConfWithZkBasePathAndLockKeyLock, null); zookeeperBasedLockProvider.unlock(); } + + @Test + public void testFailFastWhenZkUnreachable() { + Properties properties = new Properties(); + // Nothing listens on 127.0.0.1:1, so the connect-wait must time out instead of hanging. + properties.setProperty(ZK_CONNECT_URL_PROP_KEY, "127.0.0.1:1"); + properties.setProperty(ZK_BASE_PATH_PROP_KEY, basePath); + properties.setProperty(ZK_LOCK_KEY_PROP_KEY, key); + properties.setProperty(LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY, "100"); + properties.setProperty(LOCK_ACQUIRE_RETRY_MAX_WAIT_TIME_IN_MILLIS_PROP_KEY, "300"); + properties.setProperty(LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY, "1"); + properties.setProperty(ZK_SESSION_TIMEOUT_MS_PROP_KEY, "1000"); + properties.setProperty(ZK_CONNECTION_TIMEOUT_MS_PROP_KEY, "1000"); + LockConfiguration unreachable = new LockConfiguration(properties); + // Construction must fail fast (seconds, bounded by the connection timeout) with a + // HoodieLockException instead of being amplified into a multi-minute retry hang. + Assertions.assertTimeoutPreemptively(Duration.ofSeconds(15), () -> + Assertions.assertThrows(HoodieLockException.class, + () -> new ZookeeperBasedLockProvider(unreachable, null))); + } } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestFileSystemBasedLockProvider.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestFileSystemBasedLockProvider.java new file mode 100644 index 0000000000000..f7ea8a3cac70c --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestFileSystemBasedLockProvider.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.hudi.client.transaction.lock; + +import org.apache.hudi.common.config.LockConfiguration; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.config.HoodieLockConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.HoodieStorageUtils; +import org.apache.hudi.storage.StorageConfiguration; +import org.apache.hudi.storage.StoragePath; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import static org.apache.hudi.common.config.LockConfiguration.FILESYSTEM_LOCK_EXPIRE_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.FILESYSTEM_LOCK_PATH_PROP_KEY; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link FileSystemBasedLockProvider} against a local temp directory. + */ +public class TestFileSystemBasedLockProvider { + + @TempDir + Path tempDir; + + private LockConfiguration lockConfiguration(String lockPath, int expireMinutes) { + Properties props = new Properties(); + props.setProperty(FILESYSTEM_LOCK_PATH_PROP_KEY, lockPath); + props.setProperty(FILESYSTEM_LOCK_EXPIRE_PROP_KEY, String.valueOf(expireMinutes)); + return new LockConfiguration(props); + } + + private String lockDir(String name) { + return tempDir.resolve(name).toString(); + } + + @Test + public void testAcquireAndReleaseLock() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + FileSystemBasedLockProvider provider = + new FileSystemBasedLockProvider(lockConfiguration(lockDir("acquire"), 0), storageConf); + try { + assertTrue(provider.tryLock(1, TimeUnit.SECONDS), "first acquisition should succeed"); + // getLock exposes the fully-qualified lock file path used on the backing storage. + assertTrue(provider.getLock().endsWith("/lock")); + provider.unlock(); + // After unlock the file is gone, so the lock is acquirable again. + assertTrue(provider.tryLock(1, TimeUnit.SECONDS), "re-acquisition after unlock should succeed"); + } finally { + provider.unlock(); + provider.close(); + } + } + + @Test + public void testConcurrentProvidersCannotBothHoldLock() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + LockConfiguration config = lockConfiguration(lockDir("contended"), 0); + FileSystemBasedLockProvider holder = new FileSystemBasedLockProvider(config, storageConf); + FileSystemBasedLockProvider contender = new FileSystemBasedLockProvider(config, storageConf); + try { + assertTrue(holder.tryLock(1, TimeUnit.SECONDS)); + // A non-expired lock owned by another provider blocks acquisition. + assertFalse(contender.tryLock(1, TimeUnit.SECONDS), "second provider must not acquire a held lock"); + // The contender is able to read the current owner's lock info. + assertTrue(contender.getCurrentOwnerLockInfo() != null && !contender.getCurrentOwnerLockInfo().isEmpty()); + holder.unlock(); + assertTrue(contender.tryLock(1, TimeUnit.SECONDS), "contender acquires after holder releases"); + } finally { + holder.unlock(); + contender.unlock(); + holder.close(); + } + } + + @Test + public void testExpiredLockIsReclaimed() throws Exception { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + String path = lockDir("expiry"); + // Expiry of 1 minute; we age the lock file deterministically rather than sleeping. + LockConfiguration config = lockConfiguration(path, 1); + FileSystemBasedLockProvider holder = new FileSystemBasedLockProvider(config, storageConf); + FileSystemBasedLockProvider contender = new FileSystemBasedLockProvider(config, storageConf); + try { + assertTrue(holder.tryLock(1, TimeUnit.SECONDS)); + // Not yet expired: contender is blocked. + assertFalse(contender.tryLock(1, TimeUnit.SECONDS)); + + // Age the lock file well beyond the 1-minute expiry window. + StoragePath lockFile = new StoragePath(path + StoragePath.SEPARATOR + "lock"); + HoodieStorage storage = HoodieStorageUtils.getStorage(lockFile.toString(), storageConf); + storage.setModificationTime(lockFile, System.currentTimeMillis() - (5 * 60 * 1000L)); + + // The expired lock file is deleted and the contender acquires it. + assertTrue(contender.tryLock(1, TimeUnit.SECONDS), "expired lock should be reclaimable"); + } finally { + holder.unlock(); + contender.unlock(); + contender.close(); + } + } + + @Test + public void testZeroExpiryNeverReclaims() throws Exception { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + String path = lockDir("noexpiry"); + // Expiry of 0 disables reclamation entirely. + LockConfiguration config = lockConfiguration(path, 0); + FileSystemBasedLockProvider holder = new FileSystemBasedLockProvider(config, storageConf); + FileSystemBasedLockProvider contender = new FileSystemBasedLockProvider(config, storageConf); + try { + assertTrue(holder.tryLock(1, TimeUnit.SECONDS)); + + // Even a very old lock file must not be reclaimed when expiry is disabled. + StoragePath lockFile = new StoragePath(path + StoragePath.SEPARATOR + "lock"); + HoodieStorage storage = HoodieStorageUtils.getStorage(lockFile.toString(), storageConf); + storage.setModificationTime(lockFile, System.currentTimeMillis() - (60 * 60 * 1000L)); + + assertFalse(contender.tryLock(1, TimeUnit.SECONDS), "zero expiry must never reclaim a lock"); + } finally { + holder.unlock(); + contender.unlock(); + holder.close(); + } + } + + @Test + public void testUnlockWithoutLockIsNoOp() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + FileSystemBasedLockProvider provider = + new FileSystemBasedLockProvider(lockConfiguration(lockDir("idempotent"), 0), storageConf); + // Releasing when no lock is held must not raise. + assertDoesNotThrow(provider::unlock); + provider.close(); + } + + @Test + public void testConstructorRejectsNegativeExpiry() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + LockConfiguration config = lockConfiguration(lockDir("bad"), -1); + assertThrows(IllegalArgumentException.class, + () -> new FileSystemBasedLockProvider(config, storageConf)); + } + + @Test + public void testGetLockConfigProducesUsableProperties() { + String tablePath = tempDir.resolve("table").toString(); + TypedProperties props = FileSystemBasedLockProvider.getLockConfig(tablePath); + // The generated config points the lock provider at the table's auxiliary folder. + assertTrue(props.getString(HoodieLockConfig.FILESYSTEM_LOCK_PATH.key()).startsWith(tablePath)); + assertEquals(FileSystemBasedLockProvider.class.getName(), + props.getString(HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key())); + + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + FileSystemBasedLockProvider provider = + new FileSystemBasedLockProvider(new LockConfiguration(props), storageConf); + try { + assertTrue(provider.tryLock(1, TimeUnit.SECONDS)); + } finally { + provider.unlock(); + provider.close(); + } + } + + @Test + public void testLockPathDefaultsToMetafolderFromBasePath() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + Properties props = new Properties(); + props.setProperty(HoodieWriteConfig.BASE_PATH.key(), lockDir("defaultpath")); + props.setProperty(FILESYSTEM_LOCK_EXPIRE_PROP_KEY, "0"); + FileSystemBasedLockProvider provider = + new FileSystemBasedLockProvider(new LockConfiguration(props), storageConf); + try { + assertTrue(provider.tryLock(1, TimeUnit.SECONDS), + "lock acquisition must work without an explicit lock path"); + // Without an explicit lock path the provider locks under the table metafolder. + assertTrue(provider.getLock().endsWith( + HoodieTableMetaClient.METAFOLDER_NAME + StoragePath.SEPARATOR + "lock")); + } finally { + provider.unlock(); + provider.close(); + } + } + + @Test + public void testSameProviderSecondTryLockFails() { + StorageConfiguration storageConf = HoodieTestUtils.getDefaultStorageConf(); + FileSystemBasedLockProvider provider = + new FileSystemBasedLockProvider(lockConfiguration(lockDir("nonreentrant"), 0), storageConf); + try { + assertTrue(provider.tryLock(1, TimeUnit.SECONDS)); + // The file lock is not reentrant: a second tryLock by the same provider fails. + assertFalse(provider.tryLock(1, TimeUnit.SECONDS)); + } finally { + provider.unlock(); + provider.close(); + } + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestLockResultEnums.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestLockResultEnums.java new file mode 100644 index 0000000000000..41caaa78575ce --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestLockResultEnums.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.hudi.client.transaction.lock; + +import org.apache.hudi.client.transaction.lock.models.LockGetResult; +import org.apache.hudi.client.transaction.lock.models.LockUpsertResult; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests the small result enums used by the storage-based lock provider. + */ +public class TestLockResultEnums { + + @Test + void lockGetResultCodesAreStable() { + assertEquals(0, LockGetResult.NOT_EXISTS.getCode()); + assertEquals(1, LockGetResult.SUCCESS.getCode()); + assertEquals(2, LockGetResult.UNKNOWN_ERROR.getCode()); + // Codes must be unique so callers can map them one-to-one. + assertEquals(3, LockGetResult.values().length); + } + + @Test + void lockGetResultValueOfRoundTrips() { + for (LockGetResult result : LockGetResult.values()) { + assertSame(result, LockGetResult.valueOf(result.name())); + } + } + + @Test + void lockUpsertResultCodesAreStable() { + assertEquals(0, LockUpsertResult.SUCCESS.getCode()); + assertEquals(1, LockUpsertResult.ACQUIRED_BY_OTHERS.getCode()); + assertEquals(2, LockUpsertResult.UNKNOWN_ERROR.getCode()); + assertEquals(3, LockUpsertResult.THROTTLED.getCode()); + assertEquals(4, LockUpsertResult.values().length); + } + + @Test + void lockUpsertResultValueOfRoundTrips() { + for (LockUpsertResult result : LockUpsertResult.values()) { + assertSame(result, LockUpsertResult.valueOf(result.name())); + } + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java index 11ea99df36aa3..26db59f5dc6ac 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestStorageBasedLockProvider.java @@ -625,8 +625,49 @@ void testRenewLockSucceeds() { assertTrue(lockProvider.renewLock()); verify(mockLogger).info( - eq("Owner {}: Lock renewal successful. The renewal completes {} ms before expiration for lock {}."), - eq(this.ownerId), anyLong(), eq("gs://bucket/lake/db/tbl-default/.hoodie/.locks/table_lock.json")); + eq("Owner {}: Lock renewal successful. The renewal completes {} ms before old expiration. The lock will expire in {} ms for lock {}."), + eq(this.ownerId), anyLong(), anyLong(), eq("gs://bucket/lake/db/tbl-default/.hoodie/.locks/table_lock.json")); + } + + @Test + void testRenewLockMetricUsesNewLeaseExpiration() { + // Regression test for https://github.com/apache/hudi/issues/18493: after a successful + // renewal the lock.expiration.deadline metric must reflect the remaining time on the newly + // renewed lease, not on the previous (about-to-expire) lease. + TypedProperties props = new TypedProperties(); + props.put(StorageBasedLockConfig.VALIDITY_TIMEOUT_SECONDS.key(), "10"); + props.put(StorageBasedLockConfig.RENEW_INTERVAL_SECS.key(), "1"); + props.put(BASE_PATH.key(), "gs://bucket/lake/db/tbl-default"); + + HoodieLockMetrics mockMetrics = mock(HoodieLockMetrics.class); + try (StorageBasedLockProvider providerWithMetrics = spy(new StorageBasedLockProvider( + ownerId, + props, + (a, b, c) -> mockHeartbeatManager, + (a, b, c) -> mockLockService, + mockLogger, + mockMetrics))) { + + long t0 = 100_000L; + when(providerWithMetrics.getCurrentEpochMs()).thenReturn(t0); + + // The currently held lease is about to expire (only 100 ms left) -- this is what makes the + // old vs new distinction observable: the old lease would have reported ~100 ms. + StorageLockData oldData = new StorageLockData(false, t0 + 100, ownerId); + StorageLockFile oldLockFile = new StorageLockFile(oldData, "v1"); + doReturn(oldLockFile).when(providerWithMetrics).getLock(); + + StorageLockData renewedLockData = new StorageLockData(false, t0 + DEFAULT_LOCK_VALIDITY_MS, ownerId); + StorageLockFile renewedLockFile = new StorageLockFile(renewedLockData, "v2"); + when(mockLockService.tryUpsertLockFile(any(), eq(Option.of(oldLockFile)))) + .thenReturn(Pair.of(LockUpsertResult.SUCCESS, Option.of(renewedLockFile))); + + assertTrue(providerWithMetrics.renewLock()); + + // New lease = t0 + 10000ms validity, evaluated at t0 -> 10000 ms remaining (the fix), + // not the ~100 ms remaining on the old lease (the bug). + verify(mockMetrics).updateLockExpirationDeadlineMetric(DEFAULT_LOCK_VALIDITY_MS); + } } @Test diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/audit/TestAuditOperationState.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/audit/TestAuditOperationState.java new file mode 100644 index 0000000000000..fd9cdf7f44e1f --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/audit/TestAuditOperationState.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.hudi.client.transaction.lock.audit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests the {@link AuditOperationState} lifecycle enum. + */ +public class TestAuditOperationState { + + @Test + void declaresExpectedStatesInOrder() { + assertArrayEquals( + new AuditOperationState[] { + AuditOperationState.START, + AuditOperationState.RENEW, + AuditOperationState.END + }, + AuditOperationState.values()); + } + + @Test + void valueOfResolvesEachState() { + for (AuditOperationState state : AuditOperationState.values()) { + assertSame(state, AuditOperationState.valueOf(state.name())); + } + } + + @Test + void valueOfRejectsUnknownState() { + assertThrows(IllegalArgumentException.class, () -> AuditOperationState.valueOf("PAUSE")); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/utils/TestLegacyArchivedMetaEntryReader.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/utils/TestLegacyArchivedMetaEntryReader.java index 5317583bbe9c2..7bab6f5a19a1c 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/utils/TestLegacyArchivedMetaEntryReader.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/utils/TestLegacyArchivedMetaEntryReader.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.ActiveAction; @@ -101,10 +102,13 @@ private void prepareLegacyArchivedTimeline(HoodieTableMetaClient metaClient) thr private HoodieLogFormat.Writer openWriter(HoodieTableMetaClient metaClient) { try { - return HoodieLogFormat.newWriterBuilder() - .onParentPath(metaClient.getArchivePath()) - .withFileId("commits").withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) - .withStorage(metaClient.getStorage()).withInstantTime("").build(); + return HoodieLogFormatWriter.builder() + .withParentPath(metaClient.getArchivePath()) + .withLogFileId("commits") + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withStorage(metaClient.getStorage()) + .withInstantTime("") + .build(); } catch (IOException e) { throw new HoodieException("Unable to initialize HoodieLogFormat writer", e); } @@ -113,13 +117,13 @@ private HoodieLogFormat.Writer openWriter(HoodieTableMetaClient metaClient) { public void archive(HoodieTableMetaClient metaClient, List instants) throws HoodieCommitException { try (HoodieLogFormat.Writer writer = openWriter(metaClient)) { Schema wrapperSchema = HoodieArchivedMetaEntry.getClassSchema(); - log.info("Wrapper schema " + wrapperSchema.toString()); + log.info("Wrapper schema {}", wrapperSchema); List records = new ArrayList<>(); for (HoodieInstant hoodieInstant : instants) { try { records.add(convertToAvroRecord(hoodieInstant, metaClient)); } catch (Exception e) { - log.error("Failed to archive commits, .commit file: " + INSTANT_FILE_NAME_GENERATOR.getFileName(hoodieInstant), e); + log.error("Failed to archive commits, .commit file: {}", INSTANT_FILE_NAME_GENERATOR.getFileName(hoodieInstant), e); throw e; } } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/validator/TestStreamingOffsetValidator.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/validator/TestStreamingOffsetValidator.java index 818bc2087a3c1..bc402e5ee5a0e 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/validator/TestStreamingOffsetValidator.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/validator/TestStreamingOffsetValidator.java @@ -62,7 +62,7 @@ public MockOffsetValidator(TypedProperties config) { // Expose protected method for testing public void testValidateOffsetConsistency(long offsetDiff, long recordsWritten, String current, String previous) { - validateOffsetConsistency(offsetDiff, recordsWritten, current, previous); + validateOffsetConsistency(offsetDiff, recordsWritten, 0L, current, previous); } // Expose validateWithMetadata for testing diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV2.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV2.java index 02ca10c102701..cd977c41a99af 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV2.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV2.java @@ -26,8 +26,10 @@ import org.apache.hudi.common.engine.LocalTaskContextSupplier; import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.testutils.HoodieCommonTestHarness; import org.apache.hudi.common.testutils.HoodieTestTable; +import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.index.HoodieIndex; @@ -38,6 +40,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; @@ -46,6 +49,8 @@ import static org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; /** @@ -77,6 +82,54 @@ public void testLoadingInstantsIncrementally() throws Exception { assertThat(archivedTimeline.firstInstant().map(HoodieInstant::requestedTime).orElse(""), is("10000011")); } + @Test + void testLoadCompactionDetailsForSingleInstant() { + String instantTime = "10000001"; + byte[] compactionPlan = {1, 2, 3}; + HoodieInstant completed = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, instantTime, "10000002"); + ActiveAction activeAction = new DummyActiveAction(completed, new byte[0]) { + @Override + public String getPendingAction() { + return HoodieTimeline.COMPACTION_ACTION; + } + + @Override + public Option getCompactionPlan(HoodieTableMetaClient metaClient) { + return Option.of(compactionPlan); + } + }; + createTimelineWriter().write( + Collections.singletonList(activeAction), Option.empty(), Option.empty()); + + HoodieArchivedTimeline archivedTimeline = metaClient.getArchivedTimeline(); + HoodieInstant archivedInstant = archivedTimeline.firstInstant().get(); + assertFalse(archivedTimeline.getInstantDetails(archivedInstant).isPresent()); + + archivedTimeline.loadCompactionDetailsInMemory(instantTime); + + assertArrayEquals(compactionPlan, archivedTimeline.getInstantDetails(archivedInstant).get()); + } + + @Test + void testLoadCompletedDetailsForSingleInstant() { + String instantTime = "10000001"; + byte[] commitMetadata = {1, 2, 3}; + HoodieInstant completed = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, instantTime, "10000002"); + createTimelineWriter().write( + Collections.singletonList(new DummyActiveAction(completed, commitMetadata)), + Option.empty(), Option.empty()); + + HoodieArchivedTimeline archivedTimeline = metaClient.getArchivedTimeline(); + HoodieInstant archivedInstant = archivedTimeline.firstInstant().get(); + assertFalse(archivedTimeline.getInstantDetails(archivedInstant).isPresent()); + + archivedTimeline.loadCompletedInstantDetailsInMemory(instantTime, instantTime); + + assertArrayEquals(commitMetadata, archivedTimeline.getInstantDetails(archivedInstant).get()); + } + @Test void getInstantReaderReferencesSelf() { HoodieArchivedTimeline timeline = TIMELINE_FACTORY.createArchivedTimeline(metaClient); @@ -88,12 +141,8 @@ void getInstantReaderReferencesSelf() { private void writeArchivedTimeline(int batchSize, long startTs) throws Exception { HoodieTestTable testTable = HoodieTestTable.of(this.metaClient); - HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder().withPath(this.metaClient.getBasePath()) - .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build()) - .withMarkersType("DIRECT") - .build(); + LSMTimelineWriter writer = createTimelineWriter(); HoodieEngineContext engineContext = new HoodieLocalEngineContext(getDefaultStorageConf()); - LSMTimelineWriter writer = LSMTimelineWriter.getInstance(writeConfig, new LocalTaskContextSupplier(), metaClient); List instantBuffer = new ArrayList<>(); for (int i = 1; i <= 50; i++) { long instantTimeTs = startTs + i; @@ -105,10 +154,18 @@ private void writeArchivedTimeline(int batchSize, long startTs) throws Exception instantBuffer.add(new DummyActiveAction(instant, serializedMetadata)); if (i % batchSize == 0) { // archive 10 instants each time - writer.write(instantBuffer, org.apache.hudi.common.util.Option.empty(), org.apache.hudi.common.util.Option.empty()); + writer.write(instantBuffer, Option.empty(), Option.empty()); writer.compactAndClean(engineContext); instantBuffer.clear(); } } } + + private LSMTimelineWriter createTimelineWriter() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder().withPath(this.metaClient.getBasePath()) + .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build()) + .withMarkersType("DIRECT") + .build(); + return LSMTimelineWriter.getInstance(writeConfig, new LocalTaskContextSupplier(), metaClient); + } } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java index 6c1d1418b1907..044e41ed89d2b 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/config/TestHoodieWriteConfig.java @@ -37,6 +37,7 @@ import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.config.HoodieWriteConfig.Builder; +import org.apache.hudi.exception.HoodieIndexException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; @@ -619,6 +620,25 @@ public void testSimpleBucketIndexPartitionerConfig() { assertEquals("org.apache.hudi.table.action.commit.UpsertPartitioner", overwritePartitioner.getString(HoodieLayoutConfig.LAYOUT_PARTITIONER_CLASS_NAME)); } + @Test + public void testBucketIndexKeyFieldValidationTrimsFields() { + Properties props = new Properties(); + props.setProperty(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "uuid, name"); + + HoodieIndexConfig indexConfig = HoodieIndexConfig.newBuilder() + .fromProperties(props) + .withIndexType(HoodieIndex.IndexType.BUCKET) + .withIndexKeyField(" name") + .build(); + assertEquals(" name", indexConfig.getString(HoodieIndexConfig.BUCKET_INDEX_HASH_FIELD)); + + assertThrows(HoodieIndexException.class, () -> HoodieIndexConfig.newBuilder() + .fromProperties(props) + .withIndexType(HoodieIndex.IndexType.BUCKET) + .withIndexKeyField("missing") + .build()); + } + @Test void testBloomIndexFileIdKeySortingConfig() { Properties props = new Properties(); diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/exception/TestClientExceptions.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/exception/TestClientExceptions.java new file mode 100644 index 0000000000000..8501aa3774b63 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/exception/TestClientExceptions.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.hudi.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the thin client-side exception types that wrap {@link HoodieException}. + */ +public class TestClientExceptions { + + @Test + void keyGeneratorExceptionPreservesMessageAndCause() { + Throwable cause = new IllegalStateException("boom"); + HoodieKeyGeneratorException withCause = new HoodieKeyGeneratorException("bad key", cause); + assertEquals("bad key", withCause.getMessage()); + assertSame(cause, withCause.getCause()); + assertTrue(withCause instanceof HoodieException); + + HoodieKeyGeneratorException messageOnly = new HoodieKeyGeneratorException("no cause"); + assertEquals("no cause", messageOnly.getMessage()); + assertNull(messageOnly.getCause()); + } + + @Test + void compactionExceptionPreservesMessageAndCause() { + Throwable cause = new RuntimeException("io"); + HoodieCompactionException withCause = new HoodieCompactionException("compaction failed", cause); + assertEquals("compaction failed", withCause.getMessage()); + assertSame(cause, withCause.getCause()); + assertTrue(withCause instanceof HoodieException); + + HoodieCompactionException messageOnly = new HoodieCompactionException("compaction failed"); + assertEquals("compaction failed", messageOnly.getMessage()); + assertNull(messageOnly.getCause()); + } + + @Test + void rollbackExceptionPreservesMessageAndCause() { + Throwable cause = new RuntimeException("io"); + HoodieRollbackException withCause = new HoodieRollbackException("rollback failed", cause); + assertEquals("rollback failed", withCause.getMessage()); + assertSame(cause, withCause.getCause()); + assertTrue(withCause instanceof HoodieException); + + HoodieRollbackException messageOnly = new HoodieRollbackException("rollback failed"); + assertEquals("rollback failed", messageOnly.getMessage()); + assertNull(messageOnly.getCause()); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertSortMode.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertSortMode.java new file mode 100644 index 0000000000000..8ff7ef0679d58 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertSortMode.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.hudi.execution.bulkinsert; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests {@link BulkInsertSortMode}. The constant names double as config values, so the + * full set is pinned here to catch accidental renames or removals. + */ +public class TestBulkInsertSortMode { + + @Test + void exposesExpectedModes() { + assertEquals( + "NONE,GLOBAL_SORT,PARTITION_SORT,PARTITION_PATH_REPARTITION,PARTITION_PATH_REPARTITION_AND_SORT", + Arrays.stream(BulkInsertSortMode.values()) + .map(Enum::name) + .collect(Collectors.joining(","))); + } + + @Test + void valueOfResolvesEachModeCaseSensitively() { + for (BulkInsertSortMode mode : BulkInsertSortMode.values()) { + assertSame(mode, BulkInsertSortMode.valueOf(mode.name())); + } + assertThrows(IllegalArgumentException.class, () -> BulkInsertSortMode.valueOf("global_sort")); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bloom/TestBloomIndexFileInfo.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bloom/TestBloomIndexFileInfo.java new file mode 100644 index 0000000000000..9f235d1d23370 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bloom/TestBloomIndexFileInfo.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.hudi.index.bloom; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link BloomIndexFileInfo} accessors and key-range checks. + */ +public class TestBloomIndexFileInfo { + + @Test + void fileIdOnlyConstructorLeavesRangeUnset() { + BloomIndexFileInfo info = new BloomIndexFileInfo("f1"); + assertEquals("f1", info.getFileId()); + assertNull(info.getMinRecordKey()); + assertNull(info.getMaxRecordKey()); + assertFalse(info.hasKeyRanges()); + } + + @Test + void fullConstructorPopulatesRange() { + BloomIndexFileInfo info = new BloomIndexFileInfo("f1", "key05", "key20"); + assertEquals("key05", info.getMinRecordKey()); + assertEquals("key20", info.getMaxRecordKey()); + assertTrue(info.hasKeyRanges()); + } + + @Test + void isKeyInRangeIsInclusiveOfBounds() { + BloomIndexFileInfo info = new BloomIndexFileInfo("f1", "key05", "key20"); + assertTrue(info.isKeyInRange("key05")); + assertTrue(info.isKeyInRange("key10")); + assertTrue(info.isKeyInRange("key20")); + assertFalse(info.isKeyInRange("key04")); + assertFalse(info.isKeyInRange("key21")); + } + + @Test + void isKeyInRangeRequiresBounds() { + BloomIndexFileInfo noRange = new BloomIndexFileInfo("f1"); + // isKeyInRange does not guard on hasKeyRanges(); with no bounds set it currently + // fails fast with an NPE from Objects.requireNonNull rather than returning false. + assertThrows(NullPointerException.class, () -> noRange.isKeyInRange("key10")); + } + + @Test + void valueSemanticsForEqualsAndHashCode() { + BloomIndexFileInfo a = new BloomIndexFileInfo("f1", "min", "max"); + BloomIndexFileInfo same = new BloomIndexFileInfo("f1", "min", "max"); + BloomIndexFileInfo different = new BloomIndexFileInfo("f2", "min", "max"); + assertEquals(a, same); + assertEquals(a.hashCode(), same.hashCode()); + assertNotEquals(a, different); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieWriteHandle.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieWriteHandle.java index b37f5989e57c3..394fa7b292c37 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieWriteHandle.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieWriteHandle.java @@ -81,6 +81,7 @@ public void setUp() { MockitoAnnotations.initMocks(this); when(mockHoodieTable.getMetaClient()).thenReturn(mockMetaClient); when(mockMetaClient.getTableConfig()).thenReturn(mockTableConfig); + when(mockTableConfig.getPayloadClassIfPresent()).thenReturn(Option.empty()); when(mockWriteConfig.getRecordMerger()).thenReturn(mockRecordMerger); // Set up a basic schema for the write config diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/keygen/TestKeyGenUtils.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/keygen/TestKeyGenUtils.java index 777c4792901fb..cd837c0608e89 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/keygen/TestKeyGenUtils.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/keygen/TestKeyGenUtils.java @@ -18,9 +18,11 @@ package org.apache.hudi.keygen; +import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.util.Option; import org.apache.hudi.exception.HoodieKeyException; +import org.apache.hudi.keygen.constant.KeyGeneratorOptions; import org.apache.hudi.keygen.constant.KeyGeneratorType; import org.apache.avro.Schema; @@ -31,6 +33,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import static org.apache.hudi.common.table.HoodieTableConfig.KEY_GENERATOR_TYPE; @@ -121,6 +124,24 @@ public void testInferKeyGeneratorTypeFromPartitionFields() { KeyGenUtils.inferKeyGeneratorTypeFromPartitionFields(null)); } + @Test + public void testGetRecordKeyFields() { + assertEquals(Collections.emptyList(), KeyGenUtils.getRecordKeyFields((String) null)); + assertEquals(Collections.emptyList(), KeyGenUtils.getRecordKeyFields("")); + assertEquals(Arrays.asList("id", "ts", "name"), KeyGenUtils.getRecordKeyFields(" id,ts, name,, ")); + + TypedProperties props = new TypedProperties(); + props.setProperty(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), " id,ts "); + assertEquals(Arrays.asList("id", "ts"), KeyGenUtils.getRecordKeyFields(props)); + } + + @Test + public void testGetIndexKeyFields() { + assertEquals(Collections.emptyList(), KeyGenUtils.getIndexKeyFields(null)); + assertEquals(Collections.emptyList(), KeyGenUtils.getIndexKeyFields("")); + assertEquals(Arrays.asList("id", "ts", "name"), KeyGenUtils.getIndexKeyFields(" id,ts, name,, ")); + } + @Test public void testExtractRecordKeys() { // if for recordKey one column only is used, then there is no added column name before value diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java index 4065f113d518f..d658cb27aa936 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java @@ -18,33 +18,46 @@ package org.apache.hudi.metadata; +import org.apache.hudi.avro.model.HoodieRestoreMetadata; import org.apache.hudi.client.BaseHoodieWriteClient; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.config.HoodieTableServiceManagerConfig; import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2; +import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieCleanConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.storage.StorageConfiguration; 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.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockedStatic; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -54,19 +67,24 @@ import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; class TestHoodieBackedTableMetadataWriter { @@ -85,6 +103,29 @@ void setUp() { when(metadataConfig.getMaxReaderBufferSize()).thenReturn(1024); } + @Test + void completeStreamingCommitSkipsAlreadyCompletedMetadataInstant() { + String instantTime = "20260709120000000"; + HoodieBackedTableMetadataWriter, List> metadataWriter = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + HoodieEngineContext engineContext = mock(HoodieEngineContext.class); + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class); + HoodieTimeline completedTimeline = mock(HoodieTimeline.class); + BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class); + + metadataWriter.metadataMetaClient = metadataMetaClient; + when(metadataMetaClient.getActiveTimeline()).thenReturn(activeTimeline); + when(activeTimeline.filterCompletedInstants()).thenReturn(completedTimeline); + when(completedTimeline.containsInstant(instantTime)).thenReturn(true); + when(metadataWriter.initializeWriteClient()).thenReturn(writeClient); + + metadataWriter.completeStreamingCommit(instantTime, engineContext, Collections.emptyList(), mock(HoodieCommitMetadata.class)); + + verify(writeClient).postCommit(instantTime); + verifyNoMoreInteractions(writeClient); + } + @ParameterizedTest @CsvSource(value = { "true,true,false,true", @@ -377,6 +418,47 @@ void testValidateRollbackForMDT() throws Exception { assertDoesNotThrow(() -> validateRollbackMethod.invoke(writer, instantToRollback)); } + // ---- resolveDataSchemaForRLIBootstrap tests ---- + + private static final String SIMPLE_SCHEMA_JSON = + "{\"type\":\"record\",\"name\":\"Test\",\"namespace\":\"test\"," + + "\"fields\":[{\"name\":\"id\",\"type\":\"string\"}]}"; + + @Test + void resolveDataSchemaForRLIBootstrap_usesConfigSchemaWhenPresent() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieWriteConfig writeConfig = mock(HoodieWriteConfig.class); + when(writeConfig.getWriteSchema()).thenReturn(SIMPLE_SCHEMA_JSON); + when(writeConfig.allowOperationMetadataField()).thenReturn(false); + + HoodieSchema result = HoodieBackedTableMetadataWriter.resolveDataSchemaForRLIBootstrap(metaClient, writeConfig); + + assertNotNull(result); + // metadata fields (_hoodie_*) should have been prepended + assertTrue(result.getFields().stream().anyMatch(f -> f.name().startsWith("_hoodie_"))); + } + + @Test + void resolveDataSchemaForRLIBootstrap_fallsBackToTableSchemaResolverWhenNull() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieWriteConfig writeConfig = mock(HoodieWriteConfig.class); + when(writeConfig.getWriteSchema()).thenReturn(null); + when(writeConfig.allowOperationMetadataField()).thenReturn(false); + + HoodieSchema tableSchema = HoodieSchema.parse(SIMPLE_SCHEMA_JSON); + try (org.mockito.MockedConstruction mockedResolver = + mockConstruction(TableSchemaResolver.class, + (resolver, ctx) -> when(resolver.getTableSchema(false)).thenReturn(tableSchema))) { + + HoodieSchema result = HoodieBackedTableMetadataWriter.resolveDataSchemaForRLIBootstrap(metaClient, writeConfig); + + assertNotNull(result); + assertTrue(result.getFields().stream().anyMatch(f -> f.name().startsWith("_hoodie_"))); + // exactly one TableSchemaResolver was constructed (with metaClient) + assertEquals(1, mockedResolver.constructed().size()); + } + } + @SuppressWarnings("deprecation") private HoodieActiveTimeline createMockTimeline(List instants) { ActiveTimelineV2 timeline = new ActiveTimelineV2(); @@ -506,4 +588,172 @@ void testPerformTableServicesWithFailureHandling( // Verify metrics are incremented when there's a failure verify(metrics, times(1)).incrementMetric(HoodieMetadataMetrics.PENDING_COMPACTIONS_FAILURES, 1); } + + @Test + void wrapsMetadataReaderFailures() throws Exception { + // Reader setup must preserve the public exception contract. + // The lazy file listing half of this test is dropped on this branch: it reflects on + // getLazyMergedFileSlices, which master added in 2baa29b14d37 (#18372) as part of the + // index-abstraction refactor of the metadata table update path, and that refactor is not + // backported here. + HoodieBackedTableMetadataWriter, List> writer = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + writer.dataWriteConfig = HoodieWriteConfig.newBuilder().withPath("/tmp/missing-table").build(); + writer.dataMetaClient = mock(HoodieTableMetaClient.class); + Method maybeReinitializeReader = + HoodieBackedTableMetadataWriter.class.getDeclaredMethod("mayBeReinitMetadataReader"); + maybeReinitializeReader.setAccessible(true); + InvocationTargetException readerFailure = assertThrows( + InvocationTargetException.class, () -> maybeReinitializeReader.invoke(writer)); + assertTrue(readerFailure.getCause() instanceof HoodieException); + } + + @Test + void detectsEmptyMetadataTimelineAndHandlesMissingMetadataTable(@TempDir Path tempDir) throws Exception { + // Missing MDT state requires bootstrap without trusting stale table config. + HoodieBackedTableMetadataWriter, List> writer = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + Method isBootstrapNeeded = HoodieBackedTableMetadataWriter.class + .getDeclaredMethod("isBootstrapNeeded", Option.class); + isBootstrapNeeded.setAccessible(true); + assertTrue((boolean) isBootstrapNeeded.invoke(writer, Option.empty())); + + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + when(dataMetaClient.getTableConfig()).thenReturn(tableConfig); + when(tableConfig.isMetadataTableAvailable()).thenReturn(true); + writer.storageConf = org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf(); + writer.dataWriteConfig = HoodieWriteConfig.newBuilder() + .withPath(tempDir.resolve("data-table").toString()) + .build(); + writer.metadataWriteConfig = HoodieWriteConfig.newBuilder() + .withPath(tempDir.resolve("missing-metadata-table").toString()) + .build(); + Method metadataTableExists = HoodieBackedTableMetadataWriter.class + .getDeclaredMethod("metadataTableExists", HoodieTableMetaClient.class); + metadataTableExists.setAccessible(true); + assertFalse((boolean) metadataTableExists.invoke(writer, dataMetaClient)); + } + + @Test + void ignoresIOExceptionWhileRemovingPendingIndexInstant() throws Exception { + // A corrupt pending index plan must not block partition cleanup. + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class); + HoodieInstant pendingIndex = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.INDEXING_ACTION, "001"); + when(metaClient.getInstantGenerator()).thenReturn(INSTANT_GENERATOR); + when(metaClient.reloadActiveTimeline()).thenReturn(timeline); + when(metaClient.getActiveTimeline()).thenReturn(timeline); + when(timeline.filterPendingIndexTimeline()).thenReturn(timeline); + when(timeline.getInstantsAsStream()).thenReturn(Stream.of(pendingIndex)); + when(timeline.readIndexPlan(pendingIndex)).thenThrow(new IOException("cannot read plan")); + Method deletePendingIndexingInstant = HoodieBackedTableMetadataWriter.class + .getDeclaredMethod("deletePendingIndexingInstant", HoodieTableMetaClient.class, String.class); + deletePendingIndexingInstant.setAccessible(true); + + assertDoesNotThrow(() -> deletePendingIndexingInstant.invoke(null, metaClient, "column_stats")); + } + + @Test + void wrapsRestorePlanReadFailure() throws Exception { + // Restore-plan I/O failures must surface as HoodieIOException. + HoodieBackedTableMetadataWriter, List> writer = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + HoodieBackedTableMetadata metadata = mock(HoodieBackedTableMetadata.class); + HoodieTableFileSystemView metadataView = mock(HoodieTableFileSystemView.class); + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class); + when(metadata.getMetadataFileSystemView()).thenReturn(metadataView); + when(dataMetaClient.getInstantGenerator()).thenReturn(INSTANT_GENERATOR); + when(dataMetaClient.getActiveTimeline()).thenReturn(timeline); + when(timeline.readRestorePlan(any())).thenThrow(new IOException("cannot read restore plan")); + writer.metadata = metadata; + writer.metadataMetaClient = metadataMetaClient; + writer.dataMetaClient = dataMetaClient; + + assertThrows(HoodieIOException.class, + () -> writer.update(mock(HoodieRestoreMetadata.class), "001")); + } + + @Test + void rejectsPendingMetadataCompactionAndWrapsCloseFailures() { + // Pending compaction blocks scheduling, while close errors remain visible. + HoodieBackedTableMetadataWriter, List> writer = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class); + when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true); + writer.metadataWriteConfig = metadataWriteConfig; + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, RETURNS_DEEP_STUBS); + HoodieInstant pendingCompaction = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION, "001"); + when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline); + when(metadataTimeline.filterPendingLogCompactionTimeline().firstInstant()).thenReturn(Option.empty()); + when(metadataTimeline.filterPendingCompactionTimeline().firstInstant()).thenReturn(Option.of(pendingCompaction)); + writer.metadataMetaClient = metadataMetaClient; + + assertThrows(HoodieException.class, () -> { + doThrow(new HoodieException("close failed")).when(writer).close(); + writer.closeInternal(); + }); + assertFalse(writer.validateCompactionScheduling(Option.empty(), "002")); + } + + @Test + void compactIfNecessaryHandlesSkipDelegationAndFailures() { + // Exercise skip, delegation, and failure propagation for both compaction types. + Properties tableServiceManagerProperties = new Properties(); + tableServiceManagerProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), "true"); + tableServiceManagerProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), "compaction,logcompaction"); + HoodieTableServiceManagerConfig tableServiceManagerConfig = + HoodieTableServiceManagerConfig.newBuilder().fromProperties(tableServiceManagerProperties).build(); + HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class); + when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn(tableServiceManagerConfig); + when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true); + + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class, RETURNS_DEEP_STUBS); + when(dataMetaClient.reloadActiveTimeline().filterInflightsAndRequested() + .filter(any()).firstInstant()).thenReturn(Option.empty()); + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class); + HoodieTimeline completedTimeline = mock(HoodieTimeline.class); + when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline); + when(metadataTimeline.filterCompletedInstants()).thenReturn(completedTimeline); + when(completedTimeline.containsInstant(any(String.class))) + .thenAnswer(invocation -> "100".equals(invocation.getArgument(0))); + + HoodieBackedTableMetadataWriter, List> writer = + mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + writer.dataMetaClient = dataMetaClient; + writer.metadataMetaClient = metadataMetaClient; + writer.metadataWriteConfig = metadataWriteConfig; + writer.metrics = Option.empty(); + + BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class); + when(writeClient.createNewInstantTime(false)).thenReturn("100", "200", "300", "400"); + when(writeClient.scheduleCompactionAtInstant("200", Option.empty())).thenReturn(true); + when(writeClient.scheduleCompactionAtInstant("300", Option.empty())) + .thenThrow(new HoodieException("compaction failed")); + when(writeClient.scheduleCompactionAtInstant("400", Option.empty())).thenReturn(false); + when(writeClient.scheduleLogCompaction(Option.empty())) + .thenReturn(Option.of("201")) + .thenThrow(new HoodieException("log compaction failed")); + + writer.compactIfNecessary(writeClient, Option.empty()); + writer.compactIfNecessary(writeClient, Option.empty()); + assertThrows(HoodieException.class, () -> writer.compactIfNecessary(writeClient, Option.empty())); + assertThrows(HoodieException.class, () -> writer.compactIfNecessary(writeClient, Option.empty())); + } + + private static void setField(Object target, String name, Object value) throws Exception { + // Exercise private failure paths without changing production visibility. + java.lang.reflect.Field field = HoodieBackedTableMetadataWriter.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java index 804acc5f2ef3e..184003552ca39 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java @@ -18,6 +18,10 @@ package org.apache.hudi.metadata; +import org.apache.hudi.client.BaseHoodieWriteClient; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.config.HoodieTableServiceManagerConfig; +import org.apache.hudi.common.model.ActionType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; @@ -26,19 +30,28 @@ import org.apache.hudi.common.table.timeline.versioning.v1.ActiveTimelineV1; import org.apache.hudi.common.table.timeline.versioning.v1.InstantGeneratorV1; import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieMetadataException; import org.junit.jupiter.api.Test; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Properties; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -225,6 +238,146 @@ void testShouldInitializeFromFilesystem_completedRollbacksDoNotCount() throws Ex assertFalse(result, "Should block initialization when rollbacks are completed, not pending"); } + @Test + void testGenerateUniqueInstantTimePreservesIndexingInstant() throws Exception { + // An indexing instant is already globally unique and must be reused. + String indexingInstant = "20250101120000000"; + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline timeline = createMockTimeline(Collections.singletonList( + INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.INDEXING_ACTION, indexingInstant))); + when(dataMetaClient.getActiveTimeline()).thenReturn(timeline); + HoodieBackedTableMetadataWriterTableVersionSix writer = createMockWriter(dataMetaClient); + + assertTrue(indexingInstant.equals(writer.generateUniqueInstantTime(indexingInstant))); + } + + @Test + void testValidateCompactionSchedulingRejectsPendingMetadataTableService() throws Exception { + // Do not schedule compaction while another metadata table service is pending. + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline dataTimeline = createMockTimeline(Collections.emptyList()); + when(dataMetaClient.reloadActiveTimeline()).thenReturn(dataTimeline); + HoodieBackedTableMetadataWriterTableVersionSix writer = createMockWriter(dataMetaClient); + + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, RETURNS_DEEP_STUBS); + HoodieInstant pendingCompaction = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION, "20250101120000001"); + when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline); + when(metadataTimeline.filterPendingLogCompactionTimeline().firstInstant()).thenReturn(Option.empty()); + when(metadataTimeline.filterPendingCompactionTimeline().firstInstant()).thenReturn(Option.of(pendingCompaction)); + writer.metadataMetaClient = metadataMetaClient; + + assertFalse(writer.validateCompactionScheduling(Option.empty(), "20250101120000002")); + } + + @Test + void testValidateCompactionSchedulingRejectsExcessiveDeltaCommits() throws Exception { + // Protect pending data commits from unbounded metadata delta commits. + HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class); + HoodieInstant pendingCommit = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.COMMIT_ACTION, "20250101120000000"); + HoodieActiveTimeline dataTimeline = createMockTimeline(Collections.singletonList(pendingCommit)); + when(dataMetaClient.reloadActiveTimeline()).thenReturn(dataTimeline); + HoodieBackedTableMetadataWriterTableVersionSix writer = createMockWriter(dataMetaClient); + + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, RETURNS_DEEP_STUBS); + when(metadataMetaClient.reloadActiveTimeline()).thenReturn(metadataTimeline); + when(metadataTimeline.filterCompletedInstants().filter(any()).lastInstant()).thenReturn(Option.empty()); + when(metadataTimeline.getDeltaCommitTimeline().countInstants()).thenReturn(2); + writer.metadataMetaClient = metadataMetaClient; + writer.dataWriteConfig = HoodieWriteConfig.newBuilder() + .withPath("/tmp/table") + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .withMaxNumDeltacommitsWhenPending(1) + .build()) + .build(); + + assertThrows(HoodieMetadataException.class, + () -> writer.validateCompactionScheduling(Option.empty(), "20250101120000001")); + } + + @Test + void testCompactIfNecessaryCoversExistingDelegatedAndLogCompactionPaths() { + // Exercise completed, delegated, and log-compaction fallback paths. + Properties tableServiceManagerProperties = new Properties(); + tableServiceManagerProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), "true"); + tableServiceManagerProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), ActionType.compaction.name()); + HoodieTableServiceManagerConfig tableServiceManagerConfig = + HoodieTableServiceManagerConfig.newBuilder() + .fromProperties(tableServiceManagerProperties) + .build(); + HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class); + when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn(tableServiceManagerConfig); + when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true); + + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class, RETURNS_DEEP_STUBS); + when(metadataMetaClient.getActiveTimeline()).thenReturn(timeline); + when(timeline.filterCompletedInstants().containsInstant("100001")).thenReturn(true); + when(timeline.filterCompletedInstants().containsInstant("200001")).thenReturn(false); + when(timeline.filterCompletedInstants().containsInstant("300001")).thenReturn(false); + when(timeline.filterCompletedInstants().containsInstant("300005")).thenReturn(false); + when(timeline.filterCompletedInstants().containsInstant("400001")).thenReturn(false); + when(timeline.filterCompletedInstants().containsInstant("400005")).thenReturn(false); + + HoodieBackedTableMetadataWriterTableVersionSix writer = + mock(HoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + writer.metadataMetaClient = metadataMetaClient; + writer.metadataWriteConfig = metadataWriteConfig; + BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class); + when(writeClient.scheduleCompactionAtInstant("200001", Option.empty())).thenReturn(true); + when(writeClient.scheduleCompactionAtInstant("300001", Option.empty())).thenReturn(false); + when(writeClient.scheduleLogCompactionAtInstant("300005", Option.empty())).thenReturn(true); + + // Version 6 derives compaction and log-compaction instants with fixed suffixes. + writer.compactIfNecessary(writeClient, Option.of("100")); + writer.compactIfNecessary(writeClient, Option.of("200")); + writer.compactIfNecessary(writeClient, Option.of("300")); + + Properties allTableServicesProperties = new Properties(); + allTableServicesProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), "true"); + allTableServicesProperties.put( + HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), "compaction,logcompaction"); + when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn( + HoodieTableServiceManagerConfig.newBuilder() + .fromProperties(allTableServicesProperties) + .build()); + when(writeClient.scheduleCompactionAtInstant("400001", Option.empty())).thenReturn(false); + when(writeClient.scheduleLogCompactionAtInstant("400005", Option.empty())).thenReturn(true); + writer.compactIfNecessary(writeClient, Option.of("400")); + + verify(writeClient).scheduleCompactionAtInstant("200001", Option.empty()); + verify(writeClient).scheduleLogCompactionAtInstant("300005", Option.empty()); + verify(writeClient).logCompact("300005", true); + } + + @Test + void testValidateRollbackRejectsCommitBeforeLatestCompaction() throws Exception { + // Version 6 cannot roll back beyond the latest compaction boundary. + HoodieBackedTableMetadataWriterTableVersionSix writer = + mock(HoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + HoodieInstant compactionInstant = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "200", "201"); + HoodieTimeline deltaCommits = mock(HoodieTimeline.class); + when(deltaCommits.countInstants()).thenReturn(2); + when(deltaCommits.getInstants()).thenReturn(Collections.emptyList()); + Method validateRollback = HoodieBackedTableMetadataWriterTableVersionSix.class + .getDeclaredMethod( + "validateRollbackVersionSix", String.class, HoodieInstant.class, HoodieTimeline.class); + validateRollback.setAccessible(true); + + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> validateRollback.invoke(writer, "100", compactionInstant, deltaCommits)); + assertTrue(exception.getCause() instanceof HoodieMetadataException); + } + private HoodieBackedTableMetadataWriterTableVersionSix createMockWriter(HoodieTableMetaClient dataMetaClient) throws Exception { // Use CALLS_REAL_METHODS so that shouldInitializeFromFilesystem executes the real logic HoodieBackedTableMetadataWriterTableVersionSix writer = mock(HoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java index 73d10ef228d23..8845cbe8912ad 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java @@ -32,10 +32,16 @@ import org.apache.hudi.config.HoodieCleanConfig; import org.apache.hudi.config.HoodieLockConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.config.metrics.HoodieMetricsConfig; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieMetadataException; +import org.apache.hudi.metrics.MetricsReporterType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import java.util.Collections; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -47,6 +53,46 @@ public class TestHoodieMetadataWriteUtils { + @ParameterizedTest + @EnumSource(value = MetricsReporterType.class, names = { + "GRAPHITE", "JMX", "PROMETHEUS_PUSHGATEWAY", "M3", "PROMETHEUS" + }) + void testCreateMetadataWriteConfigPropagatesSupportedMetricsReporter(MetricsReporterType reporterType) { + // Metadata writes must retain every reporter supported by the writer path. + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath("/tmp/base_path/") + .withProps(Collections.singletonMap( + "hoodie.metrics.graphite.metric.prefix", "metadata-test")) + .withMetricsConfig(HoodieMetricsConfig.newBuilder() + .on(true) + .withReporterType(reporterType.name()) + .build()) + .build(); + + HoodieWriteConfig metadataWriteConfig = HoodieMetadataWriteUtils.createMetadataWriteConfig( + writeConfig, HoodieFailedWritesCleaningPolicy.EAGER, HoodieTableVersion.EIGHT); + + assertTrue(metadataWriteConfig.isMetricsOn()); + assertEquals(reporterType, metadataWriteConfig.getMetricsReporterType()); + } + + @Test + void testCreateMetadataWriteConfigRejectsUnsupportedMetricsReporter() { + // Reject unsupported reporters before the metadata writer starts. + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath("/tmp/base_path/") + .withMetricsConfig(HoodieMetricsConfig.newBuilder() + .on(true) + .withReporterType(MetricsReporterType.SLF4J.name()) + .build()) + .build(); + + HoodieMetadataException exception = assertThrows(HoodieMetadataException.class, + () -> HoodieMetadataWriteUtils.createMetadataWriteConfig( + writeConfig, HoodieFailedWritesCleaningPolicy.EAGER, HoodieTableVersion.EIGHT)); + assertTrue(exception.getMessage().contains("Unsupported Metrics Reporter type SLF4J")); + } + @Test public void testCreateMetadataWriteConfigForCleaner() { HoodieWriteConfig writeConfig1 = HoodieWriteConfig.newBuilder() diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.java new file mode 100644 index 0000000000000..3db50783cc6ca --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.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.hudi.metadata; + +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieIOException; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +/** + * Tests validation and error propagation before secondary-index record generation. + */ +class TestSecondaryIndexRecordGenerationUtils { + + @Test + void rejectsLogFileInsertsBeforeReadingFileSlices() { + // Log-file inserts cannot be reconstructed without a base-file slice. + HoodieWriteStat writeStat = new HoodieWriteStat(); + writeStat.setPartitionPath("p1"); + writeStat.setPath("p1/.fileid-1_014.log.1_1-0-1"); + writeStat.setNumInserts(1); + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder().withPath("/tmp/table").build(); + + assertThrows(HoodieIOException.class, + () -> SecondaryIndexRecordGenerationUtils.convertWriteStatsToSecondaryIndexRecords( + Collections.singletonList(writeStat), "001", null, null, null, null, writeConfig)); + } + + @Test + void wrapsTableSchemaResolutionFailure() { + // Wrap schema lookup failures in the metadata utility's exception type. + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder().withPath("/tmp/table").build(); + + try (MockedStatic metadataUtil = + mockStatic(HoodieTableMetadataUtil.class, CALLS_REAL_METHODS)) { + metadataUtil.when(() -> HoodieTableMetadataUtil.tryResolveSchemaForTable(metaClient)) + .thenThrow(new IllegalStateException("no schema")); + + assertThrows(HoodieException.class, + () -> SecondaryIndexRecordGenerationUtils.convertWriteStatsToSecondaryIndexRecords( + Collections.emptyList(), "001", null, null, metaClient, null, writeConfig)); + } + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metrics/TestHoodieMetrics.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metrics/TestHoodieMetrics.java index b785d90252880..1a2702a77a040 100755 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metrics/TestHoodieMetrics.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metrics/TestHoodieMetrics.java @@ -40,6 +40,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.stream.Stream; @@ -48,7 +50,10 @@ import static org.apache.hudi.metrics.HoodieMetrics.COUNTER_METRIC_EXTENSION; import static org.apache.hudi.metrics.HoodieMetrics.FAILURE_COUNTER; import static org.apache.hudi.metrics.HoodieMetrics.SOURCE_READ_AND_INDEX_ACTION; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -322,6 +327,317 @@ public MockHoodieActiveTimeline(HoodieInstant... instants) { } } + // ----------------------------------------------------------------------- + // Metrics-off safety tests (NPE guards) + // ----------------------------------------------------------------------- + + private HoodieMetrics buildMetricsOff() { + HoodieWriteConfig offConfig = mock(HoodieWriteConfig.class); + when(offConfig.isMetricsOn()).thenReturn(false); + when(offConfig.getTableName()).thenReturn("test_table"); + return new HoodieMetrics(offConfig, HoodieTestUtils.getDefaultStorage()); + } + + @Test + public void testTimerContextsReturnNullWhenMetricsOff() { + HoodieMetrics metricsOff = buildMetricsOff(); + assertNull(metricsOff.getRollbackCtx()); + assertNull(metricsOff.getCompactionCtx()); + assertNull(metricsOff.getLogCompactionCtx()); + assertNull(metricsOff.getClusteringCtx()); + assertNull(metricsOff.getCleanCtx()); + assertNull(metricsOff.getArchiveCtx()); + assertNull(metricsOff.getCommitCtx()); + assertNull(metricsOff.getFinalizeCtx()); + assertNull(metricsOff.getDeltaCommitCtx()); + assertNull(metricsOff.getIndexCtx()); + assertNull(metricsOff.getSourceReadAndIndexTimerCtx()); + assertNull(metricsOff.getConflictResolutionCtx()); + } + + @Test + public void testUpdateMethodsAreNoOpsWhenMetricsOff() { + HoodieMetrics metricsOff = buildMetricsOff(); + HoodieCommitMetadata metadata = mock(HoodieCommitMetadata.class); + + assertDoesNotThrow(() -> metricsOff.updateCommitMetrics(0L, 0L, metadata, "commit")); + assertDoesNotThrow(() -> metricsOff.updateRollbackMetrics(0L, 0L)); + assertDoesNotThrow(() -> metricsOff.updateCleanMetrics(0L, 0)); + assertDoesNotThrow(() -> metricsOff.updateFinalizeWriteMetrics(0L, 0L)); + assertDoesNotThrow(() -> metricsOff.updateIndexMetrics("action", 0L)); + assertDoesNotThrow(() -> metricsOff.updateSourceReadAndIndexMetrics("action", 0L)); + assertDoesNotThrow(() -> metricsOff.updateArchiveMetrics(0L, 0)); + assertDoesNotThrow(() -> metricsOff.updateArchivalMetrics(new HashMap<>())); + assertDoesNotThrow(() -> metricsOff.updatePostCommitMetrics(true, 0L)); + assertDoesNotThrow(() -> metricsOff.updatePostCommitMetrics(false, 0L)); + assertDoesNotThrow(() -> metricsOff.reportMetrics("action", "metric", 0L)); + assertDoesNotThrow(() -> metricsOff.updateClusteringFileCreationMetrics(0L)); + assertDoesNotThrow(() -> metricsOff.emitRollbackFailure("SomeException")); + assertDoesNotThrow(() -> metricsOff.emitRollbackFailure(null)); + assertDoesNotThrow(() -> metricsOff.emitCompactionRequested()); + assertDoesNotThrow(() -> metricsOff.emitCompactionCompleted()); + assertDoesNotThrow(() -> metricsOff.emitIndexTypeMetrics(0)); + assertDoesNotThrow(() -> metricsOff.emitMetadataEnablementMetrics(false, false, false, false)); + assertDoesNotThrow(() -> metricsOff.emitVersionMetrics()); + } + + @Test + public void testConflictResolutionMetricsAreNoOpsWhenMetricsOffButLockingEnabled() { + // Key NPE scenario: global metrics are off so metrics field is null. + // The isMetricsOn() && isLockingMetricsEnabled() guard short-circuits before + // reaching getCounter() -> metrics.getRegistry(), so no NPE should occur. + HoodieWriteConfig offConfig = mock(HoodieWriteConfig.class); + when(offConfig.isMetricsOn()).thenReturn(false); + when(offConfig.getTableName()).thenReturn("test_table"); + HoodieMetrics metricsOff = new HoodieMetrics(offConfig, HoodieTestUtils.getDefaultStorage()); + + assertNull(metricsOff.getConflictResolutionCtx()); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionSuccessful()); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionFailed()); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionByCategory( + HoodieWriteConflictException.ConflictCategory.INGESTION_VS_INGESTION)); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionByCategory( + HoodieWriteConflictException.ConflictCategory.INGESTION_VS_TABLE_SERVICE)); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionByCategory( + HoodieWriteConflictException.ConflictCategory.TABLE_SERVICE_VS_INGESTION)); + assertDoesNotThrow(() -> metricsOff.emitConflictResolutionByCategory( + HoodieWriteConflictException.ConflictCategory.TABLE_SERVICE_VS_TABLE_SERVICE)); + } + + // ----------------------------------------------------------------------- + // Conflict resolution counters and timer + // ----------------------------------------------------------------------- + + @Test + public void testConflictResolutionSuccessAndFailureCounters() { + when(writeConfig.isLockingMetricsEnabled()).thenReturn(true); + + String successName = hoodieMetrics.getMetricsName(HoodieMetrics.CONFLICT_RESOLUTION_STR, HoodieMetrics.SUCCESS_COUNTER); + String failureName = hoodieMetrics.getMetricsName(HoodieMetrics.CONFLICT_RESOLUTION_STR, HoodieMetrics.FAILURE_COUNTER); + + hoodieMetrics.emitConflictResolutionSuccessful(); + hoodieMetrics.emitConflictResolutionSuccessful(); + assertEquals(2, metrics.getRegistry().getCounters().get(successName).getCount()); + + hoodieMetrics.emitConflictResolutionFailed(); + assertEquals(1, metrics.getRegistry().getCounters().get(failureName).getCount()); + } + + @Test + public void testConflictResolutionTimerCtx() throws InterruptedException { + when(writeConfig.isLockingMetricsEnabled()).thenReturn(true); + + Timer.Context ctx = hoodieMetrics.getConflictResolutionCtx(); + assertNotNull(ctx); + Thread.sleep(5); + assertTrue(hoodieMetrics.getDurationInMs(ctx.stop()) > 0); + } + + // ----------------------------------------------------------------------- + // Compaction counters + // ----------------------------------------------------------------------- + + @Test + public void testCompactionCounters() { + String requestedName = hoodieMetrics.getMetricsName( + HoodieTimeline.COMPACTION_ACTION, + HoodieTimeline.REQUESTED_COMPACTION_SUFFIX + HoodieMetrics.COUNTER_METRIC_EXTENSION); + String completedName = hoodieMetrics.getMetricsName( + HoodieTimeline.COMPACTION_ACTION, + HoodieTimeline.COMPLETED_COMPACTION_SUFFIX + HoodieMetrics.COUNTER_METRIC_EXTENSION); + + hoodieMetrics.emitCompactionRequested(); + hoodieMetrics.emitCompactionRequested(); + assertEquals(2, metrics.getRegistry().getCounters().get(requestedName).getCount()); + + hoodieMetrics.emitCompactionCompleted(); + assertEquals(1, metrics.getRegistry().getCounters().get(completedName).getCount()); + } + + // ----------------------------------------------------------------------- + // Archive metrics + // ----------------------------------------------------------------------- + + @Test + public void testArchiveTimerAndMetrics() throws InterruptedException { + Timer.Context ctx = hoodieMetrics.getArchiveCtx(); + assertNotNull(ctx); + Thread.sleep(5); + int numInstantsArchived = 7; + hoodieMetrics.updateArchiveMetrics(hoodieMetrics.getDurationInMs(ctx.stop()), numInstantsArchived); + + String durationName = hoodieMetrics.getMetricsName(HoodieMetrics.ARCHIVE_ACTION, HoodieMetrics.DURATION_STR); + String countName = hoodieMetrics.getMetricsName(HoodieMetrics.ARCHIVE_ACTION, HoodieMetrics.DELETE_INSTANTS_NUM_STR); + assertTrue((Long) metrics.getRegistry().getGauges().get(durationName).getValue() > 0); + assertEquals(numInstantsArchived, (long) metrics.getRegistry().getGauges().get(countName).getValue()); + } + + @Test + public void testUpdateArchivalMetrics() { + Map archivalMetrics = new HashMap<>(); + archivalMetrics.put("numFilesArchived", 10L); + archivalMetrics.put("archiveDurationMs", 250L); + + hoodieMetrics.updateArchivalMetrics(archivalMetrics); + + assertEquals(10L, (long) metrics.getRegistry().getGauges().get( + hoodieMetrics.getMetricsName("archival", "numFilesArchived")).getValue()); + assertEquals(250L, (long) metrics.getRegistry().getGauges().get( + hoodieMetrics.getMetricsName("archival", "archiveDurationMs")).getValue()); + } + + // ----------------------------------------------------------------------- + // Post-commit metrics + // ----------------------------------------------------------------------- + + @Test + public void testPostCommitMetrics() { + String successName = hoodieMetrics.getMetricsName(HoodieMetrics.POST_COMMIT_STR, HoodieMetrics.SUCCESS_COUNTER); + String failureName = hoodieMetrics.getMetricsName(HoodieMetrics.POST_COMMIT_STR, HoodieMetrics.FAILURE_COUNTER); + String durationName = hoodieMetrics.getMetricsName(HoodieMetrics.POST_COMMIT_STR, HoodieMetrics.DURATION_STR); + + hoodieMetrics.updatePostCommitMetrics(true, 100L); + hoodieMetrics.updatePostCommitMetrics(true, 200L); + hoodieMetrics.updatePostCommitMetrics(false, 50L); + + assertEquals(2, metrics.getRegistry().getCounters().get(successName).getCount()); + assertEquals(1, metrics.getRegistry().getCounters().get(failureName).getCount()); + assertEquals(50L, (long) metrics.getRegistry().getGauges().get(durationName).getValue()); + } + + // ----------------------------------------------------------------------- + // reportMetrics / updateClusteringFileCreationMetrics + // ----------------------------------------------------------------------- + + @Test + public void testReportMetrics() { + hoodieMetrics.reportMetrics("commit", "customMetric", 42L); + String metricName = hoodieMetrics.getMetricsName("commit", "customMetric"); + assertEquals(42L, (long) metrics.getRegistry().getGauges().get(metricName).getValue()); + } + + @Test + public void testUpdateClusteringFileCreationMetrics() { + hoodieMetrics.updateClusteringFileCreationMetrics(150L); + String metricName = hoodieMetrics.getMetricsName(HoodieTimeline.CLUSTERING_ACTION, "fileCreationTime"); + assertEquals(150L, (long) metrics.getRegistry().getGauges().get(metricName).getValue()); + } + + // ----------------------------------------------------------------------- + // Log-compaction and clustering timer contexts + // ----------------------------------------------------------------------- + + @Test + public void testLogCompactionTimerCtx() throws InterruptedException { + Timer.Context ctx = hoodieMetrics.getLogCompactionCtx(); + assertNotNull(ctx); + Thread.sleep(5); + assertTrue(hoodieMetrics.getDurationInMs(ctx.stop()) > 0); + } + + @Test + public void testClusteringTimerCtx() throws InterruptedException { + Timer.Context ctx = hoodieMetrics.getClusteringCtx(); + assertNotNull(ctx); + Thread.sleep(5); + assertTrue(hoodieMetrics.getDurationInMs(ctx.stop()) > 0); + } + + @Test + public void testCleanTimerCtx() throws InterruptedException { + Timer.Context ctx = hoodieMetrics.getCleanCtx(); + assertNotNull(ctx); + Thread.sleep(5); + assertTrue(hoodieMetrics.getDurationInMs(ctx.stop()) > 0); + } + + // ----------------------------------------------------------------------- + // Version metrics + // ----------------------------------------------------------------------- + + @Test + public void testVersionMetrics() { + hoodieMetrics.emitVersionMetrics(); + assertTrue(metrics.getRegistry().getGauges().keySet().stream() + .anyMatch(name -> name.startsWith("version."))); + } + + // ----------------------------------------------------------------------- + // Commit metrics with event time (latency / freshness) + // ----------------------------------------------------------------------- + + @Test + public void testCommitMetricsWithEventTime() { + long commitEpochTimeMs = System.currentTimeMillis(); + long durationMs = 1000L; + long minEventTimeMs = commitEpochTimeMs - 5000L; + long maxEventTimeMs = commitEpochTimeMs - 2000L; + + HoodieCommitMetadata metadata = mock(HoodieCommitMetadata.class); + when(metadata.fetchTotalPartitionsWritten()).thenReturn(1L); + when(metadata.fetchTotalFilesInsert()).thenReturn(0L); + when(metadata.fetchTotalFilesUpdated()).thenReturn(0L); + when(metadata.fetchTotalRecordsWritten()).thenReturn(10L); + when(metadata.fetchTotalUpdateRecordsWritten()).thenReturn(5L); + when(metadata.fetchTotalInsertRecordsWritten()).thenReturn(5L); + when(metadata.fetchTotalBytesWritten()).thenReturn(1024L); + when(metadata.getTotalScanTime()).thenReturn(0L); + when(metadata.getTotalCreateTime()).thenReturn(0L); + when(metadata.getTotalUpsertTime()).thenReturn(0L); + when(metadata.getTotalCompactedRecordsUpdated()).thenReturn(0L); + when(metadata.getTotalLogFilesCompacted()).thenReturn(0L); + when(metadata.getTotalLogFilesSize()).thenReturn(0L); + when(metadata.getTotalRecordsDeleted()).thenReturn(0L); + when(metadata.getMinAndMaxEventTime()).thenReturn(Pair.of(Option.of(minEventTimeMs), Option.of(maxEventTimeMs))); + when(writeConfig.isCompactionLogBlockMetricsOn()).thenReturn(false); + + hoodieMetrics.updateCommitMetrics(commitEpochTimeMs, durationMs, metadata, "commit"); + + long expectedLatency = commitEpochTimeMs + durationMs - minEventTimeMs; + long expectedFreshness = commitEpochTimeMs + durationMs - maxEventTimeMs; + assertEquals(expectedLatency, (long) metrics.getRegistry().getGauges().get( + hoodieMetrics.getMetricsName("commit", HoodieMetrics.COMMIT_LATENCY_IN_MS_STR)).getValue()); + assertEquals(expectedFreshness, (long) metrics.getRegistry().getGauges().get( + hoodieMetrics.getMetricsName("commit", HoodieMetrics.COMMIT_FRESHNESS_IN_MS_STR)).getValue()); + } + + // ----------------------------------------------------------------------- + // Rollback failure with null exception type + // ----------------------------------------------------------------------- + + @Test + public void testEmitRollbackFailureWithNullExceptionType() { + hoodieMetrics.emitRollbackFailure(null); + + String failureName = hoodieMetrics.getMetricsName("rollback", FAILURE_COUNTER); + assertEquals(1, metrics.getRegistry().getCounters().get(failureName).getCount()); + // No per-exception counter should be registered + long exceptionCounters = metrics.getRegistry().getCounters().keySet().stream() + .filter(n -> n.startsWith(hoodieMetrics.getMetricsName("rollback", "")) && !n.equals(failureName)) + .count(); + assertEquals(0, exceptionCounters); + } + + // ----------------------------------------------------------------------- + // getMetricsName prefix handling + // ----------------------------------------------------------------------- + + @Test + public void testGetMetricsNameWithPrefix() { + when(writeConfig.getMetricReporterMetricsNamePrefix()).thenReturn("my_prefix"); + assertEquals("my_prefix.action.metric", hoodieMetrics.getMetricsName("action", "metric")); + } + + @Test + public void testGetMetricsNameWithoutPrefix() { + when(writeConfig.getMetricReporterMetricsNamePrefix()).thenReturn(""); + assertEquals("action.metric", hoodieMetrics.getMetricsName("action", "metric")); + } + + // ----------------------------------------------------------------------- + // Existing rollback-failure and conflict-resolution-by-category tests + // ----------------------------------------------------------------------- + @Test public void testRollbackFailureMetric() { // Test that rollback failure metric is emitted correctly diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/cluster/strategy/TestPartitionAwareClusteringPlanStrategy.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/cluster/strategy/TestPartitionAwareClusteringPlanStrategy.java index 471245e3bc513..23a0b6223ca2f 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/cluster/strategy/TestPartitionAwareClusteringPlanStrategy.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/cluster/strategy/TestPartitionAwareClusteringPlanStrategy.java @@ -31,6 +31,8 @@ import org.mockito.MockitoAnnotations; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Properties; @@ -40,6 +42,8 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class TestPartitionAwareClusteringPlanStrategy { @@ -83,6 +87,29 @@ public void testFilterPartitionPaths() { assertTrue(list.contains("20210723")); } + @Test + public void testResolveMissingPartitionsFromCurrentWindow() { + HoodieWriteConfig incrementalConfig = mock(HoodieWriteConfig.class); + when(incrementalConfig.isIncrementalTableServiceEnabled()).thenReturn(true); + DummyPartitionAwareClusteringPlanStrategy incrementalStrategy = + new DummyPartitionAwareClusteringPlanStrategy(table, context, incrementalConfig); + + assertEquals(Arrays.asList("p2", "p4"), + incrementalStrategy.resolveMissingPartitionsFromCurrentWindow( + Arrays.asList("p1", "p3"), Arrays.asList("p1", "p2", "p3", "p4"))); + + HoodieWriteConfig nonIncrementalConfig = mock(HoodieWriteConfig.class); + when(nonIncrementalConfig.isIncrementalTableServiceEnabled()).thenReturn(false); + DummyPartitionAwareClusteringPlanStrategy nonIncrementalStrategy = + new DummyPartitionAwareClusteringPlanStrategy(table, context, nonIncrementalConfig); + + List nonIncrementalMissingPartitions = nonIncrementalStrategy.resolveMissingPartitionsFromCurrentWindow( + Arrays.asList("p1", "p3"), Arrays.asList("p1", "p2", "p3", "p4")); + assertTrue(nonIncrementalMissingPartitions.isEmpty()); + nonIncrementalMissingPartitions.addAll(Collections.singletonList("p5")); + assertEquals(Collections.singletonList("p5"), nonIncrementalMissingPartitions); + } + @Test public void testResolveEngineContextUsesLocalWhenEnabled() { HoodieEngineContext engineContext = new HoodieLocalEngineContext(new HadoopStorageConfiguration(false)); @@ -127,10 +154,15 @@ public DummyPartitionAwareClusteringPlanStrategy(HoodieTable table, HoodieEngine super(table, engineContext, writeConfig); } + List resolveMissingPartitionsFromCurrentWindow(List partitionsToSchedule, + List partitionsInCurrentWindow) { + return getMissingPartitionsFromCurrentWindow(partitionsToSchedule, partitionsInCurrentWindow); + } + @Override protected Map getStrategyParams() { return null; } } -} \ No newline at end of file +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/commit/TestBucketTypeAndInfo.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/commit/TestBucketTypeAndInfo.java new file mode 100644 index 0000000000000..537250c00e61c --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/commit/TestBucketTypeAndInfo.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.hudi.table.action.commit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link BucketType} and the {@link BucketInfo} value holder that carries it. + */ +public class TestBucketTypeAndInfo { + + @Test + void bucketTypeHasUpdateAndInsert() { + assertEquals(2, BucketType.values().length); + assertSame(BucketType.UPDATE, BucketType.valueOf("UPDATE")); + assertSame(BucketType.INSERT, BucketType.valueOf("INSERT")); + } + + @Test + void bucketInfoGettersReturnConstructorArgs() { + BucketInfo info = new BucketInfo(BucketType.INSERT, "fileId-1", "2024/01/01"); + assertSame(BucketType.INSERT, info.getBucketType()); + assertEquals("fileId-1", info.getFileIdPrefix()); + assertEquals("2024/01/01", info.getPartitionPath()); + } + + @Test + void bucketInfoEqualsAndHashCodeUseAllFields() { + BucketInfo a = new BucketInfo(BucketType.UPDATE, "f1", "p1"); + BucketInfo same = new BucketInfo(BucketType.UPDATE, "f1", "p1"); + BucketInfo differentType = new BucketInfo(BucketType.INSERT, "f1", "p1"); + BucketInfo differentFile = new BucketInfo(BucketType.UPDATE, "f2", "p1"); + BucketInfo differentPartition = new BucketInfo(BucketType.UPDATE, "f1", "p2"); + + assertEquals(a, same); + assertEquals(a.hashCode(), same.hashCode()); + assertNotEquals(a, differentType); + assertNotEquals(a, differentFile); + assertNotEquals(a, differentPartition); + assertNotEquals(a, null); + assertNotEquals(a, "not a bucket info"); + } + + @Test + void bucketInfoToStringContainsFields() { + String rendered = new BucketInfo(BucketType.INSERT, "fileId-9", "part-9").toString(); + assertTrue(rendered.contains("INSERT")); + assertTrue(rendered.contains("fileId-9")); + assertTrue(rendered.contains("part-9")); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java index 15c2421cd6df1..1f0f801d89b70 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java @@ -31,7 +31,6 @@ import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; -import org.apache.hudi.common.testutils.FileCreateUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.common.util.collection.Triple; @@ -64,6 +63,7 @@ import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -341,7 +341,7 @@ private void assertFailedDeletion(RollbackHelper rollbackHelper, HoodieEngineCon fail("Should not have reached here"); } catch (HoodieException e) { if (!(e.getCause() instanceof HoodieIOException)) { - log.error("Expected HoodieIOException to be thrown, but found " + e.getCause() + ", w/ error msg " + e.getCause().getMessage()); + log.error("Expected HoodieIOException to be thrown, but found {}, w/ error msg {}", e.getCause(), e.getCause().getMessage()); } assertTrue(e.getCause() instanceof HoodieIOException); assertTrue(e.getCause().getMessage().contains("Failing to delete file during rollback execution failed : " + expectedFileToFailOnDeletion)); @@ -681,7 +681,9 @@ void testPreComputeLogVersionsSentinelForMissingFileGroups() throws Exception { String missingKey = RollbackHelperV1.logVersionLookupKey(partition, "fileId-no-logs", baseInstant); assertTrue(result.containsKey(missingKey)); assertEquals(HoodieLogFile.LOGFILE_BASE_VERSION, (int) result.get(missingKey).getLeft()); - assertEquals(HoodieLogFormat.UNKNOWN_WRITE_TOKEN, result.get(missingKey).getRight()); + // Sentinel entries (no real log file) carry a null write token so they cannot be confused + // with a real log file that happens to use UNKNOWN_WRITE_TOKEN. + assertNull(result.get(missingKey).getRight()); } @Test @@ -698,10 +700,15 @@ void testV1MaybeDeleteAndCollectStatsWithMultipleRequestsPerFileGroup() throws I ctx.rollbackRequests, true, 5); validateStateAfterRollback(ctx.rollbackRequests); + // Rollback log files are written with a per-task write token from TaskContextSupplier. + // HoodieLocalEngineContext uses LocalTaskContextSupplier which returns 0/0/0 -> token "0-0-0". + String rollbackWriteToken = FSUtils.makeWriteToken(0, 0, 0); StoragePath rollbackLogPath1 = new StoragePath(new StoragePath(basePath, ctx.partition2), - FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, ctx.logFileId1, 2)); + FSUtils.makeLogFileName(ctx.logFileId1, HoodieLogFile.DELTA_EXTENSION, + ctx.baseInstantTimeOfLogFiles, 2, rollbackWriteToken)); StoragePath rollbackLogPath2 = new StoragePath(new StoragePath(basePath, ctx.partition2), - FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, ctx.logFileId2, ROLLBACK_LOG_VERSION)); + FSUtils.makeLogFileName(ctx.logFileId2, HoodieLogFile.DELTA_EXTENSION, + ctx.baseInstantTimeOfLogFiles, ROLLBACK_LOG_VERSION, rollbackWriteToken)); List> expected = buildExpectedBaseFileStats(ctx); expected.add(Pair.of(ctx.partition2, @@ -733,8 +740,10 @@ void testV1MaybeDeleteAndCollectStatsWithSingleRequestPerFileGroup() throws IOEx ctx.rollbackRequests, true, 5); validateStateAfterRollback(ctx.rollbackRequests); + String rollbackWriteToken = FSUtils.makeWriteToken(0, 0, 0); StoragePath rollbackLogPath = new StoragePath(new StoragePath(basePath, ctx.partition), - FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, ctx.logFileId, ROLLBACK_LOG_VERSION)); + FSUtils.makeLogFileName(ctx.logFileId, HoodieLogFile.DELTA_EXTENSION, + ctx.baseInstantTimeOfLogFiles, ROLLBACK_LOG_VERSION, rollbackWriteToken)); List> expected = new ArrayList<>(); expected.add(Pair.of(ctx.partition, @@ -797,8 +806,12 @@ void testV1MaybeDeleteAndCollectStatsDoDeleteFalseForLogBlocks() throws IOExcept assertTrue(storage.exists(new StoragePath(partitionStoragePath, logFileName))); } + // doDelete=false: no rollback log file is created. The reported path is the existing latest + // log file (the WriterBuilder rediscovers the existing log when we don't explicitly bump the + // version), so it carries the existing log file's write token. StoragePath rollbackLogPath = new StoragePath(partitionStoragePath, - FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, ctx.logFileId, ctx.logVersionCount)); + FSUtils.makeLogFileName(ctx.logFileId, HoodieLogFile.DELTA_EXTENSION, + ctx.baseInstantTimeOfLogFiles, ctx.logVersionCount, HoodieLogFormat.UNKNOWN_WRITE_TOKEN)); List> expected = Collections.singletonList( Pair.of(ctx.partition, HoodieRollbackStat.newBuilder() diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestKeepByTimeStrategy.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestKeepByTimeStrategy.java new file mode 100644 index 0000000000000..309eab2d6013f --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestKeepByTimeStrategy.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.hudi.table.action.ttl.strategy; + +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.table.HoodieTable; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link KeepByTimeStrategy}. + */ +public class TestKeepByTimeStrategy { + + /** + * Regression test: when there are no candidate partitions to evaluate, + * the strategy must short-circuit and return an empty result instead of + * handing a parallelism of 0 to the engine, which would surface as: + * java.lang.IllegalArgumentException: Positive number of partitions required + * from ParallelCollectionRDD.slice on the Spark path. + */ + @Test + public void testGetExpiredPartitionsForTimeStrategy_emptyInput_returnsEmptyWithoutTouchingEngine() { + HoodieTable hoodieTable = mock(HoodieTable.class); + HoodieWriteConfig writeConfig = mock(HoodieWriteConfig.class); + when(hoodieTable.getConfig()).thenReturn(writeConfig); + when(writeConfig.getPartitionTTLStrategyDaysRetain()).thenReturn(10); + + KeepByTimeStrategy strategy = new KeepByTimeStrategy(hoodieTable, "20240101000000000"); + + List expired = strategy.getExpiredPartitionsForTimeStrategy(Collections.emptyList()); + + assertTrue(expired.isEmpty(), "Empty candidate list should yield no expired partitions"); + // Crucial: we must never reach the engine map call with parallelism=0. + verify(hoodieTable, never()).getContext(); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategy.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategy.java new file mode 100644 index 0000000000000..4cad31289ec40 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategy.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.hudi.table.action.ttl.strategy; + +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.table.HoodieTable; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestPartitionTTLStrategy { + + private static final long MILLIS_PER_DAY = 24L * 3600L * 1000L; + + private KeepByTimeStrategy newStrategy(int daysRetain) { + HoodieTable hoodieTable = mock(HoodieTable.class); + HoodieWriteConfig writeConfig = mock(HoodieWriteConfig.class); + when(hoodieTable.getConfig()).thenReturn(writeConfig); + when(writeConfig.getPartitionTTLStrategyDaysRetain()).thenReturn(daysRetain); + return new KeepByTimeStrategy(hoodieTable, ""); + } + + /** + * Verify that ttlInMilis is computed in long arithmetic, so values that would + * overflow a 32-bit int multiplication (daysRetain > 24) still yield the + * correct positive millisecond value. + * + *

Without the {@code (long)} cast on {@code getPartitionTTLStrategyDaysRetain()}, + * the expression {@code daysRetain * 1000 * 3600 * 24} is evaluated as int and + * overflows once daysRetain * 86_400_000 exceeds Integer.MAX_VALUE + * (i.e., for any daysRetain >= 25). + */ + @Test + public void testKeepByTimeStrategyTTLInMilis() { + // Small value: no overflow either way, sanity check. + assertEquals(1L * MILLIS_PER_DAY, newStrategy(1).ttlInMilis); + + // Largest value that does NOT overflow int: 24 * 86_400_000 = 2_073_600_000. + assertEquals(24L * MILLIS_PER_DAY, newStrategy(24).ttlInMilis); + + // 25 days: 25 * 86_400_000 = 2_160_000_000, exceeds Integer.MAX_VALUE. + // With the (long) cast the result is correct; without it the int expression + // would overflow to a negative value. + int days = 25; + long expected = (long) days * MILLIS_PER_DAY; + assertEquals(expected, newStrategy(days).ttlInMilis); + assertTrue(newStrategy(days).ttlInMilis > 0, + "ttlInMilis must stay positive for daysRetain beyond the int-overflow boundary"); + // Guard against regression: the unfixed int expression would produce this + // (overflowed) value. The fixed code must NOT match it. + int overflowed = days * 1000 * 3600 * 24; + assertEquals(-2_134_967_296, overflowed, "sanity: confirm the int expression overflows for 25 days"); + assertTrue(newStrategy(days).ttlInMilis != overflowed, + "ttlInMilis must not equal the int-overflowed value"); + + // A clearly large value (e.g., 365 days) to ensure long arithmetic holds. + assertEquals(365L * MILLIS_PER_DAY, newStrategy(365).ttlInMilis); + + // Zero / disabled. + assertEquals(0L, newStrategy(0).ttlInMilis); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategyType.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategyType.java new file mode 100644 index 0000000000000..276273cb8953e --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/ttl/strategy/TestPartitionTTLStrategyType.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.hudi.table.action.ttl.strategy; + +import org.apache.hudi.common.config.HoodieConfig; +import org.apache.hudi.config.HoodieTTLConfig; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link PartitionTTLStrategyType}. + */ +public class TestPartitionTTLStrategyType { + + @Test + public void resolvesKeepByTimeFromType() { + HoodieConfig config = new HoodieConfig(); + config.setValue(HoodieTTLConfig.PARTITION_TTL_STRATEGY_TYPE, + PartitionTTLStrategyType.KEEP_BY_TIME.name()); + + assertEquals(PartitionTTLStrategyType.KEEP_BY_TIME.getClassName(), + PartitionTTLStrategyType.getPartitionTTLStrategyClassName(config)); + } + + @Test + public void resolvesKeepByCreationTimeFromType() { + HoodieConfig config = new HoodieConfig(); + config.setValue(HoodieTTLConfig.PARTITION_TTL_STRATEGY_TYPE, + PartitionTTLStrategyType.KEEP_BY_CREATION_TIME.name()); + + assertEquals(PartitionTTLStrategyType.KEEP_BY_CREATION_TIME.getClassName(), + PartitionTTLStrategyType.getPartitionTTLStrategyClassName(config)); + } + + @Test + public void throwsOnUnknownType() { + HoodieConfig config = new HoodieConfig(); + config.setValue(HoodieTTLConfig.PARTITION_TTL_STRATEGY_TYPE, "NOT_A_REAL_TYPE"); + + assertThrows(IllegalArgumentException.class, + () -> PartitionTTLStrategyType.getPartitionTTLStrategyClassName(config)); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/marker/TestMarkerBasedRollbackUtils.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/marker/TestMarkerBasedRollbackUtils.java new file mode 100644 index 0000000000000..cc964e4c65d3a --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/marker/TestMarkerBasedRollbackUtils.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.hudi.table.marker; + +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.table.HoodieTable; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; + +import static org.apache.hudi.common.util.MarkerUtils.MARKER_TYPE_FILENAME; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarkerBasedRollbackUtils}. + * + *

These tests target the {@code MARKERS.type}-absent code path in + * {@link MarkerBasedRollbackUtils#getAllMarkerPaths}. That branch attempts to list DIRECT + * markers first and used to catch {@code IOException | IllegalArgumentException} and fall + * back to TIMELINE_SERVER_BASED. A transient HDFS failure would therefore be swallowed and + * the rollback would return zero marker paths, leaving orphan data files on the table. + * + *

The tests mock the {@link HoodieStorage} seams the production code actually goes + * through: + *

    + *
  • {@code readMarkerType(...)} -> {@code storage.exists(MARKERS.type)} = false + *
  • {@code DirectWriteMarkers.doesMarkerDirExist()} -> {@code storage.exists(markerDir)} = true + *
  • {@code FSUtils.processFiles} -> {@code storage.listDirectEntries(markerDir)} throws + *
+ */ +public class TestMarkerBasedRollbackUtils { + + private static final String INSTANT = "20260101000000"; + private static final String BASE_PATH = "/tmp/test-table"; + private static final String MARKER_DIR = BASE_PATH + "/.hoodie/.temp/" + INSTANT; + + private HoodieTable mockTable; + private HoodieTableMetaClient mockMetaClient; + private HoodieEngineContext mockContext; + private HoodieStorage mockStorage; + + @BeforeEach + public void setUp() throws IOException { + mockTable = mock(HoodieTable.class); + mockMetaClient = mock(HoodieTableMetaClient.class); + mockContext = mock(HoodieEngineContext.class); + mockStorage = mock(HoodieStorage.class); + HoodieTableConfig mockTableConfig = mock(HoodieTableConfig.class); + + when(mockTable.getMetaClient()).thenReturn(mockMetaClient); + when(mockTable.getContext()).thenReturn(mockContext); + when(mockTable.getStorage()).thenReturn(mockStorage); + when(mockMetaClient.getBasePath()).thenReturn(new StoragePath(BASE_PATH)); + when(mockMetaClient.getMarkerFolderPath(INSTANT)).thenReturn(MARKER_DIR); + when(mockMetaClient.getTableConfig()).thenReturn(mockTableConfig); + // Table version 8+ selects DirectWriteMarkers in WriteMarkersFactory. + when(mockTableConfig.getTableVersion()).thenReturn(HoodieTableVersion.EIGHT); + + StoragePath markerDirPath = new StoragePath(MARKER_DIR); + StoragePath markerTypeFilePath = new StoragePath(markerDirPath, MARKER_TYPE_FILENAME); + // MARKERS.type is absent, this drives execution into the fallback branch under test. + when(mockStorage.exists(markerTypeFilePath)).thenReturn(false); + // The marker directory itself exists so DirectWriteMarkers.allMarkerFilePaths() + // proceeds to list entries (rather than early-returning an empty set). + when(mockStorage.exists(markerDirPath)).thenReturn(true); + } + + /** + * A transient IO failure while listing DIRECT markers must propagate as IOException + * rather than silently falling back to TIMELINE_SERVER_BASED. Falling back would return + * zero marker paths (timeline server uses a different location) and cause rollback to + * leave orphan data files on the table. + */ + @Test + public void testGetAllMarkerPathsPropagatesIOExceptionOnTransientListingFailure() throws IOException { + when(mockStorage.listDirectEntries(new StoragePath(MARKER_DIR))) + .thenThrow(new IOException("Server too busy - disconnecting")); + + IOException thrown = assertThrows(IOException.class, + () -> MarkerBasedRollbackUtils.getAllMarkerPaths(mockTable, mockContext, INSTANT, 1)); + // Verify the original transient error surfaces to the caller (not a wrapped fallback error). + assertEquals("Server too busy - disconnecting", thrown.getMessage()); + } + + /** + * IllegalArgumentException (e.g., marker path format mismatch) must retain the original + * fallback behavior, read markers via TIMELINE_SERVER_BASED path instead of failing. + * + *

Both DirectWriteMarkers and the timeline-server-based reader end up calling + * {@code storage.listDirectEntries(markerDir)}. We stub the first invocation to throw + * (triggering the fallback under test) and subsequent invocations to return an empty + * list so the fallback path completes cleanly. + */ + @Test + public void testGetAllMarkerPathsFallsBackToTimelineServerOnIllegalArgumentException() throws IOException { + when(mockStorage.listDirectEntries(new StoragePath(MARKER_DIR))) + .thenThrow(new IllegalArgumentException("bad marker path")) + .thenReturn(Collections.emptyList()); + + // No exception should surface, the IllegalArgumentException must be handled by the + // fallback rather than propagating to the caller. + assertDoesNotThrow( + () -> MarkerBasedRollbackUtils.getAllMarkerPaths(mockTable, mockContext, INSTANT, 1)); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestSevenToEightUpgradeHandler.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestSevenToEightUpgradeHandler.java index 66d03811b76fc..400f37ba768cc 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestSevenToEightUpgradeHandler.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestSevenToEightUpgradeHandler.java @@ -19,14 +19,22 @@ package org.apache.hudi.table.upgrade; +import org.apache.hudi.client.timeline.versioning.v2.LSMTimelineWriter; +import org.apache.hudi.client.utils.LegacyArchivedMetaEntryReader; import org.apache.hudi.common.bootstrap.index.hfile.HFileBootstrapIndex; import org.apache.hudi.common.config.ConfigProperty; import org.apache.hudi.common.config.RecordMergeMode; import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.ActiveAction; +import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.table.HoodieTable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,10 +42,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.apache.hudi.common.table.HoodieTableConfig.BOOTSTRAP_INDEX_CLASS_NAME; @@ -52,8 +64,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.isA; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -153,6 +172,61 @@ void testUpgradeMergeMode(String payloadClass, String preCombineField, String ex } } + @Test + void testUpgradeToLSMTimelineSingleBatch() throws Exception { + // A single batch large enough to hold all actions should result in exactly one write() call, + // proving the migration batch size config (not the regular archival batch size) drives batching. + LSMTimelineWriter writer = runMigration(500, 4); + verify(writer, times(1)).write(any(), any(), any()); + verify(writer, never()).compactAndClean(any()); + } + + @Test + void testUpgradeToLSMTimelineBatchesByMigrationBatchSize() throws Exception { + // With more actions than the migration batch size, the in-loop batching branch must fire: + // 4 actions with a batch size of 2 -> [2, 2] -> 2 write() calls. This pins that the configured + // migration batch size (not just "all actions in one batch") actually governs batching. + LSMTimelineWriter writer = runMigration(2, 4); + verify(writer, times(2)).write(any(), any(), any()); + verify(writer, never()).compactAndClean(any()); + } + + /** + * Runs {@link SevenToEightUpgradeHandler#upgradeToLSMTimeline} with the given migration batch size + * over the given number of archived actions, and returns the (mocked) LSM timeline writer for verification. + */ + private LSMTimelineWriter runMigration(int migrationBatchSize, int totalActions) { + HoodieTable table = mock(HoodieTable.class); + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + + when(table.getMetaClient()).thenReturn(metaClient); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(tableConfig.getTimelineLayoutVersion()).thenReturn(Option.of(TimelineLayoutVersion.LAYOUT_VERSION_1)); + when(metaClient.getMetaPath()).thenReturn(new StoragePath("/tmp/.hoodie")); + when(config.getMigrationCommitArchivalBatchSize()).thenReturn(migrationBatchSize); + // The regular archival batch size must not be consulted during migration. + lenient().when(config.getCommitArchivalBatchSize()).thenReturn(1); + + List actions = new ArrayList<>(); + for (int i = 0; i < totalActions; i++) { + actions.add(mock(ActiveAction.class)); + } + + LSMTimelineWriter writer = mock(LSMTimelineWriter.class); + try (MockedStatic mockedWriterStatic = mockStatic(LSMTimelineWriter.class); + MockedConstruction mockedReader = Mockito.mockConstruction( + LegacyArchivedMetaEntryReader.class, + (readerMock, ctx) -> when(readerMock.getActiveActionsIterator()) + .thenReturn(ClosableIterator.wrap(actions.iterator())))) { + mockedWriterStatic.when(() -> LSMTimelineWriter.getInstance( + any(HoodieWriteConfig.class), any(HoodieTable.class), any(Option.class))).thenReturn(writer); + + SevenToEightUpgradeHandler.upgradeToLSMTimeline(table, config); + } + return writer; + } + private static Map createMap(Object... keyValues) { Map map = new HashMap<>(); for (int i = 0; i < keyValues.length; i += 2) { diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java index 71a7d423ced67..6a922c690b56e 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java @@ -33,6 +33,7 @@ import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.testutils.FileCreateUtilsLegacy; @@ -110,7 +111,7 @@ public StoragePath withInserts(String partition, String fileId, List> withLogAppends(String partition, String } private Pair appendRecordsToLogFile(String partitionPath, String fileId, List records) throws Exception { - try (HoodieLogFormat.Writer logWriter = HoodieLogFormat.newWriterBuilder() - .onParentPath(new StoragePath(basePath, partitionPath)) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(fileId) - .withInstantTime(currentInstantTime).withStorage(storage).build()) { + try (HoodieLogFormat.Writer logWriter = HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(basePath, partitionPath)) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(fileId) + .withInstantTime(currentInstantTime) + .withStorage(storage) + .build()) { Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, currentInstantTime); header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString()); diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/RecordingCommitCallback.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/RecordingCommitCallback.java new file mode 100644 index 0000000000000..0d5944fcfdc5b --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/RecordingCommitCallback.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.hudi.testutils; + +import org.apache.hudi.callback.HoodieWriteCommitCallback; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A recording {@link HoodieWriteCommitCallback} that captures every fired message so tests can + * assert the callback fires for table-service (compaction/clustering) commits with the expected + * action type. Loaded reflectively from the write config, so it needs a public + * {@code (HoodieWriteConfig)} constructor, and the messages have to live in static state; call + * {@link #reset()} at the start of every test that asserts on them. + */ +public class RecordingCommitCallback implements HoodieWriteCommitCallback { + + private static final List MESSAGES = new CopyOnWriteArrayList<>(); + + public RecordingCommitCallback(HoodieWriteConfig config) { + // config arg required for reflective instantiation + } + + @Override + public void call(HoodieWriteCommitCallbackMessage callbackMessage) { + MESSAGES.add(callbackMessage); + } + + public static List messages() { + return new ArrayList<>(MESSAGES); + } + + public static void reset() { + MESSAGES.clear(); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/util/TestOperationConverter.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/util/TestOperationConverter.java new file mode 100644 index 0000000000000..811bea9152197 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/util/TestOperationConverter.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.hudi.util; + +import org.apache.hudi.client.utils.OperationConverter; +import org.apache.hudi.common.model.WriteOperationType; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests {@link OperationConverter}, the jcommander string-to-enum converter. + */ +public class TestOperationConverter { + + private final OperationConverter converter = new OperationConverter(); + + @Test + void convertsEnumConstantNames() { + assertSame(WriteOperationType.INSERT, converter.convert("INSERT")); + assertSame(WriteOperationType.UPSERT, converter.convert("UPSERT")); + assertSame(WriteOperationType.BULK_INSERT, converter.convert("BULK_INSERT")); + } + + @Test + void rejectsUnknownOperation() { + assertThrows(IllegalArgumentException.class, () -> converter.convert("not_an_operation")); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/utils/TestMetadataConversionUtils.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/utils/TestMetadataConversionUtils.java index 01af9d3fc3ffd..c0374b25e1f10 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/utils/TestMetadataConversionUtils.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/utils/TestMetadataConversionUtils.java @@ -405,14 +405,12 @@ private void createCleanMetadata(String instantTime) throws IOException { HoodieCleanFileInfo fileInfo = new HoodieCleanFileInfo("file1", false); HoodieCleanerPlan cleanerPlan = new HoodieCleanerPlan(new HoodieActionInstant("", "", ""), "", "", new HashMap<>(), CleanPlanV2MigrationHandler.VERSION, Collections.singletonMap("key", Collections.singletonList(fileInfo)), new ArrayList<>(), Collections.EMPTY_MAP); - HoodieCleanStat cleanStats = new HoodieCleanStat( - HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS, - HoodieTestUtils.DEFAULT_PARTITION_PATHS[new Random().nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)], - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList(), - instantTime, - ""); + HoodieCleanStat cleanStats = HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS) + .withPartitionPath(HoodieTestUtils.DEFAULT_PARTITION_PATHS[new Random().nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)]) + .withEarliestCommitToRetain(instantTime) + .withLastCompletedCommitTimestamp("") + .build(); HoodieCleanMetadata cleanMetadata = convertCleanMetadata(instantTime, Option.of(0L), Collections.singletonList(cleanStats), Collections.EMPTY_MAP); HoodieTestTable.of(metaClient).addClean(instantTime, cleanerPlan, cleanMetadata); } diff --git a/hudi-client/hudi-flink-client/pom.xml b/hudi-client/hudi-flink-client/pom.xml index 5ec3dfb9c16c4..4be3da9a3d5f7 100644 --- a/hudi-client/hudi-flink-client/pom.xml +++ b/hudi-client/hudi-flink-client/pom.xml @@ -42,7 +42,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java index f500f99e0635b..33c34b8c00a14 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java @@ -23,6 +23,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.metadata.FlinkHoodieBackedTableMetadataWriter; import org.apache.hudi.metadata.HoodieTableMetadataWriter; import org.apache.hudi.table.HoodieTable; @@ -83,6 +84,27 @@ public void startCommit(String instantTime, HoodieTable table) { metadataWriterOpt.get().startCommit(instantTime); } + /** + * Start only the metadata table heartbeat for an existing streaming write instant. + * + *

This is used by coordinator recommit where the metadata table instant and + * the streaming index files may already exist and must not be rolled back. + * + * @param instantTime The instant time + * @param table The hoodie table + */ + public void startHeartbeat(String instantTime, HoodieTable table) { + Option metadataWriterOpt = getMetadataWriter(instantTime, table); + ValidationUtils.checkState(metadataWriterOpt.isPresent(), + "Should not be reachable. Metadata Writer should have been instantiated by now"); + ValidationUtils.checkState(metadataWriterOpt.get() instanceof FlinkHoodieBackedTableMetadataWriter, + "Flink streaming metadata writes expect a Flink metadata writer"); + FlinkHoodieBackedTableMetadataWriter metadataWriter = (FlinkHoodieBackedTableMetadataWriter) metadataWriterOpt.get(); + if (metadataWriter.getWriteClient().getConfig().getFailedWritesCleanPolicy().isLazy()) { + metadataWriter.getWriteClient().getHeartbeatClient().start(instantTime); + } + } + /** * Clean resources after streaming write to the metadata table in index write function or stop * heartbeat for instant in the coordinator. This method removes the metadata writer associated @@ -93,11 +115,13 @@ public void startCommit(String instantTime, HoodieTable table) { public void cleanResources(String instantTime) { Option metadataWriterOpt = this.metadataWriterMap.remove(instantTime); if (metadataWriterOpt == null || metadataWriterOpt.isEmpty()) { - log.warn("Metadata writer for {} has not been initialized, no need to stop heartbeat.", instantTime); + log.debug("Metadata writer for {} has already been closed, skip closing.", instantTime); return; } - try { - metadataWriterOpt.get().close(); + try (HoodieTableMetadataWriter metadataWriter = metadataWriterOpt.get()) { + if (metadataWriter instanceof FlinkHoodieBackedTableMetadataWriter) { + ((FlinkHoodieBackedTableMetadataWriter) metadataWriter).getWriteClient().getHeartbeatClient().stop(instantTime); + } } catch (Exception e) { throw new HoodieException("Failed to close the metadata writer for instant: " + instantTime, e); } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java index 72ae3a967ab45..579861677a149 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java @@ -99,7 +99,9 @@ protected void completeCompaction(HoodieCommitMetadata metadata, HoodieTable tab + config.getBasePath() + " at time " + compactionCommitTime, e); } } - log.info("Compacted successfully on commit " + compactionCommitTime); + log.info("Compacted successfully on commit {}", compactionCommitTime); + fireCommitCallbackIfNecessary(compactionCommitTime, HoodieActiveTimeline.COMMIT_ACTION, + metadata.getWriteStats(), table::getBaseFileOnlyView, Option.empty()); } finally { if (config.getWriteConcurrencyMode().supportsMultiWriter()) { this.heartbeatClient.stop(compactionCommitTime); @@ -159,7 +161,9 @@ protected void completeClustering( + config.getBasePath() + " at time " + clusteringCommitTime, e); } } - log.info("Clustering successfully on commit " + clusteringCommitTime); + log.info("Clustering successfully on commit {}", clusteringCommitTime); + fireCommitCallbackIfNecessary(clusteringCommitTime, HoodieActiveTimeline.REPLACE_COMMIT_ACTION, + writeStats, table::getBaseFileOnlyView, Option.empty()); } @Override diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java index 228209cf1df6f..df044ed129c6d 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java @@ -35,6 +35,7 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieNotSupportedException; import org.apache.hudi.index.FlinkHoodieIndexFactory; @@ -138,6 +139,20 @@ public void cleanResources(String instantTime) { } } + /** + * Restart the heartbeat for a recommitted instant. + * + * @param instantTime The instant time + */ + public void restartHeartbeat(String instantTime) { + if (getConfig().getFailedWritesCleanPolicy().isLazy()) { + getHeartbeatClient().start(instantTime); + } + if (isStreamingWriteMetadataTable) { + this.streamingMetadataWriteHandler.startHeartbeat(instantTime, getHoodieTable()); + } + } + /** * Performs streaming write operations to metadata partitions. * This method retrieves the metadata writer for the given instant time and table, @@ -323,7 +338,9 @@ public List bulkInsertPreppedRecords(List> preppedR Map>> preppedRecordsByFileId = preppedRecords.stream().parallel() .collect(Collectors.groupingBy(r -> r.getCurrentLocation().getFileId())); return preppedRecordsByFileId.values().stream().parallel().map(records -> { - records.sort(Comparator.comparing(HoodieRecord::getRecordKey)); + // Only used for the metadata table, whose base files are HFiles ordered by raw UTF-8 bytes, + // so sort by UTF-8 bytes rather than String (UTF-16) order for non-ASCII / binary keys. + records.sort(Comparator.comparing(HoodieRecord::getRecordKey, StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)); HoodieWriteMetadata> result; BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, records.get(0).getCurrentLocation().getFileId(), records.get(0).getPartitionPath()); try (AutoCloseableWriteHandle closeableHandle = new AutoCloseableWriteHandle(bucketInfo, records.iterator(), instantTime, table, true)) { @@ -629,4 +646,15 @@ public void close() { ((MiniBatchHandle) writeHandle).closeGracefully(); } } + + /** + * Flink keeps the heartbeat active when a commit attempt fails because the coordinator may need to + * recommit the instant after failover. Successful commits stop the heartbeat from {@code postCommit}, + * while {@link #cleanResources(String)} cleans up data-table and metadata-table resources for + * instants discarded or resolved by the coordinator. Therefore this generic hook is intentionally + * a no-op. + */ + @Override + public void releaseResources(String instantTime) { + } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/AbstractHoodieRowData.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/AbstractHoodieRowData.java index 94b13ea32a8a0..d01bf3e34d766 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/AbstractHoodieRowData.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/AbstractHoodieRowData.java @@ -18,6 +18,7 @@ package org.apache.hudi.client.model; +import org.apache.hudi.adapter.DataTypeAdapter; import org.apache.hudi.common.model.HoodieOperation; import org.apache.hudi.common.util.ValidationUtils; @@ -169,6 +170,6 @@ protected String getMetaColumnVal(int ordinal) { protected abstract int rebaseOrdinal(int ordinal); public Variant getVariant(int i) { - throw new UnsupportedOperationException("Variant is not supported yet."); + return DataTypeAdapter.getVariant(row, rebaseOrdinal(i)); } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/BootstrapRowData.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/BootstrapRowData.java index 92c0f35a42322..23c4c82f96680 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/BootstrapRowData.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/BootstrapRowData.java @@ -18,6 +18,8 @@ package org.apache.hudi.client.model; +import org.apache.hudi.adapter.DataTypeAdapter; + import org.apache.flink.table.data.ArrayData; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.MapData; @@ -149,7 +151,7 @@ private T getValue(int pos, Function getter) { return getter.apply(pos); } - public Variant getVariant(int i) { - throw new UnsupportedOperationException("Variant is not supported yet."); + public Variant getVariant(int pos) { + return DataTypeAdapter.getVariant(row, pos); } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/HoodieFlinkRecord.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/HoodieFlinkRecord.java index b44397abc53cb..74990bc5835d1 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/HoodieFlinkRecord.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/model/HoodieFlinkRecord.java @@ -46,7 +46,6 @@ 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.data.utils.JoinedRowData; import java.io.ByteArrayOutputStream; @@ -170,11 +169,10 @@ public Object convertColumnValueForLogicalType(HoodieSchema fieldSchema, return LocalDate.ofEpochDay(((Integer) fieldValue).longValue()); } else if (schemaType == HoodieSchemaType.TIMESTAMP && keepConsistentLogicalTimestamp) { HoodieSchema.Timestamp timestampSchema = (HoodieSchema.Timestamp) fieldSchema; - TimestampData ts = (TimestampData) fieldValue; if (timestampSchema.getPrecision() == HoodieSchema.TimePrecision.MILLIS) { - return ts.getMillisecond(); + return fieldValue; } else if (timestampSchema.getPrecision() == HoodieSchema.TimePrecision.MICROS) { - return ts.getMillisecond() / 1000; + return ((Long) fieldValue) / 1000; } } else if (schemaType == HoodieSchemaType.DECIMAL) { return ((DecimalData) fieldValue).toBigDecimal(); diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/FlinkCreateHandle.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/FlinkCreateHandle.java index 038c6f8664e64..e788a82dda52a 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/FlinkCreateHandle.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/FlinkCreateHandle.java @@ -90,7 +90,7 @@ private void deleteInvalidDataFile(long lastAttemptId) { final StoragePath path = makeNewFilePath(partitionPath, lastDataFileName); try { if (storage.exists(path)) { - log.info("Deleting invalid INSERT file due to task retry: " + lastDataFileName); + log.info("Deleting invalid INSERT file due to task retry: {}", lastDataFileName); storage.deleteFile(path); } } catch (IOException e) { diff --git a/packaging/hudi-trino-bundle/src/main/java/org/apache/hudi/trino/bundle/Main.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieBloomFilterRowDataWriteSupport.java similarity index 55% rename from packaging/hudi-trino-bundle/src/main/java/org/apache/hudi/trino/bundle/Main.java rename to hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieBloomFilterRowDataWriteSupport.java index eec1ecf88a8d8..36d9e28be3e23 100644 --- a/packaging/hudi-trino-bundle/src/main/java/org/apache/hudi/trino/bundle/Main.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieBloomFilterRowDataWriteSupport.java @@ -16,21 +16,23 @@ * limitations under the License. */ -package org.apache.hudi.trino.bundle; +package org.apache.hudi.io.storage.row; -import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.avro.HoodieBloomFilterWriteSupport; +import org.apache.hudi.common.bloom.BloomFilter; +import org.apache.hudi.common.util.StringUtils; /** - * A simple main class to dump all classes loaded in current classpath - *

- * This is a workaround for generating sources and javadoc jars for packaging modules. The maven plugins for generating - * javadoc and sources plugins do not generate corresponding jars if there are no source files. - *

- * This class does not have anything to do with Hudi but is there to keep mvn javadocs/source plugin happy. + * Bloom-filter footer support for Flink RowData base-file writers. */ -public class Main { +class HoodieBloomFilterRowDataWriteSupport extends HoodieBloomFilterWriteSupport { - public static void main(String[] args) { - ReflectionUtils.getTopLevelClassesInClasspath(Main.class).forEach(System.out::println); + HoodieBloomFilterRowDataWriteSupport(BloomFilter bloomFilter) { + super(bloomFilter); + } + + @Override + protected byte[] getUTF8Bytes(String key) { + return StringUtils.getUTF8Bytes(key); } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieFlinkLanceArrowUtils.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieFlinkLanceArrowUtils.java new file mode 100644 index 0000000000000..07171f615b46a --- /dev/null +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieFlinkLanceArrowUtils.java @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.io.storage.row; + +import org.apache.hudi.exception.HoodieNotSupportedException; + +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.BitVector; +import org.apache.arrow.vector.DateDayVector; +import org.apache.arrow.vector.DecimalVector; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.Float4Vector; +import org.apache.arrow.vector.Float8Vector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.SmallIntVector; +import org.apache.arrow.vector.TimeMilliVector; +import org.apache.arrow.vector.TimeStampMicroVector; +import org.apache.arrow.vector.TinyIntVector; +import org.apache.arrow.vector.ValueVector; +import org.apache.arrow.vector.VarBinaryVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.types.DateUnit; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.TimeUnit; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.flink.table.data.DecimalData; +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.BigIntType; +import org.apache.flink.table.types.logical.BooleanType; +import org.apache.flink.table.types.logical.DateType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.DoubleType; +import org.apache.flink.table.types.logical.FloatType; +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.RowType; +import org.apache.flink.table.types.logical.SmallIntType; +import org.apache.flink.table.types.logical.TimeType; +import org.apache.flink.table.types.logical.TimestampType; +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 java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.apache.flink.table.types.logical.utils.LogicalTypeChecks.getPrecision; + +/** + * Primitive RowData/Arrow conversion helpers for Flink Lance base files. + */ +public final class HoodieFlinkLanceArrowUtils { + + private HoodieFlinkLanceArrowUtils() { + } + + public static Schema toArrowSchema(RowType rowType) { + List fields = new ArrayList<>(rowType.getFieldCount()); + for (RowType.RowField field : rowType.getFields()) { + fields.add(toArrowField(field.getName(), field.getType())); + } + return new Schema(fields); + } + + public static RowType toRowType(Schema schema) { + List fields = new ArrayList<>(schema.getFields().size()); + for (Field field : schema.getFields()) { + fields.add(new RowType.RowField(field.getName(), toLogicalType(field.getType()))); + } + return new RowType(fields); + } + + public static RowData toRowData(RowType rowType, List vectors, int rowId) { + GenericRowData rowData = new GenericRowData(vectors.size()); + for (int i = 0; i < vectors.size(); i++) { + FieldVector vector = vectors.get(i); + if (vector.isNull(rowId)) { + rowData.setField(i, null); + } else { + rowData.setField(i, readValue(rowType.getTypeAt(i), vector, rowId)); + } + } + return rowData; + } + + public static void writeValue(LogicalType type, FieldVector vector, int rowId, RowData rowData, int ordinal) { + writeValue(type, vector, rowId, rowData, ordinal, true); + } + + public static void writeValue(LogicalType type, FieldVector vector, int rowId, RowData rowData, int ordinal, boolean utcTimestamp) { + if (rowData.isNullAt(ordinal)) { + vector.setNull(rowId); + return; + } + switch (type.getTypeRoot()) { + case BOOLEAN: + ((BitVector) vector).setSafe(rowId, rowData.getBoolean(ordinal) ? 1 : 0); + return; + case TINYINT: + ((TinyIntVector) vector).setSafe(rowId, rowData.getByte(ordinal)); + return; + case SMALLINT: + ((SmallIntVector) vector).setSafe(rowId, rowData.getShort(ordinal)); + return; + case INTEGER: + ((IntVector) vector).setSafe(rowId, rowData.getInt(ordinal)); + return; + case DATE: + ((DateDayVector) vector).setSafe(rowId, rowData.getInt(ordinal)); + return; + case TIME_WITHOUT_TIME_ZONE: + ((TimeMilliVector) vector).setSafe(rowId, rowData.getInt(ordinal)); + return; + case BIGINT: + ((BigIntVector) vector).setSafe(rowId, rowData.getLong(ordinal)); + return; + case FLOAT: + ((Float4Vector) vector).setSafe(rowId, rowData.getFloat(ordinal)); + return; + case DOUBLE: + ((Float8Vector) vector).setSafe(rowId, rowData.getDouble(ordinal)); + return; + case CHAR: + case VARCHAR: + ((VarCharVector) vector).setSafe(rowId, rowData.getString(ordinal).toBytes()); + return; + case BINARY: + case VARBINARY: + ((VarBinaryVector) vector).setSafe(rowId, rowData.getBinary(ordinal)); + return; + case DECIMAL: + DecimalType decimalType = (DecimalType) type; + DecimalData decimal = rowData.getDecimal(ordinal, decimalType.getPrecision(), decimalType.getScale()); + ((DecimalVector) vector).setSafe(rowId, decimal.toBigDecimal()); + return; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + TimestampData timestamp = rowData.getTimestamp(ordinal, getPrecision(type)); + long micros = timestampToMicros(timestamp, getPrecision(type), utcTimestamp); + ((TimeStampMicroVector) vector).setSafe(rowId, micros); + return; + default: + throw unsupported(type); + } + } + + private static Object readValue(LogicalType type, ValueVector vector, int rowId) { + switch (type.getTypeRoot()) { + case BOOLEAN: + return ((BitVector) vector).get(rowId) == 1; + case TINYINT: + return ((TinyIntVector) vector).get(rowId); + case SMALLINT: + return ((SmallIntVector) vector).get(rowId); + case INTEGER: + return ((IntVector) vector).get(rowId); + case DATE: + return ((DateDayVector) vector).get(rowId); + case TIME_WITHOUT_TIME_ZONE: + return ((TimeMilliVector) vector).get(rowId); + case BIGINT: + return ((BigIntVector) vector).get(rowId); + case FLOAT: + return ((Float4Vector) vector).get(rowId); + case DOUBLE: + return ((Float8Vector) vector).get(rowId); + case CHAR: + case VARCHAR: + return StringData.fromBytes(((VarCharVector) vector).get(rowId)); + case BINARY: + case VARBINARY: + return ((VarBinaryVector) vector).get(rowId); + case DECIMAL: + DecimalType decimalType = (DecimalType) type; + BigDecimal decimal = ((DecimalVector) vector).getObject(rowId); + return DecimalData.fromBigDecimal(decimal, decimalType.getPrecision(), decimalType.getScale()); + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + long micros = ((TimeStampMicroVector) vector).get(rowId); + return TimestampData.fromEpochMillis(Math.floorDiv(micros, 1000L), (int) Math.floorMod(micros, 1000L) * 1000); + default: + throw unsupported(type); + } + } + + private static Field toArrowField(String name, LogicalType type) { + return new Field(name, FieldType.nullable(toArrowType(type)), Collections.emptyList()); + } + + private static ArrowType toArrowType(LogicalType type) { + switch (type.getTypeRoot()) { + case BOOLEAN: + return ArrowType.Bool.INSTANCE; + case TINYINT: + return new ArrowType.Int(8, true); + case SMALLINT: + return new ArrowType.Int(16, true); + case INTEGER: + return new ArrowType.Int(32, true); + case BIGINT: + return new ArrowType.Int(64, true); + case FLOAT: + return new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE); + case DOUBLE: + return new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE); + case CHAR: + case VARCHAR: + return ArrowType.Utf8.INSTANCE; + case BINARY: + case VARBINARY: + return ArrowType.Binary.INSTANCE; + case DATE: + return new ArrowType.Date(DateUnit.DAY); + case TIME_WITHOUT_TIME_ZONE: + return new ArrowType.Time(TimeUnit.MILLISECOND, 32); + case DECIMAL: + DecimalType decimalType = (DecimalType) type; + return new ArrowType.Decimal(decimalType.getPrecision(), decimalType.getScale(), 128); + case TIMESTAMP_WITHOUT_TIME_ZONE: + return new ArrowType.Timestamp(TimeUnit.MICROSECOND, null); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC"); + default: + throw unsupported(type); + } + } + + private static LogicalType toLogicalType(ArrowType arrowType) { + if (arrowType instanceof ArrowType.Bool) { + return new BooleanType(); + } else if (arrowType instanceof ArrowType.Int) { + ArrowType.Int intType = (ArrowType.Int) arrowType; + switch (intType.getBitWidth()) { + case 8: + return new TinyIntType(); + case 16: + return new SmallIntType(); + case 32: + return new IntType(); + case 64: + return new BigIntType(); + default: + throw new HoodieNotSupportedException("Unsupported Arrow int width for Lance Flink reader: " + intType.getBitWidth()); + } + } else if (arrowType instanceof ArrowType.FloatingPoint) { + ArrowType.FloatingPoint fp = (ArrowType.FloatingPoint) arrowType; + return fp.getPrecision() == FloatingPointPrecision.SINGLE + ? new FloatType() + : new DoubleType(); + } else if (arrowType instanceof ArrowType.Utf8) { + return new VarCharType(); + } else if (arrowType instanceof ArrowType.Binary) { + return new VarBinaryType(); + } else if (arrowType instanceof ArrowType.Date) { + return new DateType(); + } else if (arrowType instanceof ArrowType.Time) { + return new TimeType(); + } else if (arrowType instanceof ArrowType.Decimal) { + ArrowType.Decimal decimal = (ArrowType.Decimal) arrowType; + return new DecimalType(decimal.getPrecision(), decimal.getScale()); + } else if (arrowType instanceof ArrowType.Timestamp) { + ArrowType.Timestamp timestamp = (ArrowType.Timestamp) arrowType; + return timestamp.getTimezone() == null + ? new TimestampType(6) + : new LocalZonedTimestampType(6); + } + throw new HoodieNotSupportedException("Unsupported Arrow type for Lance Flink reader: " + arrowType); + } + + private static long timestampToMicros(TimestampData timestampData, int precision, boolean utcTimestamp) { + long millis = utcTimestamp ? timestampData.getMillisecond() : timestampData.toTimestamp().getTime(); + return precision > 3 && utcTimestamp + ? millis * 1000L + timestampData.getNanoOfMillisecond() / 1000L + : millis * 1000L; + } + + private static HoodieNotSupportedException unsupported(LogicalType type) { + return new HoodieNotSupportedException("Flink Lance base-file support currently supports primitive append-only columns; unsupported type: " + type); + } +} diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java index 45333cf4b5dbe..9571332950828 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java @@ -35,11 +35,13 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.HoodieInsertException; +import org.apache.hudi.io.storage.HoodieFileWriterFactory; import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.marker.WriteMarkers; import org.apache.hudi.table.marker.WriteMarkersFactory; +import org.apache.hudi.util.HoodieSchemaConverter; import lombok.extern.slf4j.Slf4j; import org.apache.flink.table.data.RowData; @@ -124,7 +126,7 @@ public HoodieRowDataCreateHandle(HoodieTable table, HoodieWriteConfig writeConfi } catch (IOException e) { throw new HoodieInsertException("Failed to initialize file writer for path " + path, e); } - log.info("New handle created for partition :" + partitionPath + " with fileId " + fileId); + log.info("New handle created for partition :{} with fileId {}", partitionPath, fileId); } /** @@ -164,7 +166,7 @@ public void write(String recordKey, String partitionPath, RowData record) throws ? HoodieRecordDelegate.create(recordKey, partitionPath, null, newRecordLocation) : null; writeStatus.markSuccess(recordDelegate, recordMetadata); } catch (Throwable t) { - log.error("Error writing record " + record, t); + log.error("Error writing record {}", record, t); if (!writeConfig.getIgnoreWriteFailed()) { throw new HoodieException(t.getMessage(), t); } @@ -293,7 +295,13 @@ protected HoodieRowDataFileWriter createNewFileWriter( Path path, HoodieTable hoodieTable, HoodieWriteConfig config, RowType rowType, String instantTime) throws IOException { StoragePath storagePath = new StoragePath(path.toUri()); - return (HoodieRowDataFileWriter) new HoodieRowDataFileWriterFactory(hoodieTable.getStorage()) - .newParquetFileWriter(instantTime, storagePath, config, rowType, hoodieTable.getTaskContextSupplier()); + return (HoodieRowDataFileWriter) HoodieFileWriterFactory.getFileWriter( + instantTime, + storagePath, + hoodieTable.getStorage(), + config, + HoodieSchemaConverter.convertToSchema(rowType).getNonNullType(), + hoodieTable.getTaskContextSupplier(), + HoodieRecord.HoodieRecordType.FLINK); } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataFileWriterFactory.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataFileWriterFactory.java index be3242164a40e..9fe3d6d5f3273 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataFileWriterFactory.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataFileWriterFactory.java @@ -25,6 +25,7 @@ import org.apache.hudi.common.engine.TaskContextSupplier; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; @@ -35,7 +36,7 @@ import org.apache.hudi.storage.StorageConfiguration; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; -import org.apache.hudi.util.RowDataQueryContexts; +import org.apache.hudi.util.HoodieSchemaConverter; import org.apache.flink.table.types.logical.RowType; import org.apache.hadoop.conf.Configuration; @@ -69,11 +70,9 @@ protected HoodieFileWriter newParquetFileWriter( OutputStream outputStream, HoodieConfig config, HoodieSchema schema) throws IOException { - //TODO boundary to revisit in follow up to use HoodieSchema directly - final RowType rowType = (RowType) RowDataQueryContexts.fromSchema(schema).getRowType().getLogicalType(); HoodieRowDataParquetWriteSupport writeSupport = new HoodieRowDataParquetWriteSupport( - storage.getConf().unwrapAs(Configuration.class), rowType, null); + storage.getConf().unwrapAs(Configuration.class), schema, null); return new HoodieRowDataParquetOutputStreamWriter( new FSDataOutputStream(outputStream, null), writeSupport, getParquetConfig(config, writeSupport)); } @@ -96,28 +95,6 @@ public HoodieFileWriter newParquetFileWriter( HoodieConfig config, HoodieSchema schema, TaskContextSupplier taskContextSupplier) throws IOException { - //TODO boundary to revisit in follow up to use HoodieSchema directly - final RowType rowType = (RowType) RowDataQueryContexts.fromSchema(schema).getRowType().getLogicalType(); - return newParquetFileWriter(instantTime, storagePath, config, rowType, taskContextSupplier); - } - - /** - * Create a parquet RowData writer on a given storage path. - * - * @param instantTime instant time to write - * @param storagePath file storage path - * @param config hoodie configuration - * @param rowType rowType of record - * @param taskContextSupplier task context supplier - * - * @return a RowData parquet writer - */ - public HoodieFileWriter newParquetFileWriter( - String instantTime, - StoragePath storagePath, - HoodieConfig config, - RowType rowType, - TaskContextSupplier taskContextSupplier) throws IOException { boolean populateMetaFields = config.getBooleanOrDefault(HoodieTableConfig.POPULATE_META_FIELDS); boolean withOperation = config.getBooleanOrDefault(HoodieWriteConfig.ALLOW_OPERATION_METADATA_FIELD); @@ -129,13 +106,39 @@ public HoodieFileWriter newParquetFileWriter( BloomFilter filter = createBloomFilter(hoodieConfig); HoodieRowDataParquetWriteSupport writeSupport = (HoodieRowDataParquetWriteSupport) ReflectionUtils.loadClass( hoodieConfig.getStringOrDefault(HoodieStorageConfig.HOODIE_PARQUET_FLINK_ROW_DATA_WRITE_SUPPORT_CLASS), - new Class[] {Configuration.class, RowType.class, BloomFilter.class}, - conf, rowType, filter); + new Class[] {Configuration.class, HoodieSchema.class, BloomFilter.class}, + conf, schema, filter); return new HoodieRowDataParquetWriter(storagePath, getParquetConfig(hoodieConfig, writeSupport), instantTime, taskContextSupplier, populateMetaFields, withOperation); } + @Override + public HoodieFileWriter newLanceFileWriter( + String instantTime, + StoragePath path, + HoodieConfig config, + HoodieSchema schema, + TaskContextSupplier taskContextSupplier) { + boolean populateMetaFields = config.getBooleanOrDefault(HoodieTableConfig.POPULATE_META_FIELDS); + boolean withOperation = config.getBooleanOrDefault(HoodieWriteConfig.ALLOW_OPERATION_METADATA_FIELD); + Option bloomFilter = enableBloomFilter(populateMetaFields, config) + ? Option.of(createBloomFilter(config)) : Option.empty(); + RowType rowType = HoodieSchemaConverter.convertToRowType(schema); + return new HoodieRowDataLanceWriter( + path, + rowType, + instantTime, + taskContextSupplier, + bloomFilter, + config.getLongOrDefault(HoodieStorageConfig.LANCE_MAX_FILE_SIZE), + config.getLongOrDefault(HoodieStorageConfig.LANCE_WRITE_ALLOCATOR_SIZE_BYTES), + config.getLongOrDefault(HoodieStorageConfig.LANCE_WRITE_FLUSH_BYTE_WATERMARK), + config.getBooleanOrDefault(HoodieStorageConfig.WRITE_UTC_TIMEZONE), + populateMetaFields, + withOperation); + } + private static HoodieParquetConfig getParquetConfig( HoodieConfig config, HoodieRowDataParquetWriteSupport writeSupport) { return new HoodieParquetConfig<>( diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataLanceWriter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataLanceWriter.java new file mode 100644 index 0000000000000..f4388c23fc437 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataLanceWriter.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.hudi.io.storage.row; + +import org.apache.hudi.client.model.HoodieRowDataCreation; +import org.apache.hudi.common.bloom.BloomFilter; +import org.apache.hudi.common.engine.TaskContextSupplier; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.io.lance.HoodieBaseLanceWriter; +import org.apache.hudi.storage.StoragePath; + +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; + +import java.io.IOException; +import java.util.function.Function; + +/** + * Lance writer for Flink {@link RowData} append-only base files. + */ +public class HoodieRowDataLanceWriter extends HoodieBaseLanceWriter + implements HoodieRowDataFileWriter { + + private static final long MIN_RECORDS_FOR_SIZE_CHECK = 100L; + private static final long MAX_RECORDS_FOR_SIZE_CHECK = 10000L; + + private final RowType rowType; + private final Schema arrowSchema; + private final String fileName; + private final String instantTime; + private final long maxFileSize; + private final boolean utcTimestamp; + private final boolean populateMetaFields; + private final boolean withOperation; + private final Function seqIdGenerator; + private long recordCountForNextSizeCheck = MIN_RECORDS_FOR_SIZE_CHECK; + + public HoodieRowDataLanceWriter( + StoragePath file, + RowType rowType, + String instantTime, + TaskContextSupplier taskContextSupplier, + Option bloomFilterOpt, + long maxFileSize, + long allocatorSize, + long flushByteWatermark, + boolean utcTimestamp, + boolean populateMetaFields, + boolean withOperation) { + super(file, DEFAULT_BATCH_SIZE, allocatorSize, flushByteWatermark, + bloomFilterOpt.map(HoodieBloomFilterRowDataWriteSupport::new)); + ValidationUtils.checkArgument(maxFileSize > 0, "maxFileSize must be a positive number"); + ValidationUtils.checkArgument(allocatorSize > 0, "allocatorSize must be a positive number"); + ValidationUtils.checkArgument(flushByteWatermark > 0, "flushByteWatermark must be a positive number"); + ValidationUtils.checkArgument(flushByteWatermark < allocatorSize, + "flushByteWatermark (" + flushByteWatermark + ") must be less than allocatorSize (" + + allocatorSize + ")"); + this.rowType = rowType; + this.arrowSchema = HoodieFlinkLanceArrowUtils.toArrowSchema(rowType); + this.fileName = file.getName(); + this.instantTime = instantTime; + this.maxFileSize = maxFileSize; + this.utcTimestamp = utcTimestamp; + this.populateMetaFields = populateMetaFields; + this.withOperation = withOperation; + this.seqIdGenerator = recordIndex -> { + Integer partitionId = taskContextSupplier.getPartitionIdSupplier().get(); + return HoodieRecord.generateSequenceId(instantTime, partitionId, recordIndex); + }; + } + + @Override + public boolean canWrite() { + long writtenCount = getWrittenRecordCount(); + if (writtenCount >= recordCountForNextSizeCheck) { + long dataSize = getDataSize(); + long avgRecordSize = Math.max(dataSize / writtenCount, 1); + if (dataSize > (maxFileSize - avgRecordSize * 2)) { + return false; + } + recordCountForNextSizeCheck = writtenCount + Math.min( + Math.max(MIN_RECORDS_FOR_SIZE_CHECK, (maxFileSize / avgRecordSize - writtenCount) / 2), + MAX_RECORDS_FOR_SIZE_CHECK); + } + return true; + } + + @Override + public void writeRow(String key, RowData row) throws IOException { + bloomFilterWriteSupportOpt.ifPresent(bloomFilterWriteSupport -> bloomFilterWriteSupport.addKey(key)); + super.write(row); + } + + @Override + public void writeRowWithMetaData(HoodieKey key, RowData row) throws IOException { + if (populateMetaFields) { + RowData rowWithMeta = updateRecordMetadata(row, key, getWrittenRecordCount()); + writeRow(key.getRecordKey(), rowWithMeta); + } else { + writeRow(key.getRecordKey(), row); + } + } + + @Override + protected ArrowWriter createArrowWriter(VectorSchemaRoot root) { + return new RowDataArrowWriter(root); + } + + @Override + protected Schema getArrowSchema() { + return arrowSchema; + } + + private class RowDataArrowWriter implements ArrowWriter { + private final VectorSchemaRoot root; + private int rowId; + + private RowDataArrowWriter(VectorSchemaRoot root) { + this.root = root; + } + + @Override + public void write(RowData row) { + for (int i = 0; i < rowType.getFieldCount(); i++) { + HoodieFlinkLanceArrowUtils.writeValue(rowType.getTypeAt(i), root.getVector(i), rowId, row, i, utcTimestamp); + } + rowId++; + } + + @Override + public void finishBatch() { + root.getFieldVectors().forEach(vector -> vector.setValueCount(rowId)); + root.setRowCount(rowId); + } + + @Override + public void reset() { + rowId = 0; + } + } + + private RowData updateRecordMetadata(RowData row, HoodieKey key, long recordCount) { + return HoodieRowDataCreation.create(instantTime, seqIdGenerator.apply(recordCount), + key.getRecordKey(), key.getPartitionPath(), fileName, row, withOperation, true); + } +} diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataParquetWriteSupport.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataParquetWriteSupport.java index b2b1d9c058cf4..14ed278b70a28 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataParquetWriteSupport.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataParquetWriteSupport.java @@ -20,11 +20,10 @@ import org.apache.hudi.avro.HoodieBloomFilterWriteSupport; import org.apache.hudi.common.bloom.BloomFilter; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.StringUtils; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.types.logical.RowType; import org.apache.hadoop.conf.Configuration; import org.apache.parquet.hadoop.api.WriteSupport; @@ -38,8 +37,8 @@ public class HoodieRowDataParquetWriteSupport extends RowDataParquetWriteSupport private final Option> bloomFilterWriteSupportOpt; - public HoodieRowDataParquetWriteSupport(Configuration conf, RowType rowType, BloomFilter bloomFilter) { - super(rowType, conf); + public HoodieRowDataParquetWriteSupport(Configuration conf, HoodieSchema schema, BloomFilter bloomFilter) { + super(schema, conf); this.bloomFilterWriteSupportOpt = Option.ofNullable(bloomFilter) .map(HoodieBloomFilterRowDataWriteSupport::new); } @@ -61,15 +60,4 @@ public void add(String recordKey) { this.bloomFilterWriteSupportOpt.ifPresent(bloomFilterWriteSupport -> bloomFilterWriteSupport.addKey(recordKey)); } - - private static class HoodieBloomFilterRowDataWriteSupport extends HoodieBloomFilterWriteSupport { - public HoodieBloomFilterRowDataWriteSupport(BloomFilter bloomFilter) { - super(bloomFilter); - } - - @Override - protected byte[] getUTF8Bytes(String key) { - return StringUtils.getUTF8Bytes(key); - } - } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/RowDataParquetWriteSupport.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/RowDataParquetWriteSupport.java index 7315461db5006..e0cce0aacffb5 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/RowDataParquetWriteSupport.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/RowDataParquetWriteSupport.java @@ -19,11 +19,11 @@ package org.apache.hudi.io.storage.row; import org.apache.hudi.common.config.HoodieStorageConfig; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.io.storage.row.parquet.ParquetRowDataWriter; import org.apache.hudi.io.storage.row.parquet.ParquetSchemaConverter; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.types.logical.RowType; import org.apache.hadoop.conf.Configuration; import org.apache.parquet.hadoop.api.WriteSupport; import org.apache.parquet.io.api.RecordConsumer; @@ -36,16 +36,16 @@ */ public class RowDataParquetWriteSupport extends WriteSupport { - private final RowType rowType; + protected final HoodieSchema hoodieSchema; private final MessageType schema; private ParquetRowDataWriter writer; protected final Configuration hadoopConf; - public RowDataParquetWriteSupport(RowType rowType, Configuration config) { + public RowDataParquetWriteSupport(HoodieSchema hoodieSchema, Configuration config) { super(); - this.rowType = rowType; + this.hoodieSchema = hoodieSchema; this.hadoopConf = new Configuration(config); - this.schema = ParquetSchemaConverter.convertToParquetMessageType("flink_schema", rowType); + this.schema = ParquetSchemaConverter.convertToParquetMessageType("flink_schema", hoodieSchema); } @Override @@ -60,7 +60,7 @@ public void prepareForWrite(RecordConsumer recordConsumer) { hadoopConf.getBoolean( HoodieStorageConfig.WRITE_UTC_TIMEZONE.key(), HoodieStorageConfig.WRITE_UTC_TIMEZONE.defaultValue()); - this.writer = new ParquetRowDataWriter(recordConsumer, rowType, schema, utcTimestamp); + this.writer = new ParquetRowDataWriter(recordConsumer, utcTimestamp, hoodieSchema); } @Override diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetRowDataWriter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetRowDataWriter.java index b3b612506b952..7fea807e4672b 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetRowDataWriter.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetRowDataWriter.java @@ -18,7 +18,10 @@ package org.apache.hudi.io.storage.row.parquet; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.util.HoodieSchemaConverter; import org.apache.flink.table.data.ArrayData; import org.apache.flink.table.data.DecimalDataUtils; @@ -35,14 +38,12 @@ import org.apache.flink.util.Preconditions; import org.apache.parquet.io.api.Binary; import org.apache.parquet.io.api.RecordConsumer; -import org.apache.parquet.schema.GroupType; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; import java.util.Arrays; -import static org.apache.flink.formats.parquet.utils.ParquetSchemaConverter.computeMinBytesForDecimalPrecision; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.NANOS_PER_MILLISECOND; @@ -64,16 +65,17 @@ public class ParquetRowDataWriter { public ParquetRowDataWriter( RecordConsumer recordConsumer, - RowType rowType, - GroupType schema, - boolean utcTimestamp) { + boolean utcTimestamp, + HoodieSchema schema) { this.recordConsumer = recordConsumer; this.utcTimestamp = utcTimestamp; + RowType rowType = HoodieSchemaConverter.convertToRowType(schema); this.filedWriters = new FieldWriter[rowType.getFieldCount()]; this.fieldNames = rowType.getFieldNames().toArray(new String[0]); for (int i = 0; i < rowType.getFieldCount(); i++) { - this.filedWriters[i] = createWriter(rowType.getTypeAt(i)); + HoodieSchema fieldSchema = HoodieSchemaUtils.getFieldSchema(schema, fieldNames[i]); + this.filedWriters[i] = createWriter(rowType.getTypeAt(i), fieldSchema); } } @@ -97,7 +99,8 @@ public void write(final RowData record) { recordConsumer.endMessage(); } - private FieldWriter createWriter(LogicalType t) { + private FieldWriter createWriter(LogicalType t, HoodieSchema oriFieldSchema) { + HoodieSchema fieldSchema = oriFieldSchema.getNonNullType(); switch (t.getTypeRoot()) { case CHAR: case VARCHAR: @@ -109,7 +112,7 @@ private FieldWriter createWriter(LogicalType t) { return new BinaryWriter(); case DECIMAL: DecimalType decimalType = (DecimalType) t; - return createDecimalWriter(decimalType.getPrecision(), decimalType.getScale()); + return createDecimalWriter(decimalType.getPrecision(), decimalType.getScale(), fieldSchema); case TINYINT: return new ByteWriter(); case SMALLINT: @@ -143,19 +146,21 @@ private FieldWriter createWriter(LogicalType t) { case ARRAY: ArrayType arrayType = (ArrayType) t; LogicalType elementType = arrayType.getElementType(); - FieldWriter elementWriter = createWriter(elementType); + FieldWriter elementWriter = createWriter(elementType, fieldSchema.getElementType()); return new ArrayWriter(elementWriter); case MAP: MapType mapType = (MapType) t; LogicalType keyType = mapType.getKeyType(); LogicalType valueType = mapType.getValueType(); - FieldWriter keyWriter = createWriter(keyType); - FieldWriter valueWriter = createWriter(valueType); + FieldWriter keyWriter = createWriter(keyType, fieldSchema.getKeyType()); + FieldWriter valueWriter = createWriter(valueType, fieldSchema.getValueType()); return new MapWriter(keyWriter, valueWriter); case ROW: RowType rowType = (RowType) t; FieldWriter[] fieldWriters = rowType.getFields().stream() - .map(RowType.RowField::getType).map(this::createWriter).toArray(FieldWriter[]::new); + .map(field -> createWriter( + field.getType(), HoodieSchemaUtils.getFieldSchema(fieldSchema, field.getName()))) + .toArray(FieldWriter[]::new); String[] fieldNames = rowType.getFields().stream() .map(RowType.RowField::getName).toArray(String[]::new); return new RowWriter(fieldNames, fieldWriters); @@ -390,24 +395,21 @@ private Binary timestampToInt96(TimestampData timestampData) { return Binary.fromConstantByteBuffer(buf); } - private FieldWriter createDecimalWriter(int precision, int scale) { + private FieldWriter createDecimalWriter(int precision, int scale, HoodieSchema fieldSchema) { Preconditions.checkArgument( precision <= DecimalType.MAX_PRECISION, "Decimal precision %s exceeds max precision %s", precision, DecimalType.MAX_PRECISION); + int numBytes = ParquetSchemaConverter.resolveDecimalByteLength(fieldSchema, precision); /* * This is optimizer for UnscaledBytesWriter. */ class LongUnscaledBytesWriter implements FieldWriter { - private final int numBytes; - private final int initShift; private final byte[] decimalBuffer; private LongUnscaledBytesWriter() { - this.numBytes = computeMinBytesForDecimalPrecision(precision); - this.initShift = 8 * (numBytes - 1); this.decimalBuffer = new byte[numBytes]; } @@ -424,12 +426,15 @@ public void write(ArrayData array, int ordinal) { } private void doWrite(long unscaled) { - int i = 0; - int shift = initShift; - while (i < numBytes) { - decimalBuffer[i] = (byte) (unscaled >> shift); - i += 1; - shift -= 8; + // Parquet encodes FIXED_LEN_BYTE_ARRAY decimals as big-endian two's complement. A compact + // Flink decimal provides at most eight value bytes, so pad wider Avro fixed types with the + // sign byte to preserve the value. + int firstValueByte = Math.max(0, numBytes - Long.BYTES); + Arrays.fill(decimalBuffer, 0, firstValueByte, unscaled < 0 ? (byte) -1 : (byte) 0); + // Copy from the least-significant byte backwards to produce the big-endian representation. + for (int i = numBytes - 1; i >= firstValueByte; i--) { + decimalBuffer[i] = (byte) unscaled; + unscaled >>= Byte.SIZE; } recordConsumer.addBinary(Binary.fromReusedByteArray(decimalBuffer, 0, numBytes)); @@ -437,11 +442,9 @@ private void doWrite(long unscaled) { } class UnscaledBytesWriter implements FieldWriter { - private final int numBytes; private final byte[] decimalBuffer; private UnscaledBytesWriter() { - this.numBytes = computeMinBytesForDecimalPrecision(precision); this.decimalBuffer = new byte[numBytes]; } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetSchemaConverter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetSchemaConverter.java index d98497fdc86c8..25736390ab9c4 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetSchemaConverter.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/parquet/ParquetSchemaConverter.java @@ -18,7 +18,11 @@ package org.apache.hudi.io.storage.row.parquet; +import org.apache.hudi.adapter.DataTypeAdapter; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.util.HoodieSchemaConverter; import lombok.extern.slf4j.Slf4j; import org.apache.flink.table.api.DataTypes; @@ -39,6 +43,8 @@ import org.apache.parquet.schema.Type; import org.apache.parquet.schema.Types; +import javax.annotation.Nullable; + import java.util.List; import java.util.stream.Collectors; @@ -47,6 +53,17 @@ /** * Schema converter converts Parquet schema to and from Flink internal types. * + *

On reads, this converter performs best-effort physical type mapping. It detects the + * Parquet {@code VARIANT} annotation and will reject shredded variants. Blob and Vector types + * cannot be distinguished from ordinary binary columns via Parquet schema alone. + * + *

On writes, this converter maps Flink {@code VariantType} to the canonical unshredded Parquet + * layout (group with binary metadata + value fields). The VARIANT logical type annotation is + * resolved by {@link DataTypeAdapter#variantParquetAnnotation()} — on Flink 2.1+ with + * parquet-java 1.16.0+ the annotation is attached automatically; on pre-2.1 Flink or with + * parquet < 1.16.0 the write throws {@link UnsupportedOperationException} because writing + * variant data without the annotation would produce files that no reader can identify as variant. + * *

Reference org.apache.flink.formats.parquet.utils.ParquetSchemaConverter to support timestamp of INT64 8 bytes. */ @Slf4j @@ -155,6 +172,18 @@ public static RowType.RowField convertToRowField(Type parquetType) { new MapType( convertToRowField(keyValueType.getLeft()).getType().copy(true), convertToRowField(keyValueType.getRight()).getType())); + } else if (hasVariantAnnotation(logicalType)) { + // Fires for files written with parquet-java that carry the VARIANT annotation. + // The reader infers the Flink RowType from the Parquet footer via convertToRowType(), + // so this annotation detection is the primary mechanism for recognizing Variant columns. + if (isShreddedVariant(groupType)) { + throw new UnsupportedOperationException( + "Shredded Variant is not supported in Flink. " + + "The Parquet group '" + groupType.getName() + "' contains a '" + + HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD + + "' field indicating a shredded layout."); + } + dataType = DataTypeAdapter.createVariantType(); } else { dataType = DataTypes.of(new RowType( @@ -180,18 +209,100 @@ public static Pair parquetMapKeyValueType(GroupType mapType) { return Pair.of(keyValue.getType(MAP_KEY_NAME), keyValue.getType(MAP_VALUE_NAME)); } + /** + * Converts from the Flink type alone. Prefer + * {@link #convertToParquetMessageType(String, HoodieSchema)} when the caller already holds a + * HoodieSchema: a RowType cannot express an Avro fixed decimal's declared width, so this + * overload falls back to the minimum byte count for the precision. + * + *

This does not simply delegate to the HoodieSchema overload. Converting a RowType to a + * HoodieSchema is lossy for types Parquet supports but Avro does not -- a map with a non-string + * key, or a timestamp of precision greater than 6 -- and both are reachable here. + */ public static MessageType convertToParquetMessageType(String name, RowType rowType) { Type[] types = new Type[rowType.getFieldCount()]; for (int i = 0; i < rowType.getFieldCount(); i++) { String fieldName = rowType.getFieldNames().get(i); LogicalType fieldType = rowType.getTypeAt(i); - types[i] = convertToParquetType(fieldName, fieldType, fieldType.isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED); + types[i] = convertToParquetType( + fieldName, + fieldType, + fieldType.isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED, + null); + } + return new MessageType(name, types); + } + + public static MessageType convertToParquetMessageType(String name, HoodieSchema oriRowSchema) { + HoodieSchema rowSchema = oriRowSchema.getNonNullType(); + RowType rowType = HoodieSchemaConverter.convertToRowType(rowSchema); + Type[] types = new Type[rowType.getFieldCount()]; + for (int i = 0; i < rowType.getFieldCount(); i++) { + String fieldName = rowType.getFieldNames().get(i); + LogicalType fieldType = rowType.getTypeAt(i); + HoodieSchema fieldSchema = HoodieSchemaUtils.getFieldSchema(rowSchema, fieldName); + types[i] = convertToParquetType( + fieldName, + fieldType, + fieldType.isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED, + fieldSchema); } return new MessageType(name, types); } + /** + * Checks whether the group carries the Parquet {@code VARIANT} logical type annotation. + * Uses class-name matching so this compiles against parquet-java versions that predate the + * {@code VariantLogicalTypeAnnotation} class (< 1.15.2). + */ + private static boolean hasVariantAnnotation(LogicalTypeAnnotation logicalType) { + // needs to ensure the writer attach the variant annotation in 1.3. + return logicalType != null + && logicalType.getClass().getSimpleName().equals("VariantLogicalTypeAnnotation"); + } + + /** + * Checks whether a variant group contains a {@code typed_value} field, indicating a shredded + * layout. Called only after {@link #hasVariantAnnotation} returns true. + */ + private static boolean isShreddedVariant(GroupType groupType) { + return groupType.containsField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD); + } + + /** + * Converts a Variant column to the canonical unshredded Parquet layout: + * a group with required binary {@code metadata} and required binary {@code value}. + * + *

No shredded-variant guard is needed here: Flink 2.1's {@code VariantType} is a single + * atomic {@code LogicalTypeRoot.VARIANT} with no shredding representation (FLIP-521 scopes + * shredding out), so a shredded variant can never arrive as a Flink LogicalType. + * + *

Delegates to {@link DataTypeAdapter#variantParquetAnnotation()} for the VARIANT logical + * type annotation. On Flink < 2.1 this throws (variant writes are unsupported). On Flink 2.1+ + * with parquet-java < 1.16.0 this also throws, because writing variant data without the + * annotation would produce files that no reader can identify as variant. + */ + private static Type convertVariantToParquetType(String name, Type.Repetition repetition) { + LogicalTypeAnnotation annotation = DataTypeAdapter.variantParquetAnnotation() + .orElseThrow(() -> new UnsupportedOperationException( + "Cannot write Variant columns: parquet-java 1.16.0+ is required to emit the VARIANT " + + "logical type annotation. Without the annotation, readers cannot identify the " + + "column as Variant. Current parquet-java version does not support " + + "LogicalTypeAnnotation.variantType().")); + return Types.buildGroup(repetition) + .as(annotation) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, Type.Repetition.REQUIRED) + .named(HoodieSchema.Variant.VARIANT_METADATA_FIELD)) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, Type.Repetition.REQUIRED) + .named(HoodieSchema.Variant.VARIANT_VALUE_FIELD)) + .named(name); + } + private static Type convertToParquetType( - String name, LogicalType type, Type.Repetition repetition) { + String name, LogicalType type, Type.Repetition repetition, @Nullable HoodieSchema oriFieldSchema) { + // Null when the caller only had a Flink type; every use below is null-tolerant and falls back + // to what the Flink type alone implies. + HoodieSchema fieldSchema = oriFieldSchema == null ? null : oriFieldSchema.getNonNullType(); switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -208,7 +319,7 @@ private static Type convertToParquetType( case DECIMAL: int precision = ((DecimalType) type).getPrecision(); int scale = ((DecimalType) type).getScale(); - int numBytes = computeMinBytesForDecimalPrecision(precision); + int numBytes = resolveDecimalByteLength(fieldSchema, precision); return Types.primitive( PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, repetition) .as(LogicalTypeAnnotation.decimalType(scale, precision)) @@ -277,7 +388,13 @@ private static Type convertToParquetType( Type.Repetition eleRepetition = arrayType.getElementType().isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED; return ConversionPatterns.listOfElements( - repetition, name, convertToParquetType("element", arrayType.getElementType(), eleRepetition)); + repetition, + name, + convertToParquetType( + "element", + arrayType.getElementType(), + eleRepetition, + fieldSchema == null ? null : fieldSchema.getElementType())); case MAP: // group (MAP) { // repeated group key_value { @@ -293,17 +410,31 @@ private static Type convertToParquetType( .addField( Types .repeatedGroup() - .addField(convertToParquetType("key", keyType, Type.Repetition.REQUIRED)) - .addField(convertToParquetType("value", valueType, valueType.isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED)) + .addField(convertToParquetType( + "key", keyType, Type.Repetition.REQUIRED, + fieldSchema == null ? null : fieldSchema.getKeyType())) + .addField(convertToParquetType( + "value", + valueType, + valueType.isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED, + fieldSchema == null ? null : fieldSchema.getValueType())) .named("key_value")) .named(name); case ROW: RowType rowType = (RowType) type; Types.GroupBuilder builder = Types.buildGroup(repetition); rowType.getFields().forEach(field -> builder - .addField(convertToParquetType(field.getName(), field.getType(), field.getType().isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED))); + .addField(convertToParquetType( + field.getName(), + field.getType(), + field.getType().isNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED, + fieldSchema == null + ? null : HoodieSchemaUtils.getFieldSchema(fieldSchema, field.getName())))); return builder.named(name); default: + if (DataTypeAdapter.isVariantType(type)) { + return convertVariantToParquetType(name, repetition); + } throw new UnsupportedOperationException("Unsupported type: " + type); } } @@ -315,4 +446,14 @@ public static int computeMinBytesForDecimalPrecision(int precision) { } return numBytes; } + + static int resolveDecimalByteLength(HoodieSchema fieldSchema, int precision) { + if (fieldSchema instanceof HoodieSchema.Decimal) { + HoodieSchema.Decimal decimalSchema = (HoodieSchema.Decimal) fieldSchema; + if (decimalSchema.isFixed()) { + return decimalSchema.getFixedSize(); + } + } + return computeMinBytesForDecimalPrecision(precision); + } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java index 268ae942b92e9..8cc44f068df54 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java @@ -98,7 +98,7 @@ public static HoodieTableMetadataWriter create(StorageConfiguration conf, protected void initRegistry() { if (metadataWriteConfig.isMetricsOn()) { // should support executor metrics - this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage())); + this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage(), dataWriteConfig.getMetadataConfig().isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkDeleteHelper.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkDeleteHelper.java index 7dfc8a336c325..733b79fa9987e 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkDeleteHelper.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkDeleteHelper.java @@ -38,6 +38,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.stream.Collectors; @@ -65,16 +66,16 @@ public static FlinkDeleteHelper newInstance() { public List deduplicateKeys(List keys, HoodieTable>, List, List> table, int parallelism) { boolean isIndexingGlobal = table.getIndex().isGlobal(); if (isIndexingGlobal) { - HashSet recordKeys = keys.stream().map(HoodieKey::getRecordKey).collect(Collectors.toCollection(HashSet::new)); + HashSet recordKeys = new HashSet<>(); List deduplicatedKeys = new LinkedList<>(); keys.forEach(x -> { - if (recordKeys.contains(x.getRecordKey())) { + if (recordKeys.add(x.getRecordKey())) { deduplicatedKeys.add(x); } }); return deduplicatedKeys; } else { - HashSet set = new HashSet<>(keys); + LinkedHashSet set = new LinkedHashSet<>(keys); keys.clear(); keys.addAll(set); return keys; diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkPartitionTTLActionExecutor.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkPartitionTTLActionExecutor.java index dc3b38c5d8107..1b8f965d5a065 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkPartitionTTLActionExecutor.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkPartitionTTLActionExecutor.java @@ -58,7 +58,7 @@ public HoodieWriteMetadata> execute() { if (expiredPartitions.isEmpty()) { return emptyResult; } - log.info("Partition ttl find the following expired partitions to delete: " + String.join(",", expiredPartitions)); + log.info("Partition ttl find the following expired partitions to delete: {}", String.join(",", expiredPartitions)); return new FlinkAutoCommitActionExecutor(new FlinkDeletePartitionCommitActionExecutor<>(context, config, table, instantTime, expiredPartitions)).execute(); } catch (HoodieDeletePartitionPendingTableServiceException deletePartitionPendingTableServiceException) { log.info("Partition is under table service, do nothing, call delete partition next time."); diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/format/FlinkRecordContext.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/format/FlinkRecordContext.java index d242dcbfb9d60..e2406b829693e 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/format/FlinkRecordContext.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/format/FlinkRecordContext.java @@ -25,6 +25,7 @@ import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieOperation; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.table.HoodieTableConfig; @@ -125,7 +126,7 @@ public RowData getDeleteRow(String recordKey) { @Override public RowData convertAvroRecord(IndexedRecord avroRecord) { Schema recordSchema = avroRecord.getSchema(); - AvroToRowDataConverters.AvroToRowDataConverter converter = RowDataQueryContexts.fromSchema(HoodieSchema.fromAvroSchema(recordSchema), utcTimezone).getAvroToRowDataConverter(); + AvroToRowDataConverters.AvroToRowDataConverter converter = RowDataQueryContexts.fromSchema(HoodieAvroSchemaCache.intern(recordSchema), utcTimezone).getAvroToRowDataConverter(); RowData rowData = (RowData) converter.convert(avroRecord); Schema.Field operationField = recordSchema.getField(HoodieRecord.OPERATION_METADATA_FIELD); if (operationField != null) { diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/AvroToRowDataConverters.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/AvroToRowDataConverters.java index fee24e520382d..0da058bfd91b4 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/AvroToRowDataConverters.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/AvroToRowDataConverters.java @@ -18,6 +18,8 @@ package org.apache.hudi.util; +import org.apache.hudi.adapter.DataTypeAdapter; + import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.apache.avro.generic.GenericFixed; @@ -155,6 +157,9 @@ public static AvroToRowDataConverter createConverter(LogicalType type, boolean u case MULTISET: return createMapConverter(type, utcTimezone); default: + if (DataTypeAdapter.isVariantType(type)) { + return createVariantConverter(); + } throw new UnsupportedOperationException("Unsupported type: " + type); } } @@ -212,6 +217,18 @@ private static AvroToRowDataConverter createMapConverter(LogicalType type, boole }; } + /** + * Creates a converter for Flink 2.1+ VARIANT LogicalType. The converter receives an Avro + * GenericRecord carrying metadata/value binary fields and produces a Flink + * {@code BinaryVariant}. + */ + private static AvroToRowDataConverter createVariantConverter() { + return avroObject -> { + IndexedRecord record = (IndexedRecord) avroObject; + return DataTypeAdapter.createVariant(convertToBytes(record.get(1)), convertToBytes(record.get(0))); + }; + } + private static AvroToRowDataConverter createTimestampConverter(int precision, boolean utcTimezone) { final ChronoUnit chronoUnit; if (precision <= 3) { @@ -323,7 +340,8 @@ public static JodaConverter getConverter() { public long convertDate(Object object) { final org.joda.time.LocalDate value = (org.joda.time.LocalDate) object; - return value.toDate().getTime(); + return LocalDate.of( + value.getYear(), value.getMonthOfYear(), value.getDayOfMonth()).toEpochDay(); } public int convertTime(Object object) { diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/HoodieSchemaConverter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/HoodieSchemaConverter.java index 0b3a1af7982b7..c859675488ad7 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/HoodieSchemaConverter.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/HoodieSchemaConverter.java @@ -19,6 +19,7 @@ package org.apache.hudi.util; +import org.apache.hudi.adapter.DataTypeAdapter; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; @@ -69,6 +70,10 @@ public static HoodieSchema convertToSchema(LogicalType logicalType) { *

The "{rowName}." is used as the nested row type name prefix in order to generate * the right schema. Nested record types that only differ by type name are still compatible. * + *

On Flink 2.1+, {@code LogicalTypeRoot.VARIANT} is detected via string comparison + * (to avoid compile-time dependency) and mapped to {@link HoodieSchema#createVariant()}. + * Pre-2.1 Flink does not support Variant. + * * @param logicalType Flink logical type * @param rowName the record name * @return HoodieSchema matching this logical type @@ -222,6 +227,10 @@ public static HoodieSchema convertToSchema(LogicalType logicalType, String rowNa case RAW: default: + if (DataTypeAdapter.isVariantType(logicalType)) { + schema = HoodieSchema.createVariant(); + break; + } throw new UnsupportedOperationException( "Unsupported type for HoodieSchema conversion: " + logicalType); } @@ -263,6 +272,16 @@ private static boolean isFamily(LogicalType logicalType, LogicalTypeFamily famil /** * Detects if a Flink RowType represents a BLOB structure by validating it matches the schema defined in {@link HoodieSchema.Blob}. + * + *

Detection intentionally keys off the stable structural signals (field names and base type + * roots) and does not assert nested-field nullability. Flink SQL {@code CREATE TABLE} does + * not reliably preserve {@code NOT NULL} constraints on nested {@code ROW} fields, so requiring an + * exact nullability match would silently demote a user's BLOB column to a generic record when the + * column is declared through DDL. The canonical nullability is restored by {@link HoodieSchema#createBlob()}. + * + *

TODO: This heuristic is a workaround for the lack of a native Flink/Parquet BLOB logical + * type. See apache/hudi#18711 for + * the tracked work to remove this structural inference. */ private static boolean isBlobStructure(RowType rowType) { // Validate: 3 fields with exact names @@ -277,24 +296,20 @@ private static boolean isBlobStructure(RowType rowType) { return false; } - // Validate 'type' field: non-null STRING - LogicalType typeField = rowType.getTypeAt(0); - if (!isFamily(typeField, LogicalTypeFamily.CHARACTER_STRING) || typeField.isNullable()) { + // Validate 'type' field: STRING + if (!isFamily(rowType.getTypeAt(0), LogicalTypeFamily.CHARACTER_STRING)) { return false; } - // Validate 'data' field: nullable BYTES/VARBINARY + // Validate 'data' field: BYTES/VARBINARY LogicalType dataField = rowType.getTypeAt(1); if (dataField.getTypeRoot() != LogicalTypeRoot.BINARY && dataField.getTypeRoot() != LogicalTypeRoot.VARBINARY) { return false; } - if (!dataField.isNullable()) { - return false; - } - // Validate 'reference' field: nullable ROW + // Validate 'reference' field: ROW LogicalType referenceField = rowType.getTypeAt(2); - if (!referenceField.isNullable() || referenceField.getTypeRoot() != LogicalTypeRoot.ROW) { + if (referenceField.getTypeRoot() != LogicalTypeRoot.ROW) { return false; } @@ -313,24 +328,20 @@ private static boolean isBlobStructure(RowType rowType) { } // Validate reference sub-field types - // external_path: non-null STRING - if (!isFamily(referenceRow.getTypeAt(0), LogicalTypeFamily.CHARACTER_STRING) - || referenceRow.getTypeAt(0).isNullable()) { + // external_path: STRING + if (!isFamily(referenceRow.getTypeAt(0), LogicalTypeFamily.CHARACTER_STRING)) { return false; } - // offset: nullable BIGINT - if (referenceRow.getTypeAt(1).getTypeRoot() != LogicalTypeRoot.BIGINT - || !referenceRow.getTypeAt(1).isNullable()) { + // offset: BIGINT + if (referenceRow.getTypeAt(1).getTypeRoot() != LogicalTypeRoot.BIGINT) { return false; } - // length: nullable BIGINT - if (referenceRow.getTypeAt(2).getTypeRoot() != LogicalTypeRoot.BIGINT - || !referenceRow.getTypeAt(2).isNullable()) { + // length: BIGINT + if (referenceRow.getTypeAt(2).getTypeRoot() != LogicalTypeRoot.BIGINT) { return false; } - // managed: non-null BOOLEAN - if (referenceRow.getTypeAt(3).getTypeRoot() != LogicalTypeRoot.BOOLEAN - || referenceRow.getTypeAt(3).isNullable()) { + // managed: BOOLEAN + if (referenceRow.getTypeAt(3).getTypeRoot() != LogicalTypeRoot.BOOLEAN) { return false; } @@ -576,23 +587,24 @@ private static DataType convertUnion(HoodieSchema schema) { } /** - * Converts a Variant schema to Flink's ROW type. - * Variant is represented as ROW<`metadata` BYTES, `value` BYTES> in Flink. + * Converts a Variant HoodieSchema to the native Flink {@code VariantType} DataType. + * Requires Flink 2.1+ at runtime; throws {@link UnsupportedOperationException} on older versions. * * @param schema HoodieSchema to convert (must be a VARIANT type) - * @return DataType representing the Variant as a ROW with binary fields + * @return native VariantType DataType + * @throws UnsupportedOperationException if Flink runtime is pre-2.1 or variant is shredded */ private static DataType convertVariant(HoodieSchema schema) { if (schema.getType() != HoodieSchemaType.VARIANT) { throw new IllegalStateException("Expected HoodieSchema.Variant but got: " + schema.getClass()); } - // Variant is stored as a struct with two binary fields: metadata and value. - // Field order follows the Parquet spec and Iceberg convention (metadata first, value second). - return DataTypes.ROW( - DataTypes.FIELD("metadata", DataTypes.BYTES().notNull()), - DataTypes.FIELD("value", DataTypes.BYTES().notNull()) - ).notNull(); + if (((HoodieSchema.Variant) schema).isShredded()) { + throw new UnsupportedOperationException( + "Shredded Variant is not yet supported in Flink. Use unshredded Variant instead."); + } + + return DataTypeAdapter.createVariantType().notNull(); } /** diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/RowDataToAvroConverters.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/RowDataToAvroConverters.java index 9eb2979e19aa0..050ad483de104 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/RowDataToAvroConverters.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/RowDataToAvroConverters.java @@ -18,6 +18,7 @@ package org.apache.hudi.util; +import org.apache.hudi.adapter.DataTypeAdapter; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; @@ -147,6 +148,14 @@ public Object convert(HoodieSchema schema, Object object) { @Override public Object convert(HoodieSchema schema, Object object) { + // The BLOB `type` discriminator is a STRING in Flink but an ENUM in Avro. + // Detect that at call time from the HoodieSchema so the converter stays + // reusable across any row shape — not hard-wired by Flink row structure alone. + HoodieSchema nonNullSchema = schema.getNonNullType(); + if (nonNullSchema.getType() == HoodieSchemaType.ENUM) { + return new GenericData.EnumSymbol( + nonNullSchema.toAvroSchema(), object.toString()); + } return new Utf8(((BinaryStringData) object).toBytes()); } }; @@ -239,6 +248,10 @@ public Object convert(HoodieSchema schema, Object object) { break; case RAW: default: + if (DataTypeAdapter.isVariantType(type)) { + converter = createVariantConverter(); + break; + } throw new UnsupportedOperationException("Unsupported type: " + type); } @@ -357,5 +370,28 @@ public Object convert(HoodieSchema schema, Object object) { } }; } + + /** + * Creates a converter for Flink 2.1+ VARIANT LogicalType. The converter receives a Flink + * {@code Variant} object at runtime and extracts the raw metadata/value byte arrays, + * then packs them into an Avro GenericRecord with the Variant schema. + * + *

No shredded-variant check is needed here: {@code HoodieSchemaConverter.convertVariant()} + * already rejects shredded variants before a Flink type or converter is ever constructed, + * and Flink 2.1 itself only supports unshredded variants (FLIP-521). + */ + private static RowDataToAvroConverter createVariantConverter() { + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(HoodieSchema schema, Object object) { + final GenericRecord record = new GenericData.Record(schema.toAvroSchema()); + record.put(HoodieSchema.Variant.VARIANT_METADATA_FIELD, ByteBuffer.wrap(DataTypeAdapter.getVariantMetadata(object))); + record.put(HoodieSchema.Variant.VARIANT_VALUE_FIELD, ByteBuffer.wrap(DataTypeAdapter.getVariantValue(object))); + return record; + } + }; + } } diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java index 33ae4238962a1..bf41c13426bbf 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java @@ -19,27 +19,53 @@ package org.apache.hudi.client; +import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.TableServiceType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieCleanConfig; +import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieNotSupportedException; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.table.HoodieTable; import org.apache.hudi.testutils.HoodieFlinkClientTestHarness; +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.ValueSource; import java.io.IOException; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class TestFlinkWriteClient extends HoodieFlinkClientTestHarness { @BeforeEach - private void setup() throws IOException { + void setup() throws IOException { initPath(); initFileSystem(); initMetaClient(); } + @AfterEach + void teardown() throws IOException { + cleanupResources(); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) public void testWriteClientAndTableServiceClientWithTimelineServer( @@ -61,4 +87,110 @@ public void testWriteClientAndTableServiceClientWithTimelineServer( writeClient.close(); } + + @Test + public void testReleaseAndPostCommitResourcesForLazyFailedWrites() throws IOException { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withEngineType(EngineType.FLINK) + .withCleanConfig(HoodieCleanConfig.newBuilder() + .withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY) + .build()) + .build(); + + AtomicBoolean failTableCreation = new AtomicBoolean(false); + writeClient = new HoodieFlinkWriteClient(context, writeConfig) { + @Override + protected HoodieTable createTable(HoodieWriteConfig config) { + if (failTableCreation.get()) { + throw new HoodieException("Expected table creation failure"); + } + return super.createTable(config); + } + }; + String instantTime = "20260709120000000"; + writeClient.restartHeartbeat(instantTime); + + assertTrue(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metaClient.getBasePath().toString(), instantTime)); + + writeClient.releaseResources(instantTime); + assertTrue(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metaClient.getBasePath().toString(), instantTime)); + + writeClient.postCommit(instantTime); + assertFalse(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metaClient.getBasePath().toString(), instantTime)); + + String failedPostCommitInstantTime = "20260709120000001"; + writeClient.restartHeartbeat(failedPostCommitInstantTime); + failTableCreation.set(true); + assertThrows(HoodieException.class, () -> writeClient.postCommit(failedPostCommitInstantTime)); + assertFalse(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metaClient.getBasePath().toString(), failedPostCommitInstantTime)); + } + + @Test + public void testCleanResourcesCleansMetadataTableHeartbeatForStreamingMetadataWrites() throws IOException { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withEngineType(EngineType.FLINK) + .withIndexConfig(HoodieIndexConfig.newBuilder() + .withIndexType(HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX) + .build()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withStreamingWriteEnabled(true) + .withEnableGlobalRecordLevelIndex(true) + .build()) + .withCleanConfig(HoodieCleanConfig.newBuilder() + .withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY) + .build()) + .build(); + + writeClient = new HoodieFlinkWriteClient(context, writeConfig, true); + String instantTime = "20260709120000000"; + writeClient.restartHeartbeat(instantTime); + + String metadataTableBasePath = metaClient.getBasePath() + + "/" + HoodieTableMetaClient.METADATA_TABLE_FOLDER_PATH; + assertTrue(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metadataTableBasePath, instantTime)); + + writeClient.cleanResources(instantTime); + assertFalse(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metaClient.getBasePath().toString(), instantTime)); + assertFalse(HoodieHeartbeatClient.heartbeatExists( + metaClient.getStorage(), metadataTableBasePath, instantTime)); + } + + @Test + void testUnsupportedWriteEntryPointsAndInvalidTableServiceFailFast() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withEngineType(EngineType.FLINK) + .withEmbeddedTimelineServerEnabled(false) + .build(); + writeClient = new HoodieFlinkWriteClient(context, writeConfig); + + assertThrows(HoodieNotSupportedException.class, () -> writeClient.bootstrap(Option.empty())); + assertThrows(HoodieNotSupportedException.class, + () -> writeClient.insertPreppedRecords(Collections.emptyList(), "001")); + assertThrows(HoodieNotSupportedException.class, + () -> writeClient.bulkInsert(Collections.emptyList(), "001")); + assertThrows(HoodieNotSupportedException.class, + () -> writeClient.bulkInsert(Collections.emptyList(), "001", Option.empty())); + assertThrows(HoodieNotSupportedException.class, + () -> writeClient.cluster("001", false)); + assertThrows(HoodieException.class, + () -> writeClient.delete(Collections.singletonList(new HoodieKey("id", "partition")), "001")); + assertThrows(HoodieException.class, + () -> writeClient.deletePrepped(Collections.emptyList(), "001")); + assertThrows(IllegalArgumentException.class, + () -> writeClient.completeTableService(TableServiceType.CLEAN, null, null, "001")); + assertFalse(writeClient.loadActiveTimelineOnTableInit()); + writeClient.waitForCleaningFinish(); + writeClient.cleanHandles(); + assertNotNull(writeClient.getHoodieTable(false)); + } } diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClientFunctional.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClientFunctional.java new file mode 100644 index 0000000000000..4d5e2670db144 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClientFunctional.java @@ -0,0 +1,491 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hudi.client; + +import org.apache.hudi.avro.model.HoodieClusteringPlan; +import org.apache.hudi.client.clustering.plan.strategy.FlinkSizeBasedClusteringPlanStrategyRecently; +import org.apache.hudi.client.common.HoodieFlinkEngineContext; +import org.apache.hudi.client.model.HoodieFlinkRecord; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieOperation; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.cdc.HoodieCDCSupplementalLoggingMode; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.util.ClusteringUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieClusteringConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.io.FlinkCreateHandle; +import org.apache.hudi.io.FlinkMergeHandle; +import org.apache.hudi.io.FlinkWriteHandleFactory; +import org.apache.hudi.io.HoodieWriteMergeHandle; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.table.HoodieFlinkTable; +import org.apache.hudi.table.action.HoodieWriteMetadata; +import org.apache.hudi.table.action.commit.BucketInfo; +import org.apache.hudi.table.action.commit.BucketType; +import org.apache.hudi.testutils.HoodieFlinkClientTestHarness; + +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.StringData; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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.verify; +import static org.mockito.Mockito.when; + +/** + * Functional coverage for the Flink client write boundary. + * + *

The datasource bucket assigner hands this client records that already carry a target file group. + * These tests construct that same input directly so client and handle behavior can be exercised without + * depending on the datasource module. + */ +class TestFlinkWriteClientFunctional extends HoodieFlinkClientTestHarness { + + private static final String PARTITION_PATH = "2026/07/23"; + private static final String FILE_ID = "f0"; + private static final String SCHEMA = "{" + + "\"type\":\"record\"," + + "\"name\":\"flink_write_test\"," + + "\"fields\":[" + + "{\"name\":\"id\",\"type\":\"string\"}," + + "{\"name\":\"name\",\"type\":\"string\"}," + + "{\"name\":\"ts\",\"type\":\"long\"}" + + "]}"; + + private HoodieWriteConfig writeConfig; + + @BeforeEach + void setUp() { + initPath(); + initFileSystem(); + } + + @AfterEach + void tearDown() throws IOException { + cleanupResources(); + } + + static Stream tableTypesAndCdc() { + return Stream.of( + Arguments.of(HoodieTableType.COPY_ON_WRITE, false), + Arguments.of(HoodieTableType.COPY_ON_WRITE, true), + Arguments.of(HoodieTableType.MERGE_ON_READ, false), + Arguments.of(HoodieTableType.MERGE_ON_READ, true)); + } + + @ParameterizedTest + @MethodSource("tableTypesAndCdc") + void testInsertAndUpsertWriteFilesAndCommitMetadata(HoodieTableType tableType, boolean cdcEnabled) + throws IOException { + initWriteClient(tableType, cdcEnabled, false); + + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + List firstBatch = new ArrayList<>(Arrays.asList( + insertRecord("id1", "one", 1L), + insertRecord("id2", "two", 2L))); + if (tableType == HoodieTableType.MERGE_ON_READ) { + firstBatch.add(insertRecord("id3", "three", 3L)); + } + List firstInsertStatuses = writeClient.insert(firstBatch, insertInstant); + assertWriteStatuses(firstInsertStatuses, firstBatch.size()); + + // A second COW mini-batch for the same bucket exercises the incremental/replace handle. + List insertStatuses = tableType == HoodieTableType.COPY_ON_WRITE + ? writeClient.insert(Arrays.asList(insertRecord("id3", "three", 3L)), insertInstant) + : firstInsertStatuses; + if (tableType == HoodieTableType.COPY_ON_WRITE && cdcEnabled) { + insertStatuses = writeClient.upsert( + Arrays.asList(updateRecord("id1", "one-mini-batch-update", 4L, insertInstant)), + insertInstant); + } + assertWriteStatuses(insertStatuses, 3); + assertTrue(writeClient.commit(insertInstant, insertStatuses)); + assertCommitMetadata(insertInstant, tableType, 3); + + writeClient.cleanHandles(); + String updateInstant = writeClient.startCommit(); + transitionToInflight(updateInstant); + List updates = Arrays.asList( + updateRecord("id1", "one-updated", 11L, insertInstant), + deleteRecord("id2", 12L, insertInstant)); + List updateStatuses = writeClient.upsert(updates, updateInstant); + long expectedWrites = tableType == HoodieTableType.COPY_ON_WRITE ? 2 : 1; + assertWriteStatuses(updateStatuses, expectedWrites); + assertEquals(1, + updateStatuses.stream().map(WriteStatus::getStat).mapToLong(stat -> stat.getNumDeletes()).sum()); + assertTrue(writeClient.commit(updateInstant, updateStatuses)); + assertCommitMetadata(updateInstant, tableType, expectedWrites); + + if (tableType == HoodieTableType.COPY_ON_WRITE && cdcEnabled) { + assertTrue(updateStatuses.stream() + .map(WriteStatus::getStat) + .anyMatch(stat -> stat.getCdcStats() != null && !stat.getCdcStats().isEmpty())); + } + } + + @Test + void testCopyOnWriteCleansRetryFiles() throws IOException { + context = new HoodieFlinkEngineContext( + new HoodieFlinkEngineContext.DefaultTaskContextSupplier() { + @Override + public Supplier getAttemptIdSupplier() { + return () -> 1L; + } + }); + initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false); + + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + StoragePath staleInsertPath = createInvalidRetryFile(insertInstant); + List insertStatuses = writeClient.insert( + Collections.singletonList(insertRecord("id1", "one", 1L)), insertInstant); + assertWriteStatuses(insertStatuses, 1); + assertFalse(metaClient.getStorage().exists(staleInsertPath)); + assertTrue(writeClient.commit(insertInstant, insertStatuses)); + + writeClient.cleanHandles(); + String updateInstant = writeClient.startCommit(); + transitionToInflight(updateInstant); + StoragePath staleUpdatePath = createInvalidRetryFile(updateInstant); + List updateStatuses = writeClient.upsert( + Collections.singletonList(updateRecord("id1", "one-updated", 2L, insertInstant)), + updateInstant); + assertWriteStatuses(updateStatuses, 1); + assertFalse(metaClient.getStorage().exists(staleUpdatePath)); + assertTrue(writeClient.commit(updateInstant, updateStatuses)); + } + + @Test + void testCopyOnWriteHandleRolloverAndGracefulCloseCleanup() throws IOException { + initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false); + + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + writeClient.insert(Arrays.asList( + insertRecord("id1", "one", 1L), + insertRecord("id2", "two", 2L)), insertInstant); + List insertStatuses = writeClient.insert( + Collections.singletonList(insertRecord("id3", "three", 3L)), insertInstant); + assertWriteStatuses(insertStatuses, 3); + + HoodieFlinkTable table = writeClient.getHoodieTable(); + FlinkCreateHandle rolloverHandle = new FlinkCreateHandle( + writeConfig, insertInstant, table, PARTITION_PATH, FILE_ID, table.getTaskContextSupplier()); + assertTrue(rolloverHandle.canWrite(insertRecord("id4", "four", 4L))); + assertNotEquals(insertStatuses.get(0).getStat().getPath(), rolloverHandle.getWritePath().toString()); + rolloverHandle.closeGracefully(); + // Closing gracefully is intentionally idempotent. + rolloverHandle.closeGracefully(); + + FlinkCreateHandle failingHandle = new FlinkCreateHandle( + writeConfig, insertInstant, table, PARTITION_PATH, FILE_ID, table.getTaskContextSupplier()) { + @Override + public List close() { + super.close(); + throw new IllegalStateException("expected close failure"); + } + }; + StoragePath failedCreatePath = failingHandle.getWritePath(); + failingHandle.closeGracefully(); + assertFalse(metaClient.getStorage().exists(failedCreatePath)); + assertTrue(writeClient.commit(insertInstant, insertStatuses)); + + String mergeInstant = writeClient.startCommit(); + transitionToInflight(mergeInstant); + table = writeClient.getHoodieTable(); + FlinkMergeHandle failingMergeHandle = new FlinkMergeHandle( + writeConfig, + mergeInstant, + table, + Collections.emptyList().iterator(), + PARTITION_PATH, + FILE_ID, + table.getTaskContextSupplier()) { + @Override + public List close() { + super.close(); + throw new IllegalStateException("expected close failure"); + } + }; + StoragePath failedMergePath = failingMergeHandle.getWritePath(); + failingMergeHandle.closeGracefully(); + assertFalse(metaClient.getStorage().exists(failedMergePath)); + } + + @Test + void testScheduleClusteringFromRecentlyWrittenPartition() throws IOException { + initWriteClient(HoodieTableType.COPY_ON_WRITE, false, true); + + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + List statuses = writeClient.insert(Arrays.asList( + insertRecord("id1", "one", 1L), + insertRecord("id2", "two", 2L)), insertInstant); + assertTrue(writeClient.commit(insertInstant, statuses)); + + Option clusteringInstant = writeClient.scheduleClustering(Option.empty()); + assertTrue(clusteringInstant.isPresent()); + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieInstant requestedInstant = metaClient.getInstantGenerator() + .getClusteringCommitRequestedInstant(clusteringInstant.get()); + HoodieClusteringPlan clusteringPlan = ClusteringUtils + .getClusteringPlan(metaClient, requestedInstant).get().getRight(); + assertEquals(1, clusteringPlan.getInputGroups().size()); + assertEquals(PARTITION_PATH, + clusteringPlan.getInputGroups().get(0).getSlices().get(0).getPartitionPath()); + } + + @Test + void testPreppedWriteEntryPointsCommitMetadata() throws IOException { + initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false); + + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + List insertStatuses = writeClient.insert(Arrays.asList( + insertRecord("id1", "one", 1L), + insertRecord("id2", "two", 2L)), insertInstant); + assertTrue(writeClient.commit(insertInstant, insertStatuses)); + + writeClient.cleanHandles(); + String upsertInstant = writeClient.startCommit(); + transitionToInflight(upsertInstant); + List upsertStatuses = writeClient.upsertPreppedRecords( + Collections.singletonList(updateRecord("id1", "one-prepped", 3L, insertInstant)), + upsertInstant); + assertWriteStatuses(upsertStatuses, 2); + assertTrue(writeClient.commit(upsertInstant, upsertStatuses)); + assertCommitMetadata(upsertInstant, HoodieTableType.COPY_ON_WRITE, 2); + + writeClient.cleanHandles(); + String bulkInsertInstant = writeClient.startCommit(); + transitionToInflight(bulkInsertInstant); + List bulkInsertStatuses = writeClient.bulkInsertPreppedRecords( + Collections.singletonList(insertRecord("id3", "three", 4L)), + bulkInsertInstant, + Option.empty()); + assertWriteStatuses(bulkInsertStatuses, 1); + assertTrue(writeClient.commit(bulkInsertInstant, bulkInsertStatuses)); + assertCommitMetadata(bulkInsertInstant, HoodieTableType.COPY_ON_WRITE, 1); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void testClientRoutesOverwriteAndDeleteActionsAfterPriorCommit() throws IOException { + initWriteClient(HoodieTableType.COPY_ON_WRITE, false, false); + String insertInstant = writeClient.startCommit(); + transitionToInflight(insertInstant); + List insertStatuses = writeClient.insert( + Collections.singletonList(insertRecord("id1", "one", 1L)), insertInstant); + assertTrue(writeClient.commit(insertInstant, insertStatuses)); + Map> replacedFileIds = + writeClient.getPartitionToReplacedFileIds(WriteOperationType.INSERT_OVERWRITE, insertStatuses); + assertEquals(Collections.singleton(FILE_ID), + new HashSet<>(replacedFileIds.get(PARTITION_PATH))); + + HoodieFlinkTable table = mock(HoodieFlinkTable.class); + when(table.getMetaClient()).thenReturn(metaClient); + HoodieWriteMetadata> metadata = new HoodieWriteMetadata<>(); + metadata.setWriteStatuses(Collections.emptyList()); + when(table.insertOverwrite(any(), any(), any(), anyString(), any())).thenReturn(metadata); + when(table.insertOverwriteTable(any(), any(), any(), anyString(), any())).thenReturn(metadata); + when(table.delete(any(), anyString(), any())).thenReturn(metadata); + when(table.deletePrepped(any(), anyString(), any())).thenReturn(metadata); + when(table.deletePartitions(any(), anyString(), any())).thenReturn(metadata); + + HoodieFlinkWriteClient routingClient = new HoodieFlinkWriteClient(context, writeConfig) { + @Override + protected org.apache.hudi.table.HoodieTable createTable( + HoodieWriteConfig config, HoodieTableMetaClient ignoredMetaClient) { + return table; + } + }; + FlinkWriteHandleFactory.Factory handleFactory = mock(FlinkWriteHandleFactory.Factory.class); + FlinkCreateHandle writeHandle = mock(FlinkCreateHandle.class); + when(handleFactory.create(any(), any(), any(), anyString(), any(), any())).thenReturn(writeHandle); + BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, FILE_ID, PARTITION_PATH); + List deleteKeys = Collections.singletonList(new HoodieKey("id1", PARTITION_PATH)); + List preppedDeletes = Collections.emptyList(); + List partitions = Collections.singletonList(PARTITION_PATH); + + try (MockedStatic factory = Mockito.mockStatic(FlinkWriteHandleFactory.class)) { + factory.when(() -> FlinkWriteHandleFactory.getFactory(any(), any(), anyBoolean())) + .thenReturn(handleFactory); + assertTrue(routingClient.insertOverwrite( + Collections.emptyList().iterator(), bucketInfo, "001").isEmpty()); + assertTrue(routingClient.insertOverwriteTable( + Collections.emptyList().iterator(), bucketInfo, "002").isEmpty()); + assertTrue(routingClient.delete(deleteKeys, "003").isEmpty()); + assertTrue(routingClient.deletePrepped(preppedDeletes, "004").isEmpty()); + assertTrue(routingClient.deletePartitions(partitions, "005").isEmpty()); + + verify(table).insertOverwrite(eq(context), eq(writeHandle), eq(bucketInfo), eq("001"), any()); + verify(table).insertOverwriteTable(eq(context), eq(writeHandle), eq(bucketInfo), eq("002"), any()); + verify(table).delete(eq(context), eq("003"), eq(deleteKeys)); + verify(table).deletePrepped(eq(context), eq("004"), eq(preppedDeletes)); + verify(table).deletePartitions(eq(context), eq("005"), eq(partitions)); + } finally { + routingClient.close(); + } + } + + private HoodieRecord insertRecord(String key, String name, long ts) { + return record(key, name, ts, HoodieOperation.INSERT, "I"); + } + + private HoodieRecord updateRecord(String key, String name, long ts, String instantTime) { + return record(key, name, ts, HoodieOperation.UPDATE_AFTER, instantTime); + } + + private HoodieRecord deleteRecord(String key, long ts, String instantTime) { + return record(key, "deleted", ts, HoodieOperation.DELETE, instantTime); + } + + private HoodieRecord record( + String key, String name, long ts, HoodieOperation operation, String locationInstant) { + GenericRowData row = GenericRowData.of( + StringData.fromString(key), StringData.fromString(name), ts); + HoodieFlinkRecord record = new HoodieFlinkRecord( + new HoodieKey(key, PARTITION_PATH), operation, ts, row); + record.setCurrentLocation(new HoodieRecordLocation(locationInstant, FILE_ID)); + return record; + } + + private void initWriteClient( + HoodieTableType tableType, boolean cdcEnabled, boolean useRecentClusteringStrategy) + throws IOException { + Properties tableProperties = new Properties(); + tableProperties.setProperty(HoodieTableConfig.CDC_ENABLED.key(), Boolean.toString(cdcEnabled)); + tableProperties.setProperty( + HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key(), + HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER.name()); + tableProperties.setProperty(HoodieTableConfig.RECORDKEY_FIELDS.key(), "id"); + tableProperties.setProperty(HoodieTableConfig.PARTITION_FIELDS.key(), "partition_path"); + tableProperties.setProperty(HoodieTableConfig.ORDERING_FIELDS.key(), "ts"); + tableProperties.setProperty( + HoodieWriteConfig.MERGE_ALLOW_DUPLICATE_ON_INSERTS_ENABLE.key(), "false"); + metaClient = HoodieTableMetaClient.newTableBuilder() + .setTableName("flink_write_client_test") + .setTableType(tableType) + .fromProperties(tableProperties) + .initTable(storageConf, basePath); + + HoodieWriteConfig.Builder builder = HoodieWriteConfig.newBuilder() + .withPath(basePath) + .withEngineType(EngineType.FLINK) + .withSchema(SCHEMA) + .withProperties(tableProperties) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()) + .withMergeHandleClassName(HoodieWriteMergeHandle.class.getName()); + if (useRecentClusteringStrategy) { + builder.withClusteringConfig(HoodieClusteringConfig.newBuilder() + .withEngineType(EngineType.FLINK) + .withClusteringPlanStrategyClass(FlinkSizeBasedClusteringPlanStrategyRecently.class.getName()) + .withClusteringPlanSmallFileLimit(Long.MAX_VALUE) + .withClusteringMaxNumGroups(10) + .withClusteringSortColumns("id") + .build()); + } + writeConfig = builder.build(); + writeClient = new HoodieFlinkWriteClient<>(context, writeConfig); + } + + private void assertWriteStatuses(List statuses, long expectedRecords) { + assertFalse(statuses.isEmpty()); + assertTrue(statuses.stream().noneMatch(WriteStatus::hasErrors)); + assertEquals(expectedRecords, + statuses.stream().map(WriteStatus::getStat).mapToLong(stat -> stat.getNumWrites()).sum()); + statuses.forEach(status -> { + assertEquals(PARTITION_PATH, status.getStat().getPartitionPath()); + assertNotNull(status.getStat().getPath()); + }); + } + + private void transitionToInflight(String instantTime) { + metaClient.reloadActiveTimeline(); + metaClient.getActiveTimeline().transitionRequestedToInflight( + metaClient.getCommitActionType(), instantTime); + } + + private StoragePath createInvalidRetryFile(String instantTime) throws IOException { + StoragePath partitionPath = new StoragePath(metaClient.getBasePath(), PARTITION_PATH); + metaClient.getStorage().createDirectory(partitionPath); + String fileName = FSUtils.makeBaseFileName( + instantTime, + FSUtils.makeWriteToken(0, 1, 0), + FILE_ID, + metaClient.getTableConfig().getBaseFileFormat().getFileExtension()); + StoragePath retryPath = new StoragePath(partitionPath, fileName); + metaClient.getStorage().create(retryPath).close(); + return retryPath; + } + + private void assertCommitMetadata(String instantTime, HoodieTableType tableType, long expectedRecords) + throws IOException { + metaClient = HoodieTableMetaClient.reload(metaClient); + String action = metaClient.getCommitActionType(); + HoodieInstant instant = metaClient.getActiveTimeline() + .getTimelineOfActions(Collections.singleton(action)) + .filterCompletedInstants() + .getInstantsAsStream() + .filter(candidate -> candidate.requestedTime().equals(instantTime)) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing completed instant " + instantTime)); + assertEquals(expectedRecords, + metaClient.getActiveTimeline().readCommitMetadata(instant).fetchTotalRecordsWritten()); + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java index e54793d3926ad..4c4aa4fb932ef 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java @@ -18,29 +18,51 @@ package org.apache.hudi.client; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage; import org.apache.hudi.client.embedded.EmbeddedTimelineService; import org.apache.hudi.client.common.HoodieFlinkEngineContext; import org.apache.hudi.client.transaction.lock.InProcessLockProvider; import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieReplaceCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.model.TableServiceType; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; +import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.util.ClusteringUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieLockConfig; +import org.apache.hudi.config.HoodieWriteCommitCallbackConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.metadata.FlinkHoodieBackedTableMetadataWriter; import org.apache.hudi.storage.StorageConfiguration; import org.apache.hudi.table.HoodieFlinkTable; import org.apache.hudi.table.HoodieTable; +import org.apache.hudi.table.action.HoodieWriteMetadata; +import org.apache.hudi.table.action.compact.CompactHelpers; +import org.apache.hudi.table.marker.WriteMarkers; +import org.apache.hudi.table.marker.WriteMarkersFactory; import org.apache.hudi.testutils.HoodieFlinkClientTestHarness; +import org.apache.hudi.testutils.RecordingCommitCallback; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; import org.mockito.Mockito; import java.io.IOException; +import java.util.Collections; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -75,11 +97,13 @@ void testInitMetadataTableRespectsStreamingWriteFlag(boolean metadataStreamingWr HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class); HoodieTimeline inflightAndRequestedTimeline = mock(HoodieTimeline.class); when(table.getActiveTimeline()).thenReturn(activeTimeline); + when(table.getMetaClient()).thenReturn(metaClient); when(activeTimeline.filterInflightsAndRequested()).thenReturn(inflightAndRequestedTimeline); when(inflightAndRequestedTimeline.lastInstant()).thenReturn(Option.empty()); FlinkHoodieBackedTableMetadataWriter metadataWriter = mock(FlinkHoodieBackedTableMetadataWriter.class); when(metadataWriter.isInitialized()).thenReturn(true); + when(metadataWriter.hasPartitionsStateChanged()).thenReturn(true); TestableHoodieFlinkTableServiceClient tableServiceClient = new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), table); @@ -98,6 +122,144 @@ void testInitMetadataTableRespectsStreamingWriteFlag(boolean metadataStreamingWr verify(table, never()).maybeDeleteMetadataTable(); } + @Test + void testInitMetadataTableWrapsMetadataWriterFailure() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withLockConfig(HoodieLockConfig.newBuilder() + .withLockProvider(InProcessLockProvider.class) + .build()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(true).build()) + .build(); + HoodieFlinkTable table = mock(HoodieFlinkTable.class); + HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class); + HoodieTimeline pendingTimeline = mock(HoodieTimeline.class); + when(table.getActiveTimeline()).thenReturn(activeTimeline); + when(activeTimeline.filterInflightsAndRequested()).thenReturn(pendingTimeline); + when(pendingTimeline.lastInstant()).thenReturn(Option.empty()); + + TestableHoodieFlinkTableServiceClient client = + new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), table); + try (MockedStatic writerFactory = + Mockito.mockStatic(FlinkHoodieBackedTableMetadataWriter.class)) { + writerFactory.when(() -> FlinkHoodieBackedTableMetadataWriter.create(any(), any(), any(), any())) + .thenThrow(new IllegalStateException("expected metadata writer failure")); + assertThrows(HoodieException.class, client::initMetadataTable); + } finally { + client.close(); + } + } + + @Test + void testMetadataDisabledDeletesStaleMetadataTable() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()) + .build(); + HoodieFlinkTable table = mock(HoodieFlinkTable.class); + TestableHoodieFlinkTableServiceClient client = + new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), table); + try { + client.initMetadataTable(); + } finally { + client.close(); + } + + verify(table).maybeDeleteMetadataTable(); + verify(table, never()).deleteMetadataIndexIfNecessary(); + } + + @Test + void testOutputConversionAndNoOpHooks() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()) + .build(); + TestableHoodieFlinkTableServiceClient client = + new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), mock(HoodieTable.class)); + try { + WriteStatus status = new WriteStatus(false, 0.0); + status.setStat(new HoodieWriteStat()); + HoodieWriteMetadata> metadata = new HoodieWriteMetadata<>(); + metadata.setWriteStatuses(Collections.singletonList(status)); + + client.callTriggerWritesAndFetchWriteStats(metadata); + assertSame(metadata, client.callConvertToOutputMetadata(metadata)); + client.callHandleWriteErrors(Collections.singletonList(status.getStat())); + // cluster() is intentionally unimplemented for Flink. + assertNull(client.cluster("001", false)); + assertNotNull(client.createRealTable()); + } finally { + client.close(); + } + } + + @Test + void testCompleteCompactionCommitsAndCleansMarkers() { + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()) + .build(); + HoodieTable table = mock(HoodieTable.class); + when(table.getInstantGenerator()).thenReturn(metaClient.getInstantGenerator()); + HoodieCommitMetadata metadata = new HoodieCommitMetadata(); + TestableHoodieFlinkTableServiceClient client = + new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), table); + CompactHelpers compactHelpers = mock(CompactHelpers.class); + WriteMarkers writeMarkers = mock(WriteMarkers.class); + + try (MockedStatic helpersFactory = Mockito.mockStatic(CompactHelpers.class); + MockedStatic markersFactory = Mockito.mockStatic(WriteMarkersFactory.class)) { + helpersFactory.when(CompactHelpers::getInstance).thenReturn(compactHelpers); + markersFactory.when(() -> WriteMarkersFactory.get(any(), any(), any())).thenReturn(writeMarkers); + client.callCompleteCompaction(metadata, table, "20260723120000000"); + } finally { + client.close(); + } + + verify(compactHelpers).completeInflightCompaction(table, "20260723120000000", metadata); + verify(writeMarkers).quietDeleteMarkerDir(any(), any(Integer.class)); + } + + @Test + void testCompleteClusteringCommitsAndCleansMarkers() { + RecordingCommitCallback.reset(); + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()) + .withCallbackConfig(HoodieWriteCommitCallbackConfig.newBuilder() + .writeCommitCallbackOn("true") + .withCallbackClass(RecordingCommitCallback.class.getName()) + .build()) + .build(); + HoodieFlinkTable table = mock(HoodieFlinkTable.class); + HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class); + when(table.getActiveTimeline()).thenReturn(activeTimeline); + when(table.getInstantGenerator()).thenReturn(metaClient.getInstantGenerator()); + HoodieInstant clusteringInstant = mock(HoodieInstant.class); + when(clusteringInstant.getAction()).thenReturn(HoodieTimeline.CLUSTERING_ACTION); + HoodieReplaceCommitMetadata metadata = new HoodieReplaceCommitMetadata(); + TestableHoodieFlinkTableServiceClient client = + new TestableHoodieFlinkTableServiceClient(context, writeConfig, Option.empty(), table); + WriteMarkers writeMarkers = mock(WriteMarkers.class); + + try (MockedStatic clusteringUtils = Mockito.mockStatic(ClusteringUtils.class); + MockedStatic markersFactory = Mockito.mockStatic(WriteMarkersFactory.class)) { + clusteringUtils.when(() -> ClusteringUtils.getInflightClusteringInstant( + "20260723120000001", activeTimeline, metaClient.getInstantGenerator())) + .thenReturn(Option.of(clusteringInstant)); + markersFactory.when(() -> WriteMarkersFactory.get(any(), any(), any())).thenReturn(writeMarkers); + client.callCompleteClustering(metadata, table, "20260723120000001"); + } finally { + client.close(); + } + + verify(writeMarkers).quietDeleteMarkerDir(any(), any(Integer.class)); + List messages = RecordingCommitCallback.messages(); + assertEquals(1, messages.size(), "callback must fire once for the clustering commit"); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, messages.get(0).getCommitActionType().orElse(null)); + } + private static class TestableHoodieFlinkTableServiceClient extends HoodieFlinkTableServiceClient { private final HoodieTable mockedTable; @@ -113,5 +275,42 @@ protected TestableHoodieFlinkTableServiceClient(HoodieFlinkEngineContext context protected HoodieTable createTable(HoodieWriteConfig config, StorageConfiguration storageConf, boolean skipValidation) { return mockedTable; } + + private void callTriggerWritesAndFetchWriteStats(HoodieWriteMetadata> metadata) { + triggerWritesAndFetchWriteStats(metadata); + } + + private HoodieWriteMetadata> callConvertToOutputMetadata( + HoodieWriteMetadata> metadata) { + return convertToOutputMetadata(metadata); + } + + private void callHandleWriteErrors(java.util.List writeStats) { + handleWriteErrors(writeStats, TableServiceType.COMPACT); + } + + private HoodieTable createRealTable() { + return super.createTable(config, storageConf, false); + } + + private void callCompleteCompaction( + HoodieCommitMetadata metadata, HoodieTable table, String instantTime) { + completeCompaction(metadata, table, instantTime, Collections.emptyList()); + } + + private void callCompleteClustering( + HoodieReplaceCommitMetadata metadata, HoodieTable table, String instantTime) { + completeClustering(metadata, table, instantTime); + } + + @Override + protected void finalizeWrite(HoodieTable table, String instantTime, java.util.List stats) { + // The test covers Flink orchestration; base finalize-write behavior is covered by client-common tests. + } + + @Override + protected void writeTableMetadata(HoodieTable table, String instantTime, HoodieCommitMetadata metadata) { + // The test covers Flink orchestration; metadata writer behavior is covered independently. + } } } diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkInternalRowSerializer.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkInternalRowSerializer.java new file mode 100644 index 0000000000000..60227372c7f18 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkInternalRowSerializer.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.hudi.client.model; + +import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataOutputSerializer; +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.GenericRowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.RowType; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestHoodieFlinkInternalRowSerializer { + + private static final RowType ROW_TYPE = (RowType) DataTypes.ROW( + DataTypes.FIELD("name", DataTypes.STRING()), + DataTypes.FIELD("amount", DataTypes.DECIMAL(10, 2)), + DataTypes.FIELD("created", DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)), + DataTypes.FIELD("tags", DataTypes.ARRAY(DataTypes.STRING()))) + .getLogicalType(); + + @Test + void testRoundTripCopyAndStreamCopy() throws Exception { + HoodieFlinkInternalRowSerializer serializer = new HoodieFlinkInternalRowSerializer(ROW_TYPE); + GenericRowData payload = GenericRowData.of( + StringData.fromString("alice"), + DecimalData.fromBigDecimal(new BigDecimal("12.34"), 10, 2), + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123456Z")), + new GenericArrayData(new Object[] {StringData.fromString("x"), null})); + HoodieFlinkInternalRow original = new HoodieFlinkInternalRow( + "key", "partition", "file", "instant", "I", false, payload); + + DataOutputSerializer output = new DataOutputSerializer(128); + serializer.serialize(original, output); + HoodieFlinkInternalRow restored = serializer.deserialize( + new DataInputDeserializer(output.getCopyOfBuffer())); + assertDataRecord(restored); + + HoodieFlinkInternalRow copied = serializer.copy(original); + assertNotSame(original, copied); + assertNotSame(original.getRowData(), copied.getRowData()); + assertDataRecord(copied); + + DataOutputSerializer copiedOutput = new DataOutputSerializer(128); + serializer.copy(new DataInputDeserializer(output.getCopyOfBuffer()), copiedOutput); + assertDataRecord(serializer.deserialize(new DataInputDeserializer(copiedOutput.getCopyOfBuffer()))); + } + + @Test + void testIndexRecordRoundTripAndSerializerContract() throws Exception { + HoodieFlinkInternalRowSerializer serializer = new HoodieFlinkInternalRowSerializer(ROW_TYPE); + HoodieFlinkInternalRow indexRecord = + new HoodieFlinkInternalRow("key", "partition", "file", "instant"); + DataOutputSerializer output = new DataOutputSerializer(64); + serializer.serialize(indexRecord, output); + + HoodieFlinkInternalRow restored = serializer.deserialize( + indexRecord, new DataInputDeserializer(output.getCopyOfBuffer())); + assertTrue(restored.isIndexRecord()); + assertEquals("key", restored.getRecordKey()); + assertEquals("partition", restored.getPartitionPath()); + assertEquals("file", restored.getFileId()); + assertEquals("instant", restored.getInstantTime()); + assertEquals("", restored.getOperationType()); + + DataOutputSerializer copiedOutput = new DataOutputSerializer(64); + serializer.copy(new DataInputDeserializer(output.getCopyOfBuffer()), copiedOutput); + assertTrue(serializer.deserialize( + new DataInputDeserializer(copiedOutput.getCopyOfBuffer())).isIndexRecord()); + + assertFalse(serializer.isImmutableType()); + assertEquals(-1, serializer.getLength()); + assertEquals(serializer, serializer.duplicate()); + assertEquals(serializer.hashCode(), serializer.duplicate().hashCode()); + assertFalse(serializer.equals(null)); + assertFalse(serializer.equals("serializer")); + assertThrows(UnsupportedOperationException.class, serializer::createInstance); + assertThrows(UnsupportedOperationException.class, + () -> serializer.copy(indexRecord, indexRecord)); + assertThrows(UnsupportedOperationException.class, serializer::snapshotConfiguration); + } + + private static void assertDataRecord(HoodieFlinkInternalRow record) { + assertFalse(record.isIndexRecord()); + assertEquals("key", record.getRecordKey()); + assertEquals("partition", record.getPartitionPath()); + assertEquals("file", record.getFileId()); + assertEquals("instant", record.getInstantTime()); + assertEquals("I", record.getOperationType()); + assertEquals("alice", record.getRowData().getString(0).toString()); + assertEquals(new BigDecimal("12.34"), record.getRowData().getDecimal(1, 10, 2).toBigDecimal()); + assertEquals(Instant.parse("2025-02-03T04:05:06.123456Z"), + record.getRowData().getTimestamp(2, 6).toInstant()); + assertEquals("x", record.getRowData().getArray(3).getString(0).toString()); + assertTrue(record.getRowData().getArray(3).isNullAt(1)); + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkRecord.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkRecord.java index 6744e2d14830b..9753628da4101 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkRecord.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/model/TestHoodieFlinkRecord.java @@ -20,20 +20,33 @@ import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieOperation; +import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; +import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.OrderingValues; +import org.apache.flink.table.data.DecimalData; 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.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; import java.util.Arrays; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Unit tests for {@link HoodieFlinkRecord}. @@ -266,4 +279,107 @@ public void testUpdateMetaFieldWithoutOperationField() { HoodieFlinkRecord updatedRecord = (HoodieFlinkRecord) record.updateMetaField(schema, 0, "20240101000001"); assertEquals(HoodieOperation.INSERT, updatedRecord.getOperation()); } + + @Test + public void testRecordContract() { + HoodieKey key = new HoodieKey("id-001", "partition-1"); + GenericRowData row = GenericRowData.of(StringData.fromString("id-001")); + HoodieFlinkRecord record = new HoodieFlinkRecord( + key, HoodieOperation.INSERT, 100L, row, true); + + assertEquals(HoodieRecord.HoodieRecordType.FLINK, record.getRecordType()); + assertEquals(key, record.newInstance().getKey()); + assertEquals("new-key", record.newInstance( + new HoodieKey("new-key", "p"), HoodieOperation.UPDATE_AFTER).getRecordKey()); + assertEquals("another-key", record.newInstance( + new HoodieKey("another-key", "p")).getRecordKey()); + assertFalse(record.shouldIgnore(null, new Properties())); + assertSame(record, record.copy()); + assertTrue(record.getMetadata().isEmpty()); + + HoodieFlinkRecord empty = new HoodieFlinkRecord( + key, HoodieOperation.INSERT, (RowData) null); + assertTrue(empty.checkIsDelete(null, new Properties())); + } + + @Test + public void testConvertColumnValueForLogicalType() { + TimestampData timestamp = TimestampData.fromInstant( + Instant.parse("2025-02-03T04:05:06.123456Z")); + HoodieFlinkRecord record = new HoodieFlinkRecord(GenericRowData.of(timestamp)); + + assertNull(record.convertColumnValueForLogicalType( + HoodieSchema.create(HoodieSchemaType.STRING), null, true)); + assertEquals(LocalDate.ofEpochDay(2), record.convertColumnValueForLogicalType( + HoodieSchema.createDate(), 2, true)); + + HoodieSchema millisSchema = HoodieSchema.createTimestampMillis(); + HoodieSchema millisRecordSchema = HoodieSchema.createRecord( + "millis_record", null, null, + Arrays.asList(HoodieSchemaField.of("event_time", millisSchema))); + Object millisValue = record.getColumnValueAsJava( + millisRecordSchema, "event_time", new Properties()); + assertEquals(timestamp.getMillisecond(), millisValue); + assertEquals(timestamp.getMillisecond(), record.convertColumnValueForLogicalType( + millisSchema, millisValue, true)); + + HoodieSchema microsSchema = HoodieSchema.createTimestampMicros(); + HoodieSchema microsRecordSchema = HoodieSchema.createRecord( + "micros_record", null, null, + Arrays.asList(HoodieSchemaField.of("event_time", microsSchema))); + Object microsValue = record.getColumnValueAsJava( + microsRecordSchema, "event_time", new Properties()); + long expectedMicros = timestamp.toInstant().getEpochSecond() * 1_000_000 + + timestamp.toInstant().getNano() / 1_000; + assertEquals(expectedMicros, microsValue); + assertEquals(timestamp.getMillisecond(), record.convertColumnValueForLogicalType( + microsSchema, microsValue, true)); + + assertEquals(new BigDecimal("12.34"), record.convertColumnValueForLogicalType( + HoodieSchema.createDecimal("decimal", null, null, 10, 2, 5), + DecimalData.fromBigDecimal(new BigDecimal("12.34"), 10, 2), true)); + assertSame(millisValue, record.convertColumnValueForLogicalType( + millisSchema, millisValue, false)); + } + + @Test + public void testUnsupportedOperations() { + HoodieKey key = new HoodieKey("id-001", "partition-1"); + GenericRowData row = GenericRowData.of(StringData.fromString("id-001")); + HoodieFlinkRecord record = new HoodieFlinkRecord( + key, HoodieOperation.INSERT, 100L, row, true); + assertThrows(UnsupportedOperationException.class, + () -> record.writeRecordPayload(row, null, null)); + assertThrows(UnsupportedOperationException.class, + () -> record.readRecordPayload(null, null)); + assertThrows(UnsupportedOperationException.class, + () -> record.getColumnValues(null, null, false)); + assertThrows(UnsupportedOperationException.class, + () -> record.joinWith(record, null)); + assertThrows(UnsupportedOperationException.class, + () -> record.wrapIntoHoodieRecordPayloadWithParams( + null, null, Option.empty(), false, Option.empty(), false, Option.empty())); + assertThrows(UnsupportedOperationException.class, + () -> record.wrapIntoHoodieRecordPayloadWithKeyGen(null, null, Option.empty())); + assertThrows(UnsupportedOperationException.class, + () -> record.truncateRecordKey(null, null, null)); + } + + @Test + public void testKeyLookupAndAvroMaterialization() { + HoodieSchema schema = HoodieSchema.createRecord("test", null, null, Arrays.asList( + HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.STRING)), + HoodieSchemaField.of("value", HoodieSchema.create(HoodieSchemaType.INT)))); + HoodieFlinkRecord record = new HoodieFlinkRecord(GenericRowData.of( + StringData.fromString("id-001"), 42)); + + assertEquals("id-001", record.getRecordKey(schema, "id")); + // The second lookup exercises the cached record-key path. + assertEquals("id-001", record.getRecordKey(schema, "id")); + assertEquals("id-001", record.toIndexedRecord(schema, new Properties()) + .get().getData().get(0).toString()); + assertTrue(record.getAvroBytes(schema, new Properties()).size() > 0); + assertEquals(OrderingValues.getDefault(), + record.getOrderingValueAsJava(schema, new Properties(), null)); + } } diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieFlinkLanceArrowUtils.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieFlinkLanceArrowUtils.java new file mode 100644 index 0000000000000..2a9ecc5b01450 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieFlinkLanceArrowUtils.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.hudi.io.storage.row; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.TimeStampMicroVector; +import org.apache.arrow.vector.types.TimeUnit; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.TimestampType; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * Tests for {@link HoodieFlinkLanceArrowUtils}. + */ +public class TestHoodieFlinkLanceArrowUtils { + + @Test + public void testTimestampSchemaRoundTripPreservesLocalTimezone() { + RowType rowType = RowType.of( + new LogicalType[] {new TimestampType(6), new LocalZonedTimestampType(6)}, + new String[] {"timestamp", "local_timestamp"}); + + RowType roundTripped = HoodieFlinkLanceArrowUtils.toRowType( + HoodieFlinkLanceArrowUtils.toArrowSchema(rowType)); + + assertInstanceOf(TimestampType.class, roundTripped.getTypeAt(0)); + assertInstanceOf(LocalZonedTimestampType.class, roundTripped.getTypeAt(1)); + } + + @Test + public void testTimestampWriteHonorsUtcTimestampFlag() { + TimestampData timestampData = TimestampData.fromEpochMillis(1234L, 567000); + GenericRowData rowData = GenericRowData.of(timestampData); + + try (BufferAllocator allocator = new RootAllocator(); + TimeStampMicroVector vector = new TimeStampMicroVector( + "ts", + FieldType.nullable(new ArrowType.Timestamp(TimeUnit.MICROSECOND, null)), + allocator)) { + HoodieFlinkLanceArrowUtils.writeValue(new TimestampType(6), vector, 0, rowData, 0, true); + assertEquals(1234567L, vector.get(0)); + + HoodieFlinkLanceArrowUtils.writeValue(new TimestampType(6), vector, 1, rowData, 0, false); + assertEquals(timestampData.toTimestamp().getTime() * 1000L, vector.get(1)); + } + } + + @Test + public void testTimestampReadNormalizesPreEpochMicros() { + try (BufferAllocator allocator = new RootAllocator(); + TimeStampMicroVector vector = new TimeStampMicroVector( + "ts", + FieldType.nullable(new ArrowType.Timestamp(TimeUnit.MICROSECOND, null)), + allocator)) { + vector.setSafe(0, -1_234_567L); + vector.setValueCount(1); + + RowData rowData = HoodieFlinkLanceArrowUtils.toRowData( + RowType.of(new LogicalType[] {new TimestampType(6)}, new String[] {"ts"}), + Collections.singletonList(vector), + 0); + + assertEquals(TimestampData.fromEpochMillis(-1235L, 433000), rowData.getTimestamp(0, 6)); + } + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowDataParquetConfigInjector.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowDataParquetConfigInjector.java index f65a2844649bd..67bf8e09cb6dc 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowDataParquetConfigInjector.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowDataParquetConfigInjector.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.config.HoodieConfig; import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.engine.LocalTaskContextSupplier; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.testutils.DisableDictionaryInjector; import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.common.util.collection.Pair; @@ -31,6 +32,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; import org.apache.hudi.testutils.HoodieFlinkClientTestHarness; +import org.apache.hudi.util.HoodieSchemaConverter; import org.apache.flink.table.api.DataTypes; import org.apache.flink.table.data.GenericRowData; @@ -111,6 +113,7 @@ public void testDisableDictionaryEncodingViaInjector() throws Exception { basePath + "/partition/path/test_dictionary_" + instantTime + ".parquet"); RowType rowType = getTestRowType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType); // Create config with the custom injector HoodieConfig config = new HoodieConfig(); @@ -120,7 +123,7 @@ public void testDisableDictionaryEncodingViaInjector() throws Exception { // Create writer and write some data HoodieRowDataFileWriterFactory factory = new HoodieRowDataFileWriterFactory(storage); HoodieFileWriter writer = factory.newParquetFileWriter( - instantTime, parquetPath, config, rowType, new LocalTaskContextSupplier()); + instantTime, parquetPath, config, schema, new LocalTaskContextSupplier()); assertTrue(writer instanceof HoodieRowDataParquetWriter); @@ -169,6 +172,7 @@ public void testInvalidInjectorClassThrowsException() throws IOException { basePath + "/partition/path/test_invalid_" + instantTime + ".parquet"); RowType rowType = getTestRowType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType); // Create config with an invalid/non-existent injector class HoodieConfig config = new HoodieConfig(); @@ -177,7 +181,7 @@ public void testInvalidInjectorClassThrowsException() throws IOException { // Should throw an exception when trying to create the writer HoodieRowDataFileWriterFactory factory = new HoodieRowDataFileWriterFactory(storage); assertThrows(Exception.class, () -> { - factory.newParquetFileWriter(instantTime, parquetPath, config, rowType, new LocalTaskContextSupplier()); + factory.newParquetFileWriter(instantTime, parquetPath, config, schema, new LocalTaskContextSupplier()); }); } @@ -189,6 +193,7 @@ public void testNoInjectorUsesDefaultConfig() throws Exception { basePath + "/partition/path/test_no_injector_" + instantTime + ".parquet"); RowType rowType = getTestRowType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType); // Create config WITHOUT injector - should use default settings HoodieConfig config = new HoodieConfig(); @@ -197,7 +202,7 @@ public void testNoInjectorUsesDefaultConfig() throws Exception { // Create writer and write some data HoodieRowDataFileWriterFactory factory = new HoodieRowDataFileWriterFactory(storage); HoodieFileWriter writer = factory.newParquetFileWriter( - instantTime, parquetPath, config, rowType, new LocalTaskContextSupplier()); + instantTime, parquetPath, config, schema, new LocalTaskContextSupplier()); assertTrue(writer instanceof HoodieRowDataParquetWriter); diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetRowDataWriter.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetRowDataWriter.java new file mode 100644 index 0000000000000..35c45383fd656 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetRowDataWriter.java @@ -0,0 +1,218 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.io.storage.row.parquet; + +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.util.HoodieSchemaConverter; + +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.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.io.api.RecordConsumer; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class TestParquetRowDataWriter { + + @Test + void testWriteDecimalWithWidthFromHoodieSchema() { + HoodieSchema schema = HoodieSchema.parse( + "{\"type\":\"record\",\"name\":\"rec\",\"fields\":[" + + "{\"name\":\"large_fixed\",\"type\":{\"type\":\"fixed\",\"name\":\"large_fixed_type\"," + + "\"size\":10,\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}}," + + "{\"name\":\"small_fixed\",\"type\":{\"type\":\"fixed\",\"name\":\"small_fixed_type\"," + + "\"size\":10,\"logicalType\":\"decimal\",\"precision\":10,\"scale\":2}}," + + "{\"name\":\"bytes_decimal\",\"type\":{\"type\":\"bytes\",\"logicalType\":\"decimal\"," + + "\"precision\":20,\"scale\":2}}]}"); + BigDecimal largeValue = new BigDecimal("123456789.12"); + BigDecimal smallValue = new BigDecimal("-12.34"); + BigDecimal bytesValue = new BigDecimal("223456789.34"); + GenericRowData row = GenericRowData.of( + DecimalData.fromBigDecimal(largeValue, 20, 2), + DecimalData.fromBigDecimal(smallValue, 10, 2), + DecimalData.fromBigDecimal(bytesValue, 20, 2)); + RecordConsumer consumer = mock(RecordConsumer.class); + + new ParquetRowDataWriter(consumer, true, schema).write(row); + + ArgumentCaptor binaryCaptor = ArgumentCaptor.forClass(Binary.class); + verify(consumer, times(3)).addBinary(binaryCaptor.capture()); + assertArrayEquals(signExtend(largeValue.unscaledValue().toByteArray(), 10), + binaryCaptor.getAllValues().get(0).getBytes()); + assertArrayEquals(signExtend(smallValue.unscaledValue().toByteArray(), 10), + binaryCaptor.getAllValues().get(1).getBytes()); + assertArrayEquals(signExtend(bytesValue.unscaledValue().toByteArray(), 9), + binaryCaptor.getAllValues().get(2).getBytes()); + } + + @Test + void testWritePrimitiveNestedArrayMapDecimalAndTimestampValues() { + RowType rowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("text", DataTypes.STRING()), + DataTypes.FIELD("flag", DataTypes.BOOLEAN()), + DataTypes.FIELD("bytes", DataTypes.BYTES()), + DataTypes.FIELD("tiny", DataTypes.TINYINT()), + DataTypes.FIELD("small", DataTypes.SMALLINT()), + DataTypes.FIELD("number", DataTypes.INT()), + DataTypes.FIELD("big", DataTypes.BIGINT()), + DataTypes.FIELD("ratio", DataTypes.FLOAT()), + DataTypes.FIELD("score", DataTypes.DOUBLE()), + DataTypes.FIELD("day", DataTypes.DATE()), + DataTypes.FIELD("time", DataTypes.TIME(3)), + DataTypes.FIELD("small_decimal", DataTypes.DECIMAL(10, 2)), + DataTypes.FIELD("large_decimal", DataTypes.DECIMAL(30, 4)), + DataTypes.FIELD("timestamp3", DataTypes.TIMESTAMP(3)), + DataTypes.FIELD("timestamp6", DataTypes.TIMESTAMP_LTZ(6)), + DataTypes.FIELD("items", DataTypes.ARRAY(DataTypes.STRING())), + DataTypes.FIELD("empty_items", DataTypes.ARRAY(DataTypes.INT())), + DataTypes.FIELD("attributes", DataTypes.MAP(DataTypes.STRING(), DataTypes.INT())), + DataTypes.FIELD("nested", DataTypes.ROW( + DataTypes.FIELD("id", DataTypes.BIGINT()), + DataTypes.FIELD("label", DataTypes.STRING()), + DataTypes.FIELD("optional", DataTypes.INT()))), + DataTypes.FIELD("null_field", DataTypes.STRING())) + .notNull().getLogicalType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType, "writer_record"); + + Map attributes = new LinkedHashMap<>(); + attributes.put(StringData.fromString("present"), 1); + attributes.put(StringData.fromString("missing"), null); + GenericRowData row = GenericRowData.of( + StringData.fromString("hello"), true, new byte[] {1, 2}, + 3, 4, 5, 6L, 7.5f, 8.25d, 9, 10, + DecimalData.fromBigDecimal(new BigDecimal("12.34"), 10, 2), + DecimalData.fromBigDecimal(new BigDecimal("12345678901234567890.1234"), 30, 4), + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123Z")), + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123456Z")), + new GenericArrayData(new Object[] {StringData.fromString("a"), null, StringData.fromString("c")}), + new GenericArrayData(new int[0]), + new GenericMapData(attributes), + GenericRowData.of(99L, StringData.fromString("nested"), null), + null); + + RecordConsumer consumer = mock(RecordConsumer.class); + new ParquetRowDataWriter(consumer, true, schema).write(row); + + verify(consumer).startMessage(); + verify(consumer).endMessage(); + verify(consumer, atLeastOnce()).startField(any(String.class), anyInt()); + verify(consumer, atLeastOnce()).addBoolean(true); + verify(consumer, atLeastOnce()).addInteger(5); + verify(consumer, atLeastOnce()).addLong(6L); + verify(consumer, atLeastOnce()).addFloat(7.5f); + verify(consumer, atLeastOnce()).addDouble(8.25d); + verify(consumer, atLeastOnce()).addBinary(any()); + verify(consumer, atLeastOnce()).startGroup(); + verify(consumer, atLeastOnce()).endGroup(); + verify(consumer, never()).startField(eq("null_field"), anyInt()); + } + + @Test + void testNonUtcTimestampWriter() { + RowType rowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("timestamp3", DataTypes.TIMESTAMP(3)), + DataTypes.FIELD("timestamp6", DataTypes.TIMESTAMP(6))).notNull().getLogicalType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType, "timestamps"); + GenericRowData row = GenericRowData.of( + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123Z")), + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123456Z"))); + RecordConsumer consumer = mock(RecordConsumer.class); + + new ParquetRowDataWriter(consumer, false, schema).write(row); + + verify(consumer, times(2)).addLong(anyLong()); + } + + @Test + void testArrayElementWritersForPrimitiveAndComplexTypes() { + RowType rowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("booleans", DataTypes.ARRAY(DataTypes.BOOLEAN())), + DataTypes.FIELD("longs", DataTypes.ARRAY(DataTypes.BIGINT())), + DataTypes.FIELD("floats", DataTypes.ARRAY(DataTypes.FLOAT())), + DataTypes.FIELD("doubles", DataTypes.ARRAY(DataTypes.DOUBLE())), + DataTypes.FIELD("binaries", DataTypes.ARRAY(DataTypes.BYTES())), + DataTypes.FIELD("small_decimals", DataTypes.ARRAY(DataTypes.DECIMAL(10, 2))), + DataTypes.FIELD("large_decimals", DataTypes.ARRAY(DataTypes.DECIMAL(30, 4))), + DataTypes.FIELD("timestamps", DataTypes.ARRAY(DataTypes.TIMESTAMP(6))), + DataTypes.FIELD("nested_arrays", DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.INT()))), + DataTypes.FIELD("maps", DataTypes.ARRAY( + DataTypes.MAP(DataTypes.STRING(), DataTypes.INT()))), + DataTypes.FIELD("rows", DataTypes.ARRAY(DataTypes.ROW( + DataTypes.FIELD("id", DataTypes.BIGINT()), + DataTypes.FIELD("name", DataTypes.STRING()))))).notNull().getLogicalType(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType, "array_elements"); + Map map = new LinkedHashMap<>(); + map.put(StringData.fromString("key"), 1); + GenericRowData row = GenericRowData.of( + new GenericArrayData(new boolean[] {true, false}), + new GenericArrayData(new long[] {1L, 2L}), + new GenericArrayData(new float[] {1.5f, 2.5f}), + new GenericArrayData(new double[] {3.5d, 4.5d}), + new GenericArrayData(new Object[] {new byte[] {1}, new byte[] {2}}), + new GenericArrayData(new Object[] { + DecimalData.fromBigDecimal(new BigDecimal("12.34"), 10, 2)}), + new GenericArrayData(new Object[] { + DecimalData.fromBigDecimal(new BigDecimal("12345678901234567890.1234"), 30, 4)}), + new GenericArrayData(new Object[] { + TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123456Z"))}), + new GenericArrayData(new Object[] {new GenericArrayData(new int[] {1, 2})}), + new GenericArrayData(new Object[] {new GenericMapData(map)}), + new GenericArrayData(new Object[] { + GenericRowData.of(1L, StringData.fromString("nested"))})); + RecordConsumer consumer = mock(RecordConsumer.class); + + new ParquetRowDataWriter(consumer, true, schema).write(row); + + verify(consumer, atLeastOnce()).addBoolean(true); + verify(consumer, atLeastOnce()).addLong(1L); + verify(consumer, atLeastOnce()).addFloat(1.5f); + verify(consumer, atLeastOnce()).addDouble(3.5d); + verify(consumer, atLeastOnce()).addBinary(any()); + } + + private static byte[] signExtend(byte[] bytes, int length) { + byte[] result = new byte[length]; + Arrays.fill(result, 0, length - bytes.length, bytes[0] < 0 ? (byte) -1 : (byte) 0); + System.arraycopy(bytes, 0, result, length - bytes.length, bytes.length); + return result; + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetSchemaConverter.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetSchemaConverter.java index 318f2034d1753..06c599f3bba4e 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetSchemaConverter.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/io/storage/row/parquet/TestParquetSchemaConverter.java @@ -18,6 +18,10 @@ package org.apache.hudi.io.storage.row.parquet; +import org.apache.hudi.adapter.DataTypeAdapter; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.util.Option; + import org.apache.flink.table.api.DataTypes; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.ArrayType; @@ -27,13 +31,19 @@ import org.apache.flink.table.types.logical.DoubleType; import org.apache.flink.table.types.logical.FloatType; import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.MapType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.logical.SmallIntType; import org.apache.flink.table.types.logical.TimestampType; import org.apache.flink.table.types.logical.TinyIntType; import org.apache.flink.table.types.logical.VarCharType; +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.Types; import org.junit.jupiter.api.Test; import java.util.Arrays; @@ -41,6 +51,9 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Test cases for {@link ParquetSchemaConverter}. @@ -200,6 +213,21 @@ void testConvertNestedComplexTypes() { assertThat(messageType.toString(), is(expected)); } + @Test + void testDecimalFixedLenWidthFromHoodieSchema() { + HoodieSchema hoodieSchema = HoodieSchema.parse( + "{\"type\":\"record\",\"name\":\"rec\",\"fields\":[" + + "{\"name\":\"fixed_decimal\",\"type\":{\"type\":\"fixed\",\"name\":\"dec_fixed\"," + + "\"size\":10,\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}}," + + "{\"name\":\"bytes_decimal\",\"type\":{\"type\":\"bytes\",\"logicalType\":\"decimal\"," + + "\"precision\":20,\"scale\":2}}]}"); + + MessageType messageType = ParquetSchemaConverter.convertToParquetMessageType("converted", hoodieSchema); + + assertEquals(10, messageType.getType("fixed_decimal").asPrimitiveType().getTypeLength()); + assertEquals(9, messageType.getType("bytes_decimal").asPrimitiveType().getTypeLength()); + } + @Test void testConvertTimestampTypes() { DataType dataType = DataTypes.ROW( @@ -216,4 +244,202 @@ void testConvertTimestampTypes() { + "}\n"; assertThat(messageType.toString(), is(expected)); } + + @Test + void testConvertAnnotatedPrimitiveParquetTypes() { + MessageType parquet = new MessageType("annotated", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.REQUIRED) + .as(LogicalTypeAnnotation.decimalType(2, 8)).named("decimal32"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.intType(8, true)).named("tiny"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.intType(16, true)).named("small"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.intType(32, true)).named("number"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.dateType()).named("day"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.timeType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)).named("time"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT64, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.decimalType(3, 12)).named("decimal64"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT96, Type.Repetition.OPTIONAL) + .named("timestamp96"), + Types.primitive(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, Type.Repetition.OPTIONAL) + .length(8).as(LogicalTypeAnnotation.decimalType(4, 16)).named("decimal_fixed")); + + RowType converted = ParquetSchemaConverter.convertToRowType(parquet); + assertEquals("DECIMAL(8, 2) NOT NULL", converted.getTypeAt(0).asSummaryString()); + assertEquals("TINYINT", converted.getTypeAt(1).asSummaryString()); + assertEquals("SMALLINT", converted.getTypeAt(2).asSummaryString()); + assertEquals("INT", converted.getTypeAt(3).asSummaryString()); + assertEquals("DATE", converted.getTypeAt(4).asSummaryString()); + assertEquals("TIME(0)", converted.getTypeAt(5).asSummaryString()); + assertEquals("DECIMAL(12, 3)", converted.getTypeAt(6).asSummaryString()); + assertEquals("TIMESTAMP(9)", converted.getTypeAt(7).asSummaryString()); + assertEquals("DECIMAL(16, 4)", converted.getTypeAt(8).asSummaryString()); + } + + @Test + void testConvertLocalZonedTimestampToParquet() { + RowType rowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("local_millis", DataTypes.TIMESTAMP_LTZ(3)), + DataTypes.FIELD("local_micros", DataTypes.TIMESTAMP_LTZ(6))).notNull().getLogicalType(); + MessageType parquet = ParquetSchemaConverter.convertToParquetMessageType("local", rowType); + assertEquals(LogicalTypeAnnotation.timestampType( + false, LogicalTypeAnnotation.TimeUnit.MILLIS), + parquet.getType("local_millis").getLogicalTypeAnnotation()); + assertEquals(LogicalTypeAnnotation.timestampType( + false, LogicalTypeAnnotation.TimeUnit.MICROS), + parquet.getType("local_micros").getLogicalTypeAnnotation()); + } + + @Test + void testConvertBinaryDateAndTimeToParquet() { + RowType rowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("payload", DataTypes.BYTES()), + DataTypes.FIELD("day", DataTypes.DATE()), + DataTypes.FIELD("time", DataTypes.TIME(3))).notNull().getLogicalType(); + MessageType parquet = ParquetSchemaConverter.convertToParquetMessageType("primitives", rowType); + assertEquals(PrimitiveType.PrimitiveTypeName.BINARY, + parquet.getType("payload").asPrimitiveType().getPrimitiveTypeName()); + assertEquals(LogicalTypeAnnotation.dateType(), + parquet.getType("day").getLogicalTypeAnnotation()); + assertEquals(LogicalTypeAnnotation.timeType(true, LogicalTypeAnnotation.TimeUnit.MILLIS), + parquet.getType("time").getLogicalTypeAnnotation()); + } + + /** + * A Parquet group with metadata + value binary fields but NO VARIANT annotation must be + * treated as a plain ROW. Only the Parquet {@code VARIANT} annotation triggers variant + * detection in this converter; unannotated groups are never guessed as variant. + */ + @Test + void testVariantPhysicalLayoutTreatedAsRow() { + MessageType variantParquet = new MessageType( + "test", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, + Type.Repetition.REQUIRED).named("id"), + Types.buildGroup(Type.Repetition.REQUIRED) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, + Type.Repetition.REQUIRED).named("metadata")) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, + Type.Repetition.REQUIRED).named("value")) + .named("data")); + + RowType rowType = ParquetSchemaConverter.convertToRowType(variantParquet); + assertEquals(2, rowType.getFieldCount()); + assertEquals("ROW", rowType.getTypeAt(1).getTypeRoot().name()); + } + + /** + * Unannotated group with metadata + value + typed_value (3 fields) is treated as a generic + * ROW when no annotation or schema hint is present. + */ + @Test + void testUnannotatedShreddedGroupTreatedAsRow() { + MessageType shreddedNoAnnotation = new MessageType( + "test", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, + Type.Repetition.REQUIRED).named("id"), + Types.buildGroup(Type.Repetition.REQUIRED) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, + Type.Repetition.REQUIRED).named("metadata")) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, + Type.Repetition.REQUIRED).named("value")) + .addField(Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, + Type.Repetition.OPTIONAL).named("typed_value")) + .named("data")); + + RowType rowType = ParquetSchemaConverter.convertToRowType(shreddedNoAnnotation); + assertEquals(2, rowType.getFieldCount()); + assertEquals("ROW", rowType.getTypeAt(1).getTypeRoot().name()); + } + + /** + * On Flink 2.1+ with parquet 1.16.0+, converting a RowType containing a Variant column to a + * Parquet MessageType should produce a group with the VARIANT annotation and required binary + * {@code metadata} and {@code value} fields. + * On pre-2.1 Flink this test is skipped since VariantType does not exist. + * On parquet < 1.16.0 the write is expected to fail (annotation unavailable). + */ + @Test + void testVariantWritePathProducesCorrectLayout() { + LogicalType variantType; + try { + variantType = DataTypeAdapter.createVariantType().getLogicalType(); + } catch (UnsupportedOperationException e) { + // Pre-2.1 Flink: VariantType doesn't exist, skip + return; + } + + RowType rowType = RowType.of( + new LogicalType[]{new IntType(), variantType}, + new String[]{"id", "data"}); + + if (!DataTypeAdapter.variantParquetAnnotation().isPresent()) { + // parquet < 1.16.0: write must fail because annotation is unavailable + UnsupportedOperationException ex = org.junit.jupiter.api.Assertions.assertThrows( + UnsupportedOperationException.class, + () -> ParquetSchemaConverter.convertToParquetMessageType("test", rowType)); + assertTrue(ex.getMessage().contains("parquet-java 1.16.0+"), + "Error message should mention parquet version requirement"); + return; + } + + // parquet 1.16.0+: write succeeds with annotation + MessageType messageType = ParquetSchemaConverter.convertToParquetMessageType("test", rowType); + assertEquals(2, messageType.getFieldCount()); + + Type variantField = messageType.getType("data"); + assertTrue(variantField instanceof GroupType, "Variant column should be a Parquet group"); + GroupType variantGroup = (GroupType) variantField; + assertEquals(2, variantGroup.getFieldCount()); + assertEquals(HoodieSchema.Variant.VARIANT_METADATA_FIELD, variantGroup.getType(0).getName()); + assertEquals(HoodieSchema.Variant.VARIANT_VALUE_FIELD, variantGroup.getType(1).getName()); + assertTrue(variantGroup.getType(0).isPrimitive()); + assertTrue(variantGroup.getType(1).isPrimitive()); + assertEquals(PrimitiveType.PrimitiveTypeName.BINARY, + variantGroup.getType(0).asPrimitiveType().getPrimitiveTypeName()); + assertEquals(PrimitiveType.PrimitiveTypeName.BINARY, + variantGroup.getType(1).asPrimitiveType().getPrimitiveTypeName()); + assertNotNull(variantGroup.getLogicalTypeAnnotation(), + "Variant group must carry the VARIANT annotation"); + } + + /** + * Verifies that writing a Variant column fails with a clear error when parquet-java on the + * classpath does not support the VARIANT annotation (< 1.16.0). On pre-2.1 Flink the adapter + * throws directly; on Flink 2.1+ with parquet < 1.16.0 the write path throws. + */ + @Test + void testVariantWriteFailsWithoutAnnotation() { + Option annotationOpt; + try { + annotationOpt = DataTypeAdapter.variantParquetAnnotation(); + } catch (UnsupportedOperationException e) { + // Pre-2.1 Flink: expected to throw from the adapter + assertTrue(e.getMessage().contains("VARIANT type is only supported in Flink 2.1+")); + return; + } + + if (annotationOpt.isPresent()) { + // parquet 1.16.0+: annotation is available, write succeeds — nothing to test here + return; + } + + // Flink 2.1 + parquet < 1.16.0: annotation is null, write must fail + LogicalType variantType = DataTypeAdapter.createVariantType().getLogicalType(); + RowType rowType = RowType.of( + new LogicalType[]{new IntType(), variantType}, + new String[]{"id", "data"}); + + UnsupportedOperationException ex = org.junit.jupiter.api.Assertions.assertThrows( + UnsupportedOperationException.class, + () -> ParquetSchemaConverter.convertToParquetMessageType("test", rowType)); + assertTrue(ex.getMessage().contains("parquet-java 1.16.0+"), + "Error message should mention the parquet version requirement"); + assertTrue(ex.getMessage().contains("VARIANT"), + "Error message should mention VARIANT"); + } + } diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/TestHoodieFlinkTableActionRouting.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/TestHoodieFlinkTableActionRouting.java new file mode 100644 index 0000000000000..e940951c5be73 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/TestHoodieFlinkTableActionRouting.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.hudi.table; + +import org.apache.hudi.avro.model.HoodieRollbackPlan; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieNotSupportedException; +import org.apache.hudi.io.FlinkAppendHandle; +import org.apache.hudi.io.HoodieAppendHandle; +import org.apache.hudi.io.HoodieCreateHandle; +import org.apache.hudi.io.HoodieWriteHandle; +import org.apache.hudi.table.action.BaseActionExecutor; +import org.apache.hudi.table.action.HoodieWriteMetadata; +import org.apache.hudi.table.action.clean.CleanPlanActionExecutor; +import org.apache.hudi.table.action.cluster.ClusteringPlanActionExecutor; +import org.apache.hudi.table.action.commit.BucketInfo; +import org.apache.hudi.table.action.commit.BucketType; +import org.apache.hudi.table.action.commit.FlinkDeletePreppedCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkInsertCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkInsertOverwriteCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkInsertOverwriteTableCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkInsertPreppedCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkPartitionTTLActionExecutor; +import org.apache.hudi.table.action.commit.FlinkUpsertCommitActionExecutor; +import org.apache.hudi.table.action.commit.FlinkUpsertPreppedCommitActionExecutor; +import org.apache.hudi.table.action.commit.delta.FlinkUpsertDeltaCommitActionExecutor; +import org.apache.hudi.table.action.commit.delta.FlinkUpsertPreppedDeltaCommitActionExecutor; +import org.apache.hudi.table.action.compact.RunCompactionActionExecutor; +import org.apache.hudi.table.action.compact.ScheduleCompactionActionExecutor; +import org.apache.hudi.table.action.rollback.BaseRollbackPlanActionExecutor; +import org.apache.hudi.testutils.HoodieFlinkClientTestHarness; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Tests Flink table action routing and explicitly unsupported engine APIs. */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class TestHoodieFlinkTableActionRouting extends HoodieFlinkClientTestHarness { + + @BeforeEach + void setUp() { + initPath(); + initFileSystem(); + } + + @AfterEach + void tearDown() throws IOException { + cleanupResources(); + } + + @Test + void testCopyOnWriteUnsupportedActionsFailFast() throws IOException { + initMetaClient(HoodieTableType.COPY_ON_WRITE); + HoodieFlinkCopyOnWriteTable table = new HoodieFlinkCopyOnWriteTable(config(), context, metaClient); + + assertUnsupported(() -> table.upsert(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.insert(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.bulkInsert(context, "001", Collections.emptyList(), Option.empty())); + assertUnsupported(() -> table.delete(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.deletePrepped(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.upsertPrepped(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.insertPrepped(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.bulkInsertPrepped( + context, "001", Collections.emptyList(), Option.empty())); + assertUnsupported(() -> table.insertOverwrite(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.insertOverwriteTable(context, "001", Collections.emptyList())); + assertUnsupported(() -> table.scheduleCompaction(context, "001", Option.empty())); + assertUnsupported(() -> table.compact(context, "001")); + assertUnsupported(() -> table.cluster(context, "001")); + assertUnsupported(() -> table.bootstrap(context, Option.empty())); + assertUnsupported(() -> table.rollbackBootstrap(context, "001")); + assertUnsupported(() -> table.scheduleIndexing(context, "001", Collections.emptyList(), Collections.emptyList())); + assertUnsupported(() -> table.index(context, "001")); + assertUnsupported(() -> table.savepoint(context, "001", "user", "comment")); + assertUnsupported(() -> table.scheduleRestore(context, "002", "001")); + assertUnsupported(() -> table.restore(context, "002", "001")); + } + + @Test + void testCopyOnWriteRoutesSupportedPlanningActions() throws IOException { + initMetaClient(HoodieTableType.COPY_ON_WRITE); + HoodieFlinkCopyOnWriteTable table = new HoodieFlinkCopyOnWriteTable(config(), context, metaClient); + + try (MockedConstruction ignored = Mockito.mockConstruction( + ClusteringPlanActionExecutor.class, + (executor, constructionContext) -> when(executor.execute()).thenReturn(Option.empty()))) { + assertFalse(table.scheduleClustering(context, "001", Option.empty()).isPresent()); + } + try (MockedConstruction ignored = Mockito.mockConstruction( + CleanPlanActionExecutor.class, + (executor, constructionContext) -> when(executor.execute()).thenReturn(Option.empty()))) { + assertFalse(table.createCleanerPlan(context, Option.empty()).isPresent()); + } + try (MockedConstruction ignored = Mockito.mockConstruction( + FlinkPartitionTTLActionExecutor.class, + (executor, constructionContext) -> { + HoodieWriteMetadata> metadata = new HoodieWriteMetadata<>(); + metadata.setWriteStatuses(Collections.emptyList()); + when(executor.execute()).thenReturn(metadata); + })) { + assertEquals(Collections.emptyList(), table.managePartitionTTL(context, "002").getWriteStatuses()); + } + Option rollbackPlan = Option.of(mock(HoodieRollbackPlan.class)); + assertResultPropagated(BaseRollbackPlanActionExecutor.class, rollbackPlan, + () -> table.scheduleRollback( + context, "003", mock(HoodieInstant.class), false, false, false)); + } + + @Test + void testCopyOnWriteRoutesWriteActionsAndCompactionInsert() throws IOException { + initMetaClient(HoodieTableType.COPY_ON_WRITE); + HoodieFlinkCopyOnWriteTable table = new HoodieFlinkCopyOnWriteTable(config(), context, metaClient); + HoodieWriteHandle writeHandle = mock(HoodieWriteHandle.class); + BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT, "file-1", "partition"); + + assertWriteMetadataPropagated(FlinkUpsertCommitActionExecutor.class, + () -> table.upsert(context, writeHandle, bucketInfo, "001", Collections.emptyIterator())); + assertWriteMetadataPropagated(FlinkInsertCommitActionExecutor.class, + () -> table.insert(context, writeHandle, bucketInfo, "001", Collections.emptyIterator())); + assertWriteMetadataPropagated(FlinkDeletePreppedCommitActionExecutor.class, + () -> table.deletePrepped(context, writeHandle, bucketInfo, "001", Collections.emptyList())); + assertWriteMetadataPropagated(FlinkUpsertPreppedCommitActionExecutor.class, + () -> table.upsertPrepped(context, writeHandle, bucketInfo, "001", Collections.emptyList())); + assertWriteMetadataPropagated(FlinkInsertPreppedCommitActionExecutor.class, + () -> table.insertPrepped(context, writeHandle, bucketInfo, "001", Collections.emptyList())); + assertWriteMetadataPropagated(FlinkInsertOverwriteCommitActionExecutor.class, + () -> table.insertOverwrite(context, writeHandle, bucketInfo, "001", Collections.emptyIterator())); + assertWriteMetadataPropagated(FlinkInsertOverwriteTableCommitActionExecutor.class, + () -> table.insertOverwriteTable(context, writeHandle, bucketInfo, "001", Collections.emptyIterator())); + + try (MockedConstruction ignored = Mockito.mockConstruction(HoodieCreateHandle.class)) { + assertEquals(Collections.emptyList(), + table.handleInsert("001", "partition", "file-1", Collections.emptyMap()).next()); + } + } + + @Test + void testMergeOnReadValidatesHandlesAndRoutesScheduling() throws IOException { + initMetaClient(HoodieTableType.MERGE_ON_READ); + HoodieFlinkMergeOnReadTable table = new HoodieFlinkMergeOnReadTable(config(), context, metaClient); + HoodieWriteHandle writeHandle = mock(HoodieWriteHandle.class); + BucketInfo bucketInfo = new BucketInfo(BucketType.UPDATE, "file-1", "partition"); + + assertThrows(IllegalArgumentException.class, + () -> table.upsert(context, writeHandle, bucketInfo, "001", Collections.emptyIterator())); + assertThrows(IllegalArgumentException.class, + () -> table.upsertPrepped(context, writeHandle, bucketInfo, "001", Collections.emptyList())); + + try (MockedConstruction mocked = Mockito.mockConstruction( + ScheduleCompactionActionExecutor.class, + (executor, constructionContext) -> when(executor.execute()).thenReturn(Option.empty()))) { + assertFalse(table.scheduleCompaction(context, "002", Option.empty()).isPresent()); + assertFalse(table.scheduleLogCompaction(context, "003", Option.empty()).isPresent()); + assertEquals(2, mocked.constructed().size()); + } + } + + @Test + void testMergeOnReadRoutesAppendAndCompactionActions() throws IOException { + initMetaClient(HoodieTableType.MERGE_ON_READ); + HoodieFlinkMergeOnReadTable table = new HoodieFlinkMergeOnReadTable(config(), context, metaClient); + // This branch's HoodieFlinkMergeOnReadTable requires a FlinkAppendHandle. Master relaxed the + // guard to HoodieAppendHandle in 0311d6c43961 (#19067), the native log format work, which is + // not backported here. + FlinkAppendHandle appendHandle = mock(FlinkAppendHandle.class); + BucketInfo bucketInfo = new BucketInfo(BucketType.UPDATE, "file-1", "partition"); + + assertWriteMetadataPropagated(FlinkUpsertDeltaCommitActionExecutor.class, + () -> table.upsert(context, appendHandle, bucketInfo, "001", Collections.emptyIterator())); + assertWriteMetadataPropagated(FlinkUpsertPreppedDeltaCommitActionExecutor.class, + () -> table.upsertPrepped(context, appendHandle, bucketInfo, "001", Collections.emptyList())); + assertWriteMetadataPropagated(FlinkUpsertDeltaCommitActionExecutor.class, + () -> table.insert(context, appendHandle, bucketInfo, "001", Collections.emptyIterator())); + + HoodieWriteMetadata compactionMetadata = new HoodieWriteMetadata(); + compactionMetadata.setWriteStatuses(HoodieListData.eager(Collections.emptyList())); + try (MockedConstruction ignored = Mockito.mockConstruction( + RunCompactionActionExecutor.class, + (executor, constructionContext) -> when(executor.execute()).thenReturn(compactionMetadata))) { + assertEquals(Collections.emptyList(), table.compact(context, "002").getWriteStatuses()); + assertEquals(Collections.emptyList(), table.logCompact(context, "003").getWriteStatuses()); + } + + try (MockedConstruction ignored = Mockito.mockConstruction( + HoodieAppendHandle.class, + (handle, constructionContext) -> when(handle.close()).thenReturn(Collections.emptyList()))) { + assertEquals(Collections.emptyList(), + table.handleInsertsForLogCompaction( + "004", "partition", "file-1", Collections.emptyMap(), Collections.emptyMap()).next()); + } + } + + private void assertWriteMetadataPropagated( + Class executorClass, Supplier invocation) { + HoodieWriteMetadata> metadata = new HoodieWriteMetadata<>(); + metadata.setWriteStatuses(Collections.emptyList()); + assertResultPropagated(executorClass, metadata, invocation); + } + + private void assertResultPropagated( + Class executorClass, Object expected, Supplier invocation) { + try (MockedConstruction mocked = Mockito.mockConstruction( + executorClass, + (executor, constructionContext) -> when(executor.execute()).thenReturn(expected))) { + assertSame(expected, invocation.get()); + assertEquals(1, mocked.constructed().size()); + } + } + + private HoodieWriteConfig config() { + return HoodieWriteConfig.newBuilder() + .withPath(basePath) + .withEngineType(EngineType.FLINK) + .withWriteTableVersion(HoodieTableVersion.NINE.versionCode()) + .build(); + } + + private void assertUnsupported(ThrowingRunnable runnable) { + assertThrows(HoodieNotSupportedException.class, runnable::run); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/action/commit/TestFlinkDeleteHelper.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/action/commit/TestFlinkDeleteHelper.java new file mode 100644 index 0000000000000..3b9b6e8bc423d --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/table/action/commit/TestFlinkDeleteHelper.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hudi.table.action.commit; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.model.EmptyHoodieRecordPayload; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieUpsertException; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.table.HoodieTable; +import org.apache.hudi.table.action.HoodieWriteMetadata; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** Tests for {@link FlinkDeleteHelper}. */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class TestFlinkDeleteHelper { + + @Test + void testDeduplicateKeysForGlobalAndPartitionedIndexes() { + FlinkDeleteHelper helper = FlinkDeleteHelper.newInstance(); + assertSame(helper, FlinkDeleteHelper.newInstance()); + + HoodieTable table = mock(HoodieTable.class); + HoodieIndex index = mock(HoodieIndex.class); + when(table.getIndex()).thenReturn(index); + List keys = new ArrayList<>(Arrays.asList( + new HoodieKey("id1", "p1"), + new HoodieKey("id1", "p2"), + new HoodieKey("id2", "p1"), + new HoodieKey("id2", "p1"))); + + when(index.isGlobal()).thenReturn(true); + List globalResult = helper.deduplicateKeys(keys, table, 1); + assertEquals(Arrays.asList("id1", "id2"), globalResult.stream() + .map(HoodieKey::getRecordKey).collect(Collectors.toList())); + assertEquals(Arrays.asList("p1", "p1"), globalResult.stream() + .map(HoodieKey::getPartitionPath).collect(Collectors.toList())); + assertNotSame(keys, globalResult); + + when(index.isGlobal()).thenReturn(false); + List partitionedResult = helper.deduplicateKeys(keys, table, 1); + assertSame(keys, partitionedResult); + assertEquals(Arrays.asList( + new HoodieKey("id1", "p1"), + new HoodieKey("id1", "p2"), + new HoodieKey("id2", "p1")), partitionedResult); + } + + @Test + void testExecuteTagsExistingRecordsAndDelegatesDelete() { + HoodieTable table = mock(HoodieTable.class); + HoodieIndex index = mock(HoodieIndex.class); + HoodieEngineContext context = mock(HoodieEngineContext.class); + BaseCommitActionExecutor executor = mock(BaseCommitActionExecutor.class); + when(table.getIndex()).thenReturn(index); + when(index.isGlobal()).thenReturn(false); + + when(index.tagLocation(any(HoodieData.class), eq(context), eq(table))).thenAnswer(invocation -> { + HoodieData> records = invocation.getArgument(0); + List> tagged = records.collectAsList(); + tagged.get(0).setCurrentLocation(new HoodieRecordLocation("001", "file-1")); + return HoodieListData.eager(tagged); + }); + + HoodieWriteMetadata> expected = new HoodieWriteMetadata<>(); + expected.setWriteStatuses(Collections.singletonList(new WriteStatus(false, 0.0))); + when(executor.execute(any(List.class))).thenReturn(expected); + + HoodieWriteConfig config = HoodieWriteConfig.newBuilder() + .withPath("/tmp/flink-delete-helper") + .combineDeleteInput(true) + .build(); + List keys = new ArrayList<>(Arrays.asList( + new HoodieKey("id1", "p1"), new HoodieKey("id1", "p1"), new HoodieKey("missing", "p1"))); + + HoodieWriteMetadata> result = FlinkDeleteHelper.newInstance() + .execute("002", keys, context, config, table, executor); + + assertSame(expected, result); + assertTrue(result.getIndexLookupDuration().isPresent()); + verify(executor).execute(any(List.class)); + verify(executor, never()).saveWorkloadProfileMetadataToInflight(any(), any()); + } + + @Test + void testExecuteWithNoExistingRecordsCreatesEmptyMetadata() { + HoodieTable table = mock(HoodieTable.class); + HoodieIndex index = mock(HoodieIndex.class); + HoodieEngineContext context = mock(HoodieEngineContext.class); + BaseCommitActionExecutor executor = mock(BaseCommitActionExecutor.class); + when(table.getIndex()).thenReturn(index); + when(index.tagLocation(any(HoodieData.class), eq(context), eq(table))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + HoodieWriteConfig config = HoodieWriteConfig.newBuilder() + .withPath("/tmp/flink-delete-helper") + .combineDeleteInput(false) + .build(); + HoodieWriteMetadata> result = FlinkDeleteHelper.newInstance().execute( + "003", Collections.singletonList(new HoodieKey("missing", "p1")), + context, config, table, executor); + + assertTrue(result.getWriteStatuses().isEmpty()); + verify(executor).saveWorkloadProfileMetadataToInflight(any(), eq("003")); + verify(executor).runPrecommitValidators(result); + verify(executor, never()).execute(any(List.class)); + } + + @Test + void testExecutePreservesOrWrapsFailures() { + HoodieTable table = mock(HoodieTable.class); + HoodieEngineContext context = mock(HoodieEngineContext.class); + BaseCommitActionExecutor executor = mock(BaseCommitActionExecutor.class); + HoodieWriteConfig config = HoodieWriteConfig.newBuilder() + .withPath("/tmp/flink-delete-helper") + .build(); + List keys = Collections.singletonList(new HoodieKey("id1", "p1")); + + HoodieUpsertException upsertException = new HoodieUpsertException("expected"); + when(table.getIndex()).thenThrow(upsertException); + HoodieUpsertException firstFailure = assertThrows(HoodieUpsertException.class, + () -> FlinkDeleteHelper.newInstance().execute("004", keys, context, config, table, executor)); + assertSame(upsertException, firstFailure); + + reset(table); + when(table.getIndex()).thenThrow(new IllegalStateException("boom")); + HoodieUpsertException wrapped = assertThrows(HoodieUpsertException.class, + () -> FlinkDeleteHelper.newInstance().execute("005", keys, context, config, table, executor)); + assertTrue(wrapped.getCause() instanceof IllegalStateException); + } +} diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/testutils/HoodieFlinkWriteableTestTable.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/testutils/HoodieFlinkWriteableTestTable.java index 09e24fbbb0fe4..8d4395a50d7dd 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/testutils/HoodieFlinkWriteableTestTable.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/testutils/HoodieFlinkWriteableTestTable.java @@ -32,6 +32,7 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType; import org.apache.hudi.common.util.collection.Pair; @@ -136,10 +137,13 @@ public Map> withLogAppends(List record private Pair appendRecordsToLogFile(List groupedRecords) throws Exception { String partitionPath = groupedRecords.get(0).getPartitionPath(); HoodieRecordLocation location = groupedRecords.get(0).getCurrentLocation(); - try (HoodieLogFormat.Writer logWriter = HoodieLogFormat.newWriterBuilder() - .onParentPath(new StoragePath(basePath, partitionPath)) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(location.getFileId()) - .withInstantTime(location.getInstantTime()).withStorage(storage).build()) { + try (HoodieLogFormat.Writer logWriter = HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(basePath, partitionPath)) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(location.getFileId()) + .withInstantTime(location.getInstantTime()) + .withStorage(storage) + .build()) { Map header = new java.util.HashMap<>(); header.put(HeaderMetadataType.INSTANT_TIME, location.getInstantTime()); header.put(HeaderMetadataType.SCHEMA, schema.toString()); @@ -150,7 +154,7 @@ private Pair appendRecordsToLogFile(List gr HoodieAvroUtils.addHoodieKeyToRecord(val, r.getRecordKey(), r.getPartitionPath(), ""); return (IndexedRecord) val; } catch (IOException e) { - log.warn("Failed to convert record " + r.toString(), e); + log.warn("Failed to convert record {}", r, e); return null; } }).map(HoodieAvroIndexedRecord::new).collect(Collectors.toList()), header, HoodieRecord.RECORD_KEY_METADATA_FIELD)); diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestHoodieSchemaConverter.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestHoodieSchemaConverter.java index e2fd16d7112b0..b14aad4877de0 100644 --- a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestHoodieSchemaConverter.java +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestHoodieSchemaConverter.java @@ -40,6 +40,8 @@ import java.util.Arrays; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -693,23 +695,16 @@ public void testBlobInNestedStructures() { @Test public void testVariantTypeConversion() { - // Test direct Variant conversion HoodieSchema variantSchema = HoodieSchema.createVariant(); DataType dataType = HoodieSchemaConverter.convertToDataType(variantSchema); assertNotNull(dataType); - // Verify it's a ROW with metadata and value binary fields - RowType rowType = (RowType) dataType.getLogicalType(); - assertEquals(2, rowType.getFieldCount()); - assertEquals("metadata", rowType.getFieldNames().get(0)); - assertEquals("value", rowType.getFieldNames().get(1)); - assertInstanceOf(VarBinaryType.class, rowType.getTypeAt(0)); - assertInstanceOf(VarBinaryType.class, rowType.getTypeAt(1)); + assertThat("the return type should be variant", + dataType.getLogicalType().asSummaryString(), is("VARIANT NOT NULL")); } @Test public void testVariantInRecordConversion() { - // Test Variant field within a record HoodieSchema recordWithVariant = HoodieSchema.createRecord( "test_record", null, @@ -724,11 +719,38 @@ public void testVariantInRecordConversion() { assertEquals(2, result.getFieldCount()); assertEquals("data", result.getFieldNames().get(1)); - // Verify variant field is a ROW - RowType variantRowType = (RowType) result.getTypeAt(1); - assertEquals(2, variantRowType.getFieldCount()); - assertEquals("metadata", variantRowType.getFieldNames().get(0)); - assertEquals("value", variantRowType.getFieldNames().get(1)); + assertThat("the return type should be variant", + result.getTypeAt(1).asSummaryString(), is("VARIANT NOT NULL")); + } + + @Test + public void testVariantInArrayConversion() { + HoodieSchema arrayOfVariant = HoodieSchema.createArray(HoodieSchema.createVariant()); + DataType dataType = HoodieSchemaConverter.convertToDataType(arrayOfVariant); + assertNotNull(dataType); + assertInstanceOf(ArrayType.class, dataType.getLogicalType()); + LogicalType elementType = ((ArrayType) dataType.getLogicalType()).getElementType(); + assertEquals("VARIANT", elementType.getTypeRoot().name()); + } + + @Test + public void testVariantInMapConversion() { + HoodieSchema mapOfVariant = HoodieSchema.createMap(HoodieSchema.createVariant()); + DataType dataType = HoodieSchemaConverter.convertToDataType(mapOfVariant); + assertNotNull(dataType); + assertInstanceOf(MapType.class, dataType.getLogicalType()); + LogicalType valueType = ((MapType) dataType.getLogicalType()).getValueType(); + assertEquals("VARIANT", valueType.getTypeRoot().name()); + } + + @Test + public void testShreddedVariantConversionThrows() { + HoodieSchema.Variant shredded = HoodieSchema.createVariantShredded( + HoodieSchema.create(HoodieSchemaType.STRING)); + UnsupportedOperationException ex = assertThrows( + UnsupportedOperationException.class, + () -> HoodieSchemaConverter.convertToDataType(shredded)); + assertTrue(ex.getMessage().contains("Shredded Variant is not yet supported in Flink")); } @Test @@ -749,6 +771,23 @@ public void testBlobStructureValidation() { HoodieSchema convertedSchema = HoodieSchemaConverter.convertToSchema(blobLikeRowType); assertEquals(HoodieSchemaType.BLOB, convertedSchema.getType()); + // Positive case: same structure but every nested field nullable. Flink SQL CREATE TABLE does not + // preserve NOT NULL on nested ROW fields, so detection must not depend on nested nullability. + DataType allNullableBlobRow = DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.TYPE, DataTypes.STRING().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.INLINE_DATA_FIELD, DataTypes.BYTES().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE, DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, DataTypes.STRING().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, DataTypes.BOOLEAN().nullable()) + ).nullable()) + ).notNull(); + + RowType allNullableBlobRowType = (RowType) allNullableBlobRow.getLogicalType(); + HoodieSchema allNullableConverted = HoodieSchemaConverter.convertToSchema(allNullableBlobRowType); + assertEquals(HoodieSchemaType.BLOB, allNullableConverted.getType()); + // Negative case 1: Different field names DataType differentNames = DataTypes.ROW( DataTypes.FIELD("wrong_name", DataTypes.STRING().notNull()), diff --git a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestRowDataUtils.java b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestRowDataUtils.java new file mode 100644 index 0000000000000..0249e74028cf0 --- /dev/null +++ b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestRowDataUtils.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.hudi.util; + +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.TimestampType; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestRowDataUtils { + + @Test + void testJavaAndFlinkValueConverters() { + assertNull(RowDataUtils.NULL_GETTER.getFieldOrNull(GenericRowData.of(1))); + assertNull(RowDataUtils.javaValFunc(DataTypes.NULL().getLogicalType(), true).apply("ignored")); + assertEquals(7, RowDataUtils.javaValFunc(DataTypes.TINYINT().getLogicalType(), true).apply((byte) 7)); + assertEquals(8, RowDataUtils.javaValFunc(DataTypes.SMALLINT().getLogicalType(), true).apply((short) 8)); + assertEquals(2, RowDataUtils.javaValFunc(DataTypes.DATE().getLogicalType(), true).apply(2)); + assertEquals("text", RowDataUtils.javaValFunc(DataTypes.STRING().getLogicalType(), true) + .apply(StringData.fromString("text"))); + assertArrayEquals(new byte[] {1, 2}, ((ByteBuffer) RowDataUtils.javaValFunc( + DataTypes.BYTES().getLogicalType(), true).apply(new byte[] {1, 2})).array()); + assertEquals(new BigDecimal("12.30"), RowDataUtils.javaValFunc( + DataTypes.DECIMAL(8, 2).getLogicalType(), true) + .apply(DecimalData.fromBigDecimal(new BigDecimal("12.30"), 8, 2))); + + assertEquals((byte) 7, RowDataUtils.flinkValFunc(DataTypes.TINYINT().getLogicalType(), true).apply((byte) 7)); + assertEquals((short) 8, RowDataUtils.flinkValFunc(DataTypes.SMALLINT().getLogicalType(), true).apply((short) 8)); + assertEquals(3, RowDataUtils.flinkValFunc(DataTypes.DATE().getLogicalType(), true) + .apply(LocalDate.ofEpochDay(3))); + assertEquals("text", RowDataUtils.flinkValFunc(DataTypes.STRING().getLogicalType(), true) + .apply("text").toString()); + ByteBuffer buffer = ByteBuffer.wrap(new byte[] {3, 4}); + assertSame(buffer, RowDataUtils.flinkValFunc(DataTypes.BYTES().getLogicalType(), true).apply(buffer)); + assertEquals(new BigDecimal("45.60"), ((DecimalData) RowDataUtils.flinkValFunc( + DataTypes.DECIMAL(8, 2).getLogicalType(), true).apply(new BigDecimal("45.60"))).toBigDecimal()); + } + + @Test + void testTimestampConvertersAtMillisAndMicros() { + TimestampData timestamp = TimestampData.fromInstant(Instant.parse("2025-02-03T04:05:06.123456Z")); + long millis = timestamp.toInstant().toEpochMilli(); + long micros = timestamp.toInstant().getEpochSecond() * 1_000_000 + 123456; + + assertEquals(millis, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP_LTZ(3).getLogicalType(), true) + .apply(timestamp)); + assertEquals(micros, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP_LTZ(6).getLogicalType(), true) + .apply(timestamp)); + assertEquals(millis, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP(3).getLogicalType(), true) + .apply(timestamp)); + assertEquals(micros, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP(6).getLogicalType(), true) + .apply(timestamp)); + long localMillis = (long) RowDataUtils.javaValFunc( + DataTypes.TIMESTAMP(3).getLogicalType(), false).apply(timestamp); + long localMicros = (long) RowDataUtils.javaValFunc( + DataTypes.TIMESTAMP(6).getLogicalType(), false).apply(timestamp); + + assertEquals(Instant.ofEpochMilli(millis), ((TimestampData) RowDataUtils.flinkValFunc( + DataTypes.TIMESTAMP_LTZ(3).getLogicalType(), true).apply(millis)).toInstant()); + assertEquals(timestamp.toInstant(), ((TimestampData) RowDataUtils.flinkValFunc( + DataTypes.TIMESTAMP_LTZ(6).getLogicalType(), true).apply(micros)).toInstant()); + assertEquals(millis, ((TimestampData) RowDataUtils.flinkValFunc( + DataTypes.TIMESTAMP(3).getLogicalType(), false).apply(millis)).toTimestamp().getTime()); + assertEquals(timestamp.toInstant(), ((TimestampData) RowDataUtils.flinkValFunc( + DataTypes.TIMESTAMP(6).getLogicalType(), true).apply(micros)).toInstant()); + assertEquals(localMillis, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP(3).getLogicalType(), false) + .apply(RowDataUtils.flinkValFunc(DataTypes.TIMESTAMP(3).getLogicalType(), false).apply(localMillis))); + assertEquals(localMicros, RowDataUtils.javaValFunc(DataTypes.TIMESTAMP(6).getLogicalType(), false) + .apply(RowDataUtils.flinkValFunc(DataTypes.TIMESTAMP(6).getLogicalType(), false).apply(localMicros))); + + assertThrows(UnsupportedOperationException.class, + () -> RowDataUtils.javaValFunc(DataTypes.TIMESTAMP(9).getLogicalType(), true)); + assertThrows(UnsupportedOperationException.class, + () -> RowDataUtils.flinkValFunc(DataTypes.TIMESTAMP_LTZ(9).getLogicalType(), true)); + } + + @Test + void testGenericConversionAndPrecision() { + assertNull(RowDataUtils.convertValueToFlinkType(null)); + assertEquals("value", RowDataUtils.convertValueToFlinkType("value").toString()); + assertEquals(new BigDecimal("1.20"), ((DecimalData) RowDataUtils.convertValueToFlinkType( + new BigDecimal("1.20"))).toBigDecimal()); + Timestamp timestamp = Timestamp.valueOf("2025-02-03 04:05:06.123456"); + assertEquals(timestamp, ((TimestampData) RowDataUtils.convertValueToFlinkType(timestamp)).toTimestamp()); + assertEquals(2, RowDataUtils.convertValueToFlinkType(LocalDate.ofEpochDay(2))); + assertArrayEquals(new byte[] {9, 8}, + (byte[]) RowDataUtils.convertValueToFlinkType(ByteBuffer.wrap(new byte[] {9, 8}))); + Object marker = new Object(); + assertSame(marker, RowDataUtils.convertValueToFlinkType(marker)); + assertEquals(3, RowDataUtils.precision(new TimestampType(3))); + assertEquals(6, RowDataUtils.precision(new LocalZonedTimestampType(6))); + assertThrows(AssertionError.class, + () -> RowDataUtils.precision(DataTypes.INT().getLogicalType())); + } +} diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/client/common/HoodieJavaEngineContext.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/client/common/HoodieJavaEngineContext.java index 8da9e2aca0e15..a24d6fb4c25d9 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/client/common/HoodieJavaEngineContext.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/client/common/HoodieJavaEngineContext.java @@ -49,6 +49,7 @@ import java.io.IOException; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -192,6 +193,30 @@ public void cancelAllJobs() { // no operation for now } + // Allowlist of safe system properties to include in commit metadata. Avoid wildcarding system + // properties since callers may pass credentials via -D flags (e.g. -Ddb.password=...). + private static final String[] SAFE_SYSTEM_PROPERTIES = { + "java.version", + "java.vendor", + "java.vm.name", + "java.vm.version", + "os.name", + "os.version", + "os.arch" + }; + + @Override + public Map getEngineProperties() { + Map info = new HashMap<>(); + for (String property : SAFE_SYSTEM_PROPERTIES) { + String value = System.getProperty(property); + if (value != null) { + info.put(property, value); + } + } + return info; + } + @Override public O aggregate(HoodieData data, O zeroValue, Functions.Function2 seqOp, Functions.Function2 combOp) { return data.collectAsList().stream().reduce(zeroValue, seqOp::apply, combOp::apply); diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieBackedTableMetadataWriter.java index 2a035f560d5ca..b5d9d2822f19e 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieBackedTableMetadataWriter.java @@ -82,7 +82,7 @@ public static HoodieTableMetadataWriter create(StorageConfiguration conf, @Override protected void initRegistry() { if (metadataWriteConfig.isMetricsOn()) { - this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage())); + this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage(), dataWriteConfig.getMetadataConfig().isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java index 0d81cc91fcffb..e2a2137a1252f 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java @@ -19,6 +19,7 @@ package org.apache.hudi.metadata; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.table.BulkInsertPartitioner; import java.util.Comparator; @@ -39,7 +40,7 @@ public List> repartitionRecords(List> records, i if (records.isEmpty()) { return records; } - records.sort(Comparator.comparing(record -> record.getKey().getRecordKey())); + records.sort(Comparator.comparing(record -> record.getKey().getRecordKey(), StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)); fileId = HoodieTableMetadataUtil.getFileGroupPrefix(records.get(0).getCurrentLocation().getFileId()); return records; } diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/BaseJavaCommitActionExecutor.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/BaseJavaCommitActionExecutor.java index 43e5dd260561d..7746bdf3be7ee 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/BaseJavaCommitActionExecutor.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/BaseJavaCommitActionExecutor.java @@ -94,7 +94,7 @@ public HoodieWriteMetadata> execute(List> inpu WorkloadProfile workloadProfile = new WorkloadProfile(buildProfile(inputRecords), table.getIndex().canIndexLogFiles()); - log.info("Input workload profile :" + workloadProfile); + log.info("Input workload profile :{}", workloadProfile); final Partitioner partitioner = getPartitioner(workloadProfile); try { saveWorkloadProfileMetadataToInflight(workloadProfile, instantTime); @@ -236,7 +236,7 @@ public Iterator> handleUpdate(String partitionPath, String fil throws IOException { // This is needed since sometimes some buckets are never picked in getPartition() and end up with 0 records if (!recordItr.hasNext()) { - log.info("Empty partition with fileId => " + fileId); + log.info("Empty partition with fileId => {}", fileId); return Collections.singletonList((List) Collections.EMPTY_LIST).iterator(); } // these are updates diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaDeleteHelper.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaDeleteHelper.java index bc077c12c00ce..45c2637259dda 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaDeleteHelper.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaDeleteHelper.java @@ -38,7 +38,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedList; +import java.util.LinkedHashSet; import java.util.List; import java.util.stream.Collectors; @@ -64,16 +64,12 @@ public List deduplicateKeys(List keys, int parallelism) { boolean isIndexingGlobal = table.getIndex().isGlobal(); if (isIndexingGlobal) { - HashSet recordKeys = keys.stream().map(HoodieKey::getRecordKey).collect(Collectors.toCollection(HashSet::new)); - List deduplicatedKeys = new LinkedList<>(); - keys.forEach(x -> { - if (recordKeys.contains(x.getRecordKey())) { - deduplicatedKeys.add(x); - } - }); - return deduplicatedKeys; + HashSet recordKeys = new HashSet<>(); + return keys.stream() + .filter(key -> recordKeys.add(key.getRecordKey())) + .collect(Collectors.toList()); } else { - HashSet set = new HashSet<>(keys); + LinkedHashSet set = new LinkedHashSet<>(keys); keys.clear(); keys.addAll(set); return keys; diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java index 15010d21a0d62..9b86e6ccb8068 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java @@ -93,9 +93,8 @@ public JavaUpsertPartitioner(WorkloadProfile workloadProfile, HoodieEngineContex assignUpdates(workloadProfile); assignInserts(workloadProfile, context); - log.info("Total Buckets :" + totalBuckets + ", buckets info => " + bucketInfoMap + ", \n" - + "Partition to insert buckets => " + partitionPathToInsertBucketInfos + ", \n" - + "UpdateLocations mapped to buckets =>" + updateLocationToBucket); + log.info("Total Buckets :{}, buckets info => {}, \nPartition to insert buckets => {}, \nUpdateLocations mapped to buckets =>{}", + totalBuckets, bucketInfoMap, partitionPathToInsertBucketInfos, updateLocationToBucket); } private void assignUpdates(WorkloadProfile profile) { @@ -132,7 +131,7 @@ private void assignInserts(WorkloadProfile profile, HoodieEngineContext context) long averageRecordSize = averageBytesPerRecord(table.getMetaClient().getActiveTimeline().getCommitAndReplaceTimeline().filterCompletedInstants(), config); - log.info("AvgRecordSize => " + averageRecordSize); + log.info("AvgRecordSize => {}", averageRecordSize); Map> partitionSmallFilesMap = getSmallFilesForPartitions(new ArrayList(partitionPaths), context); @@ -145,7 +144,7 @@ private void assignInserts(WorkloadProfile profile, HoodieEngineContext context) List smallFiles = partitionSmallFilesMap.getOrDefault(partitionPath, new ArrayList<>()); this.smallFiles.addAll(smallFiles); - log.info("For partitionPath : " + partitionPath + " Small Files => " + smallFiles); + log.info("For partitionPath : {} Small Files => {}", partitionPath, smallFiles); long totalUnassignedInserts = pStat.getNumInserts(); List bucketNumbers = new ArrayList<>(); @@ -160,10 +159,10 @@ private void assignInserts(WorkloadProfile profile, HoodieEngineContext context) int bucket; if (updateLocationToBucket.containsKey(smallFile.location.getFileId())) { bucket = updateLocationToBucket.get(smallFile.location.getFileId()); - log.info("Assigning " + recordsToAppend + " inserts to existing update bucket " + bucket); + log.info("Assigning {} inserts to existing update bucket {}", recordsToAppend, bucket); } else { bucket = addUpdateBucket(partitionPath, smallFile.location.getFileId()); - log.info("Assigning " + recordsToAppend + " inserts to new update bucket " + bucket); + log.info("Assigning {} inserts to new update bucket {}", recordsToAppend, bucket); } if (profile.hasOutputWorkLoadStats()) { outputWorkloadStats.addInserts(smallFile.location, recordsToAppend); @@ -182,8 +181,7 @@ private void assignInserts(WorkloadProfile profile, HoodieEngineContext context) } int insertBuckets = (int) Math.ceil((1.0 * totalUnassignedInserts) / insertRecordsPerBucket); - log.info("After small file assignment: unassignedInserts => " + totalUnassignedInserts - + ", totalInsertBuckets => " + insertBuckets + ", recordsPerBucket => " + insertRecordsPerBucket); + log.info("After small file assignment: unassignedInserts => {}, totalInsertBuckets => {}, recordsPerBucket => {}", totalUnassignedInserts, insertBuckets, insertRecordsPerBucket); for (int b = 0; b < insertBuckets; b++) { bucketNumbers.add(totalBuckets); if (b < insertBuckets - 1) { @@ -210,7 +208,7 @@ private void assignInserts(WorkloadProfile profile, HoodieEngineContext context) currentCumulativeWeight += bkt.weight; insertBuckets.add(new InsertBucketCumulativeWeightPair(bkt, currentCumulativeWeight)); } - log.info("Total insert buckets for partition path " + partitionPath + " => " + insertBuckets); + log.info("Total insert buckets for partition path {} => {}", partitionPath, insertBuckets); partitionPathToInsertBucketInfos.put(partitionPath, insertBuckets); } if (profile.hasOutputWorkLoadStats()) { diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/deltacommit/BaseJavaDeltaCommitActionExecutor.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/deltacommit/BaseJavaDeltaCommitActionExecutor.java index 42be024f167da..ec25bcabd6ec0 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/deltacommit/BaseJavaDeltaCommitActionExecutor.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/deltacommit/BaseJavaDeltaCommitActionExecutor.java @@ -69,10 +69,10 @@ public Partitioner getUpsertPartitioner(WorkloadProfile profile) { @Override public Iterator> handleUpdate(String partitionPath, String fileId, Iterator> recordItr) throws IOException { - log.info("Merging updates for commit " + instantTime + " for file " + fileId); + log.info("Merging updates for commit {} for file {}", instantTime, fileId); if (!table.getIndex().canIndexLogFiles() && partitioner != null && partitioner.getSmallFileIds().contains(fileId)) { - log.info("Small file corrections for updates for commit " + instantTime + " for file " + fileId); + log.info("Small file corrections for updates for commit {} for file {}", instantTime, fileId); return super.handleUpdate(partitionPath, fileId, recordItr); } else { HoodieAppendHandle appendHandle = new HoodieAppendHandle<>(config, instantTime, table, diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestHoodieJavaWriteClientInsert.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestHoodieJavaWriteClientInsert.java index 83d51a604aef9..5883c4a558502 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestHoodieJavaWriteClientInsert.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestHoodieJavaWriteClientInsert.java @@ -27,9 +27,12 @@ import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.marker.MarkerType; +import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.common.util.FileFormatUtils; +import org.apache.hudi.common.util.MarkerUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodieWriteConfig; @@ -47,12 +50,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.AVRO_SCHEMA; import static org.apache.hudi.common.testutils.HoodieTestTable.makeNewCommitTime; @@ -140,6 +146,85 @@ public void testWriteClientAndTableServiceClientWithTimelineServer( writeClient.close(); } + /** + * HUDI-5011: exercises a Java-engine write config against both marker types with the embedded timeline + * server. {@code HoodieWriteConfig.Builder} defaults {@link MarkerType#DIRECT} for + * {@link EngineType#JAVA}, so the timeline-server-based path is only reached when + * {@code hoodie.write.markers.type} is set explicitly. Note the default applies to the config's engine + * type, not to the client: {@code HoodieJavaWriteClient} never inspects it, so a config left on the + * builder's SPARK default would resolve to TIMELINE_SERVER_BASED on its own. + * + *

Backup for the remote file system view is disabled so that a timeline-server failure fails the test + * rather than silently falling back to a local view, matching + * {@code HoodieJavaClientTestHarness#getConfigBuilder}. + */ + @ParameterizedTest + @EnumSource(MarkerType.class) + public void testInsertWithEmbeddedTimelineServerAndMarkerType(MarkerType markerType) throws Exception { + HoodieWriteConfig config = makeHoodieClientConfigBuilder(basePath) + .withMarkersType(markerType.name()) + .withFileSystemViewConfig(FileSystemViewStorageConfig.newBuilder() + .withEnableBackupForRemoteFileSystemView(false).build()) + .build(); + + HoodieJavaWriteClient writeClient = getHoodieWriteClient(config); + assertTrue(writeClient.getTimelineServer().isPresent(), + "The embedded timeline server should be running for marker type " + markerType); + + List records = new ArrayList<>(); + records.add(createSimpleRecord("1", "2021-09-11T16:16:41.415Z", 1)); + records.add(createSimpleRecord("2", "2021-09-11T16:16:41.415Z", 2)); + + String commitTime = makeNewCommitTime(1, "%09d"); + WriteClientTestUtils.startCommitWithTime(writeClient, commitTime); + List statuses = writeClient.insert(records, commitTime); + + // Inspect the markers before commit removes them. Only the timeline-server path writes MARKERS.type, + // so this is what would notice a silent fallback to DirectWriteMarkers. + metaClient = HoodieTableMetaClient.reload(metaClient); + StoragePath markerDir = new StoragePath(metaClient.getMarkerFolderPath(commitTime)); + boolean markerTypeFileExists = MarkerUtils.doesMarkerTypeFileExist(metaClient.getStorage(), markerDir); + List markerFileNames = listMarkerFileNames(markerDir); + if (markerType == MarkerType.TIMELINE_SERVER_BASED) { + assertTrue(markerTypeFileExists, + "Timeline-server-based markers should have written " + MarkerUtils.MARKER_TYPE_FILENAME); + assertTrue(markerFileNames.stream().anyMatch( + name -> name.startsWith(MarkerUtils.MARKERS_FILENAME_PREFIX) + && !name.equals(MarkerUtils.MARKER_TYPE_FILENAME)), + () -> "Timeline-server-based markers should have written a " + + MarkerUtils.MARKERS_FILENAME_PREFIX + " file. Found: " + markerFileNames); + } else { + assertFalse(markerTypeFileExists, + "Direct markers should not have written " + MarkerUtils.MARKER_TYPE_FILENAME); + // Absence of MARKERS.type alone would also hold if the write produced no markers at all, so + // require the direct markers themselves. + assertTrue(markerFileNames.stream().anyMatch(name -> name.contains(HoodieTableMetaClient.MARKER_EXTN)), + () -> "Direct markers should have written a " + HoodieTableMetaClient.MARKER_EXTN + + " file. Found: " + markerFileNames); + } + + writeClient.commit(commitTime, statuses); + + metaClient = HoodieTableMetaClient.reload(metaClient); + assertTrue(metaClient.getActiveTimeline().filterCompletedInstants().lastInstant().isPresent(), + "The commit should have completed for marker type " + markerType); + assertEquals(1, getIncrementalFiles("2021/09/11", "0", -1).length, + "One base file should have been written for marker type " + markerType); + } + + /** + * The marker file names written under {@code markerDir}, read recursively off storage rather than + * through the timeline server, so the assertion does not lean on the path it is checking. + */ + private List listMarkerFileNames(StoragePath markerDir) throws IOException { + if (!metaClient.getStorage().exists(markerDir)) { + return Collections.emptyList(); + } + return metaClient.getStorage().listFiles(markerDir).stream() + .map(pathInfo -> pathInfo.getPath().getName()) + .collect(Collectors.toList()); + } + @Test public void testInsert() throws Exception { HoodieWriteConfig config = makeHoodieClientConfigBuilder(basePath).withMergeAllowDuplicateOnInserts(true).build(); diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestJavaHoodieBackedMetadata.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestJavaHoodieBackedMetadata.java index 9563439c39eb9..36f92e621d149 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestJavaHoodieBackedMetadata.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/TestJavaHoodieBackedMetadata.java @@ -45,6 +45,7 @@ import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType; +import org.apache.hudi.common.model.HoodieRecordGlobalLocation; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.HoodieWriteStat; import org.apache.hudi.common.model.TableServiceType; @@ -77,6 +78,7 @@ import org.apache.hudi.common.util.JsonUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.common.util.hash.PartitionIndexID; import org.apache.hudi.config.HoodieArchivalConfig; import org.apache.hudi.config.HoodieCleanConfig; @@ -99,6 +101,7 @@ import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.metadata.JavaHoodieBackedTableMetadataWriter; import org.apache.hudi.metadata.MetadataPartitionType; +import org.apache.hudi.metadata.RawKey; import org.apache.hudi.metrics.Metrics; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; @@ -164,6 +167,8 @@ import static org.apache.hudi.metadata.HoodieTableMetadataUtil.deleteMetadataTable; import static org.apache.hudi.metadata.MetadataPartitionType.COLUMN_STATS; import static org.apache.hudi.metadata.MetadataPartitionType.FILES; +import static org.apache.hudi.metadata.MetadataPartitionType.PARTITION_STATS; +import static org.apache.hudi.metadata.MetadataPartitionType.RECORD_INDEX; import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -946,7 +951,7 @@ private void verifyMetadataMergedRecords(HoodieTableMetaClient metadataMetaClien HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())); HoodieAvroReaderContext readerContext = new HoodieAvroReaderContext(metadataMetaClient.getStorageConf(), metadataMetaClient.getTableConfig(), Option.empty(), Option.empty(), new TypedProperties()); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metadataMetaClient) .withLogFiles(logFiles.stream()) @@ -1322,6 +1327,116 @@ public void testReadRecordIndexLocationsByBucketId() throws Exception { } } + @Test + public void testMetadataReadRoundTrip() throws Exception { + this.tableType = COPY_ON_WRITE; + initPath(); + initFileSystem(basePath, storageConf); + storage.createDirectory(new StoragePath(basePath)); + initMetaClient(tableType); + initTestDataGenerator(); + metadataTableBasePath = getMetadataTableBasePath(basePath); + + HoodieJavaEngineContext engineContext = new HoodieJavaEngineContext(storageConf); + HoodieWriteConfig writeConfig = getWriteConfigBuilder(true, true, false) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withMetadataIndexColumnStats(true) + .withColumnStatsIndexForColumns(HoodieRecord.RECORD_KEY_METADATA_FIELD) + .withEnableGlobalRecordLevelIndex(true) + .withRecordIndexFileGroupCount(3, 3) + .build()) + .build(); + + try (HoodieJavaWriteClient client = new HoodieJavaWriteClient(engineContext, writeConfig)) { + String instantTime = client.startCommit(); + List records = dataGen.generateInserts(instantTime, 30); + Map recordKeyToPartition = records.stream() + .collect(Collectors.toMap(HoodieRecord::getRecordKey, HoodieRecord::getPartitionPath)); + List writeStatuses = client.insert(records, instantTime); + client.commit(instantTime, writeStatuses); + assertNoWriteErrors(writeStatuses); + + metaClient = HoodieTableMetaClient.reload(metaClient); + assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(FILES)); + assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(COLUMN_STATS)); + assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(PARTITION_STATS)); + assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX)); + + try (HoodieTableMetadata tableMetadata = metadata(client); + HoodieTableMetadata fileSystemMetadata = new FileSystemBackedTableMetadata( + engineContext, metaClient.getTableConfig(), metaClient.getStorage(), basePath)) { + List expectedPartitions = fileSystemMetadata.getAllPartitionPaths(); + List actualPartitions = tableMetadata.getAllPartitionPaths(); + Collections.sort(expectedPartitions); + Collections.sort(actualPartitions); + assertEquals(expectedPartitions, actualPartitions); + assertFalse(actualPartitions.isEmpty()); + + List> partitionAndFileNames = new ArrayList<>(); + for (String partition : actualPartitions) { + StoragePath partitionPath = partition.isEmpty() + ? new StoragePath(basePath) + : new StoragePath(basePath, partition); + List expectedFileNames = fileSystemMetadata.getAllFilesInPartition(partitionPath).stream() + .map(pathInfo -> pathInfo.getPath().getName()) + .sorted() + .collect(Collectors.toList()); + List actualFileNames = tableMetadata.getAllFilesInPartition(partitionPath).stream() + .map(pathInfo -> pathInfo.getPath().getName()) + .sorted() + .collect(Collectors.toList()); + assertEquals(expectedFileNames, actualFileNames); + assertFalse(actualFileNames.isEmpty()); + actualFileNames.forEach(fileName -> partitionAndFileNames.add(Pair.of(partition, fileName))); + } + + Map, HoodieMetadataColumnStats> columnStats = + tableMetadata.getColumnStats(partitionAndFileNames, HoodieRecord.RECORD_KEY_METADATA_FIELD); + assertEquals(partitionAndFileNames.size(), columnStats.size()); + columnStats.values().forEach(stats -> { + assertEquals(HoodieRecord.RECORD_KEY_METADATA_FIELD, stats.getColumnName().toString()); + assertFalse(stats.getIsDeleted()); + assertNotNull(stats.getMinValue()); + assertNotNull(stats.getMaxValue()); + }); + + Map partitionStatsKeyToPartition = actualPartitions.stream() + .collect(Collectors.toMap( + partition -> HoodieTableMetadataUtil.getPartitionStatsIndexKey( + partition, HoodieRecord.RECORD_KEY_METADATA_FIELD), + partition -> partition)); + List partitionStatsKeys = partitionStatsKeyToPartition.keySet().stream() + .map(key -> (RawKey) () -> key) + .collect(Collectors.toList()); + List> partitionStats = tableMetadata.getRecordsByKeyPrefixes( + HoodieListData.eager(partitionStatsKeys), PARTITION_STATS.getPartitionPath(), true).collectAsList(); + assertEquals(actualPartitions.size(), partitionStats.size()); + partitionStats.forEach(record -> { + assertTrue(partitionStatsKeyToPartition.containsKey(record.getRecordKey())); + assertTrue(record.getData().getColumnStatMetadata().isPresent()); + HoodieMetadataColumnStats stats = record.getData().getColumnStatMetadata().get(); + assertEquals(partitionStatsKeyToPartition.get(record.getRecordKey()), stats.getFileName().toString()); + assertEquals(HoodieRecord.RECORD_KEY_METADATA_FIELD, stats.getColumnName().toString()); + assertFalse(stats.getIsDeleted()); + assertNotNull(stats.getMinValue()); + assertNotNull(stats.getMaxValue()); + }); + + List> recordLocations = tableMetadata + .readRecordIndexLocationsWithKeys(HoodieListData.eager(new ArrayList<>(recordKeyToPartition.keySet()))) + .collectAsList(); + assertEquals(records.size(), recordLocations.size()); + assertEquals(recordKeyToPartition.keySet(), + recordLocations.stream().map(Pair::getLeft).collect(Collectors.toSet())); + recordLocations.forEach(entry -> { + assertEquals(recordKeyToPartition.get(entry.getLeft()), entry.getRight().getPartitionPath()); + assertNotNull(entry.getRight().getFileId()); + }); + } + } + } + @Test public void testReadRecordIndexLocationsByBucketIdFailsWhenRecordIndexDisabled() throws Exception { init(HoodieTableType.COPY_ON_WRITE); @@ -1840,7 +1955,7 @@ public void testMultiWriterForDoubleLocking() throws Exception { // Ensure all commits were synced to the Metadata Table HoodieTableMetaClient metadataMetaClient = createMetaClientForMetadataTable(); - log.warn("total commits in metadata table " + metadataMetaClient.getActiveTimeline().getCommitsTimeline().countInstants()); + log.warn("total commits in metadata table {}", metadataMetaClient.getActiveTimeline().getCommitsTimeline().countInstants()); // 6 commits and 2 cleaner commits. assertEquals(metadataMetaClient.getActiveTimeline().getDeltaCommitTimeline().filterCompletedInstants().countInstants(), 8); @@ -1867,7 +1982,7 @@ public void testReattemptOfFailedClusteringCommit() throws Exception { HoodieJavaWriteClient client = getHoodieWriteClient(config); // Write 1 (Bulk insert) - String newCommitTime = "0000001"; + String newCommitTime = WriteClientTestUtils.createNewInstantTime(); List records = dataGen.generateInserts(newCommitTime, 20); WriteClientTestUtils.startCommitWithTime(client, newCommitTime); List writeStatuses = client.insert(records, newCommitTime); @@ -1876,7 +1991,7 @@ public void testReattemptOfFailedClusteringCommit() throws Exception { validateMetadata(client); // Write 2 (inserts) - newCommitTime = "0000002"; + newCommitTime = WriteClientTestUtils.createNewInstantTime(); WriteClientTestUtils.startCommitWithTime(client, newCommitTime); records = dataGen.generateInserts(newCommitTime, 20); writeStatuses = client.insert(records, newCommitTime); @@ -1909,7 +2024,7 @@ public void testReattemptOfFailedClusteringCommit() throws Exception { replacedFileIds.add(new HoodieFileGroupId(partitionFiles.getKey(), file)))); // trigger new write to mimic other writes succeeding before re-attempt. - newCommitTime = "0000003"; + newCommitTime = WriteClientTestUtils.createNewInstantTime(); WriteClientTestUtils.startCommitWithTime(client, newCommitTime); records = dataGen.generateInserts(newCommitTime, 20); writeStatuses = client.insert(records, newCommitTime); @@ -2832,17 +2947,17 @@ private void validateMetadata(HoodieJavaWriteClient testClient, Option i if ((fsFileNames.size() != metadataFilenames.size()) || (!fsFileNames.equals(metadataFilenames))) { - log.info("*** File system listing = " + Arrays.toString(fsFileNames.toArray())); - log.info("*** Metadata listing = " + Arrays.toString(metadataFilenames.toArray())); + log.info("*** File system listing = {}", Arrays.toString(fsFileNames.toArray())); + log.info("*** Metadata listing = {}", Arrays.toString(metadataFilenames.toArray())); for (String fileName : fsFileNames) { if (!metadataFilenames.contains(fileName)) { - log.error(partition + "FsFilename " + fileName + " not found in Meta data"); + log.error("{}FsFilename {} not found in Meta data", partition, fileName); } } for (String fileName : metadataFilenames) { if (!fsFileNames.contains(fileName)) { - log.error(partition + "Metadata file " + fileName + " not found in original FS"); + log.error("{}Metadata file {} not found in original FS", partition, fileName); } } } @@ -2922,7 +3037,7 @@ private void validateMetadata(HoodieJavaWriteClient testClient, Option i }); // TODO: include validation for record_index partition here. - log.info("Validation time=" + timer.endTimer()); + log.info("Validation time={}", timer.endTimer()); } } diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/common/TestMultipleHoodieJavaWriteClient.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/common/TestMultipleHoodieJavaWriteClient.java index f9d95afa8d9fc..50e8d56832830 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/common/TestMultipleHoodieJavaWriteClient.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/common/TestMultipleHoodieJavaWriteClient.java @@ -35,6 +35,7 @@ import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodieLockConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieWriteConflictException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.keygen.constant.KeyGeneratorType; import org.apache.hudi.storage.StorageConfiguration; @@ -207,13 +208,24 @@ record -> { String startCommitTime = writer.startCommit(); List s = writer.upsert(Collections.singletonList(record), startCommitTime); - writer.commit(startCommitTime, s); - LOGGER.info("Completed commit"); - synchronized (writerQueue) { - try { - writerQueue.put(writer); - } catch (InterruptedException e) { - throw new RuntimeException(e); + try { + writer.commit(startCommitTime, s); + LOGGER.info("Completed commit"); + } catch (HoodieWriteConflictException e) { + // Under OPTIMISTIC_CONCURRENCY_CONTROL, two writers updating overlapping file + // groups conflict and one of them is aborted by design. This test validates that + // concurrent writers do not deadlock, so an aborted commit is an expected outcome + // and must not fail the test. + LOGGER.info("Commit {} was aborted by an expected OCC conflict", startCommitTime); + } finally { + // Always return the writer to the pool, even when its commit was aborted, so the + // remaining records are not starved of writers. + synchronized (writerQueue) { + try { + writerQueue.put(writer); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } } } } diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/functional/TestHoodieJavaClientOnMergeOnReadStorage.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/functional/TestHoodieJavaClientOnMergeOnReadStorage.java index 8f4a73f51b088..74185cc35ceac 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/functional/TestHoodieJavaClientOnMergeOnReadStorage.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/client/functional/TestHoodieJavaClientOnMergeOnReadStorage.java @@ -18,31 +18,41 @@ package org.apache.hudi.client.functional; +import org.apache.hudi.callback.common.HoodieWriteCommitCallbackMessage; import org.apache.hudi.client.HoodieJavaWriteClient; import org.apache.hudi.client.WriteClientTestUtils; +import org.apache.hudi.client.clustering.plan.strategy.JavaSizeBasedClusteringPlanStrategy; +import org.apache.hudi.client.clustering.run.strategy.JavaSortAndSizeExecutionStrategy; import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.view.SyncableFileSystemView; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.testutils.HoodieTestTable; import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieClusteringConfig; import org.apache.hudi.config.HoodieCompactionConfig; +import org.apache.hudi.config.HoodieWriteCommitCallbackConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.table.action.HoodieWriteMetadata; import org.apache.hudi.testutils.GenericRecordValidationTestUtils; import org.apache.hudi.testutils.HoodieJavaClientTestHarness; +import org.apache.hudi.testutils.RecordingCommitCallback; import org.apache.avro.generic.GenericRecord; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.apache.hudi.common.testutils.HoodieTestUtils.TIMELINE_FACTORY; import static org.apache.hudi.testutils.GenericRecordValidationTestUtils.assertDataInMORTable; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class TestHoodieJavaClientOnMergeOnReadStorage extends HoodieJavaClientTestHarness { @@ -180,4 +190,94 @@ protected HoodieTableType getTableType() { return HoodieTableType.MERGE_ON_READ; } + @Test + public void testWriteCommitCallbackFiresOnCompaction() throws Exception { + RecordingCommitCallback.reset(); + HoodieWriteConfig config = getConfigBuilder(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA, + HoodieIndex.IndexType.INMEMORY) + .withCompactionConfig(HoodieCompactionConfig.newBuilder().withMaxNumDeltaCommitsBeforeCompaction(2).build()) + .withCallbackConfig(HoodieWriteCommitCallbackConfig.newBuilder() + .writeCommitCallbackOn("true") + .withCallbackClass(RecordingCommitCallback.class.getName()) + .build()) + .build(); + HoodieJavaWriteClient client = getHoodieWriteClient(config); + + // Two delta commits through the auto-commit path. + String commitTime = WriteClientTestUtils.createNewInstantTime(); + insertBatch(config, client, commitTime, "000", 100, HoodieJavaWriteClient::insert, + false, false, 100, 100, 1, Option.empty(), INSTANT_GENERATOR); + String prevCommit = commitTime; + commitTime = WriteClientTestUtils.createNewInstantTime(); + updateBatch(config, client, commitTime, prevCommit, + Option.of(Arrays.asList(prevCommit)), "000", 50, HoodieJavaWriteClient::upsert, + false, false, 5, 100, 2, config.populateMetaFields(), INSTANT_GENERATOR); + + // The callback must fire for the auto-committed delta commits with the deltacommit action. + assertTrue(RecordingCommitCallback.messages().stream().anyMatch(m -> + HoodieTimeline.DELTA_COMMIT_ACTION.equals(m.getCommitActionType().orElse(null))), + "callback must fire for delta commits"); + + // Schedule, execute and commit compaction. + Option compactionTime = client.scheduleCompaction(Option.empty()); + assertTrue(compactionTime.isPresent()); + HoodieWriteMetadata writeMetadata = client.compact(compactionTime.get()); + client.commitCompaction(compactionTime.get(), writeMetadata, Option.empty()); + assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants().containsInstant(compactionTime.get())); + + // The callback must fire exactly once for the compaction completion, reporting the completed + // timeline action (commit). + List compactionMessages = RecordingCommitCallback.messages().stream() + .filter(m -> m.getCommitTime().equals(compactionTime.get())) + .collect(Collectors.toList()); + assertEquals(1, compactionMessages.size(), "callback must fire once for the compaction commit"); + assertEquals(HoodieTimeline.COMMIT_ACTION, compactionMessages.get(0).getCommitActionType().orElse(null)); + assertNotNull(compactionMessages.get(0).getPrevFilePaths(), "prevFilePaths must never be null"); + } + + @Test + public void testWriteCommitCallbackFiresOnClustering() throws Exception { + RecordingCommitCallback.reset(); + HoodieClusteringConfig clusteringConfig = HoodieClusteringConfig.newBuilder() + .withClusteringMaxNumGroups(10) + .withClusteringSortColumns("_row_key") + .withClusteringTargetPartitions(0) + .withClusteringPlanStrategyClass(JavaSizeBasedClusteringPlanStrategy.class.getName()) + .withClusteringExecutionStrategyClass(JavaSortAndSizeExecutionStrategy.class.getName()) + .build(); + HoodieWriteConfig config = getConfigBuilder(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA, + HoodieIndex.IndexType.INMEMORY) + .withClusteringConfig(clusteringConfig) + .withCallbackConfig(HoodieWriteCommitCallbackConfig.newBuilder() + .writeCommitCallbackOn("true") + .withCallbackClass(RecordingCommitCallback.class.getName()) + .build()) + .build(); + HoodieJavaWriteClient client = getHoodieWriteClient(config); + + // Two inserts create base-file groups that clustering can rewrite. + String commitTime = WriteClientTestUtils.createNewInstantTime(); + insertBatch(config, client, commitTime, "000", 100, HoodieJavaWriteClient::insert, + false, false, 100, 100, 1, Option.empty(), INSTANT_GENERATOR); + commitTime = WriteClientTestUtils.createNewInstantTime(); + insertBatch(config, client, commitTime, "001", 100, HoodieJavaWriteClient::insert, + false, false, 100, 200, 2, Option.empty(), INSTANT_GENERATOR); + + // Schedule and execute clustering inline (shouldComplete = true completes the commit). + Option clusteringTime = client.scheduleClustering(Option.empty()); + assertTrue(clusteringTime.isPresent(), "expected a clustering plan to be scheduled"); + client.cluster(clusteringTime.get(), true); + assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants().containsInstant(clusteringTime.get())); + + // The callback must fire once for the clustering completion, reporting the action actually on + // the timeline (replacecommit). + List clusteringMessages = RecordingCommitCallback.messages().stream() + .filter(m -> m.getCommitTime().equals(clusteringTime.get())) + .collect(Collectors.toList()); + assertEquals(1, clusteringMessages.size(), "callback must fire once for the clustering commit"); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, clusteringMessages.get(0).getCommitActionType().orElse(null)); + // Clustering writes new file groups, so every stat carries NULL_COMMIT and no prev path resolves. + assertTrue(clusteringMessages.get(0).getPrevFilePaths().isEmpty()); + } + } diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitionerFactory.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitionerFactory.java new file mode 100644 index 0000000000000..708e25d29cba6 --- /dev/null +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitionerFactory.java @@ -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.hudi.execution.bulkinsert; + +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.table.BulkInsertPartitioner; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests the sort-mode based selection in {@link JavaBulkInsertInternalPartitionerFactory}. + */ +public class TestJavaBulkInsertInternalPartitionerFactory { + + @Test + void noneModeReturnsNonSortPartitioner() { + BulkInsertPartitioner partitioner = JavaBulkInsertInternalPartitionerFactory.get(BulkInsertSortMode.NONE); + assertInstanceOf(JavaNonSortPartitioner.class, partitioner); + } + + @Test + void globalSortModeReturnsGlobalSortPartitioner() { + BulkInsertPartitioner partitioner = JavaBulkInsertInternalPartitionerFactory.get(BulkInsertSortMode.GLOBAL_SORT); + assertInstanceOf(JavaGlobalSortPartitioner.class, partitioner); + } + + @Test + void unsupportedModeThrows() { + assertThrows(HoodieException.class, + () -> JavaBulkInsertInternalPartitionerFactory.get(BulkInsertSortMode.PARTITION_SORT)); + assertThrows(HoodieException.class, + () -> JavaBulkInsertInternalPartitionerFactory.get(BulkInsertSortMode.PARTITION_PATH_REPARTITION)); + } +} diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.java new file mode 100644 index 0000000000000..2f600bac0954f --- /dev/null +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.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.hudi.metadata; + +import org.apache.hudi.common.model.EmptyHoodieRecordPayload; +import org.apache.hudi.common.model.HoodieAvroRecord; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.util.StringUtils; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link JavaHoodieMetadataBulkInsertPartitioner}, which sorts MDT/HFile record keys by raw + * UTF-8 bytes rather than String (UTF-16) order. + */ +class TestJavaHoodieMetadataBulkInsertPartitioner { + + @Test + void repartitionRecordsSortsBinaryKeysByUtf8Bytes() { + // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte 0xF0) in raw UTF-8 byte + // order, but AFTER it under String.compareTo (UTF-16). This is the pathological pair the + // partitioner's UTF-8 comparator must get right so HFile forward-only seeks stay valid. + String bmpPrivateUse = new String(Character.toChars(0xE000)); + String supplementary = new String(Character.toChars(0x20000)); + // All records share one file group so the partitioner's single-group assumption holds. + String fileId = "files-0000"; + + // Shuffled input mixing both prefixes plus ascii suffixes. + List inputKeys = Arrays.asList( + supplementary + "-b", + "ascii-key", + bmpPrivateUse + "-a", + supplementary + "-a", + bmpPrivateUse + "-b"); + + List> records = new ArrayList<>(); + for (String key : inputKeys) { + HoodieRecord record = + new HoodieAvroRecord<>(new HoodieKey(key, ""), new EmptyHoodieRecordPayload()); + record.unseal(); + record.setCurrentLocation(new HoodieRecordLocation("001", fileId)); + record.seal(); + records.add(record); + } + + JavaHoodieMetadataBulkInsertPartitioner partitioner = + new JavaHoodieMetadataBulkInsertPartitioner<>(); + List> sorted = partitioner.repartitionRecords(records, 1); + + assertTrue(partitioner.arePartitionRecordsSorted(), "Records must be sorted"); + + List actualKeys = new ArrayList<>(); + for (HoodieRecord record : sorted) { + actualKeys.add(record.getRecordKey()); + } + List expectedKeys = new ArrayList<>(inputKeys); + expectedKeys.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); + assertEquals(expectedKeys, actualKeys, "Records must be sorted by UTF-8 byte order"); + + // The divergent pair: every U+E000-prefixed key precedes every U+20000-prefixed key in UTF-8 + // byte order, the opposite of String.compareTo (UTF-16) order. + int lastBmpIndex = -1; + int firstSupplementaryIndex = actualKeys.size(); + for (int i = 0; i < actualKeys.size(); i++) { + if (actualKeys.get(i).startsWith(bmpPrivateUse)) { + lastBmpIndex = i; + } else if (actualKeys.get(i).startsWith(supplementary) && firstSupplementaryIndex == actualKeys.size()) { + firstSupplementaryIndex = i; + } + } + assertTrue(lastBmpIndex < firstSupplementaryIndex, + "All U+E000-prefixed keys should sort before U+20000-prefixed keys in UTF-8 order"); + } +} diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java index 8fc8b480a3f0d..a0998cbdd0310 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java @@ -366,7 +366,7 @@ public void testFileSizeUpsertRecords() throws Exception { int counts = 0; for (File file : Paths.get(basePath, "2016/01/31").toFile().listFiles()) { if (file.getName().endsWith(table.getBaseFileExtension()) && FSUtils.getCommitTime(file.getName()).equals(instantTime)) { - log.info(file.getName() + "-" + file.length()); + log.info("{}-{}", file.getName(), file.length()); counts++; } } diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaDeleteHelper.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaDeleteHelper.java new file mode 100644 index 0000000000000..fab7d0423163f --- /dev/null +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaDeleteHelper.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.hudi.table.action.commit; + +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.table.HoodieTable; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Tests for {@link JavaDeleteHelper}. */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class TestJavaDeleteHelper { + + @Test + void testDeduplicateKeysForGlobalAndPartitionedIndexes() { + JavaDeleteHelper helper = JavaDeleteHelper.newInstance(); + assertSame(helper, JavaDeleteHelper.newInstance()); + + HoodieTable table = mock(HoodieTable.class); + HoodieIndex index = mock(HoodieIndex.class); + when(table.getIndex()).thenReturn(index); + List keys = new ArrayList<>(Arrays.asList( + new HoodieKey("id1", "p1"), + new HoodieKey("id1", "p2"), + new HoodieKey("id2", "p1"), + new HoodieKey("id2", "p1"))); + + when(index.isGlobal()).thenReturn(true); + List globalResult = helper.deduplicateKeys(keys, table, 1); + assertEquals(Arrays.asList("id1", "id2"), globalResult.stream() + .map(HoodieKey::getRecordKey).collect(Collectors.toList())); + assertEquals(Arrays.asList("p1", "p1"), globalResult.stream() + .map(HoodieKey::getPartitionPath).collect(Collectors.toList())); + assertNotSame(keys, globalResult); + + when(index.isGlobal()).thenReturn(false); + List partitionedResult = helper.deduplicateKeys(keys, table, 1); + assertSame(keys, partitionedResult); + assertEquals(Arrays.asList( + new HoodieKey("id1", "p1"), + new HoodieKey("id1", "p2"), + new HoodieKey("id2", "p1")), partitionedResult); + } +} diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/HoodieJavaClientTestHarness.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/HoodieJavaClientTestHarness.java index 353be1549081b..5d5d15e19d9e6 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/HoodieJavaClientTestHarness.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/HoodieJavaClientTestHarness.java @@ -353,7 +353,7 @@ public void validateMetadata(HoodieTestTable testTable, List inflightCom runFullValidation(writeConfig, metadataTableBasePath, engineContext); } - log.info("Validation time=" + timer.endTimer()); + log.info("Validation time={}", timer.endTimer()); } protected void validateFilesPerPartition(HoodieTestTable testTable, diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/TestHoodieMetadataBase.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/TestHoodieMetadataBase.java index b555b76964613..792f742ed92a5 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/TestHoodieMetadataBase.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/testutils/TestHoodieMetadataBase.java @@ -308,6 +308,7 @@ protected HoodieWriteConfig.Builder getWriteConfigBuilder(HoodieFailedWritesClea .enable(useFileListingMetadata) .withMetadataIndexColumnStats(false) .enableMetrics(enableMetrics) + .enableDetailedMetadataMetrics(enableMetrics) .ignoreSpuriousDeletes(validateMetadataPayloadConsistency) .withMetadataIndexColumnStats(false) // HUDI-8774 .withEngineType(EngineType.JAVA) diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SingleSparkJobConsistentHashingExecutionStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SingleSparkJobConsistentHashingExecutionStrategy.java index 7b49e7972f473..112273387b81b 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SingleSparkJobConsistentHashingExecutionStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SingleSparkJobConsistentHashingExecutionStrategy.java @@ -44,6 +44,7 @@ import org.apache.hudi.io.HoodieWriteHandle; import org.apache.hudi.io.IOUtils; import org.apache.hudi.io.WriteHandleFactory; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.cluster.strategy.BaseConsistentHashingBucketClusteringPlanStrategy; import org.apache.hudi.util.ExecutorFactory; @@ -69,13 +70,15 @@ @Slf4j public class SingleSparkJobConsistentHashingExecutionStrategy extends SingleSparkJobExecutionStrategy { - private final String indexKeyFields; + // parsed once; the per-record bucket lookup uses the List overload of getBucket so the + // comma-separated config string is not re-split per record + private final List indexKeyFieldList; private final HoodieSchema readerSchema; public SingleSparkJobConsistentHashingExecutionStrategy(HoodieTable table, HoodieEngineContext engineContext, HoodieWriteConfig writeConfig) { super(table, engineContext, writeConfig); - this.indexKeyFields = table.getConfig().getBucketIndexHashField(); + this.indexKeyFieldList = KeyGenUtils.getIndexKeyFields(table.getConfig().getBucketIndexHashField()); this.readerSchema = HoodieSchemaUtils.addMetadataFields(HoodieSchema.parse(writeConfig.getSchema())); } @@ -111,7 +114,8 @@ private List performBucketMergeForGroup(ReaderContextFactory rea Option> extraMetadata = clusteringGroup.getExtraMetadata(); ValidationUtils.checkArgument(extraMetadata.isPresent(), "Extra metadata should be present for consistent hashing operations"); String partition = extraMetadata.get().get(BaseConsistentHashingBucketClusteringPlanStrategy.METADATA_PARTITION_KEY); - ValidationUtils.checkArgument(!StringUtils.isNullOrEmpty(partition), "Partition should not be null or empty"); + // Note: partition can be an empty string for non-partitioned tables, so only check for null here. + ValidationUtils.checkArgument(partition != null, "Partition should not be null"); List nodes = decodeConsistentHashingNodes(clusteringGroup); Option newBucket = Option.fromJavaOptional(nodes.stream().filter(node -> node.getTag() == ConsistentHashingNode.NodeTag.REPLACE).findFirst()); ValidationUtils.checkArgument(newBucket.isPresent(), "New bucket should be present for merge operation"); @@ -197,7 +201,8 @@ private List performBucketSplitForGroup(ReaderContextFactory rea Option> extraMetadata = clusteringGroup.getExtraMetadata(); ValidationUtils.checkArgument(extraMetadata.isPresent(), "Extra metadata should be present for consistent hashing operations"); String partition = extraMetadata.get().get(BaseConsistentHashingBucketClusteringPlanStrategy.METADATA_PARTITION_KEY); - ValidationUtils.checkArgument(!StringUtils.isNullOrEmpty(partition), "Partition should not be null or empty"); + // Note: partition can be an empty string for non-partitioned tables, so only check for null here. + ValidationUtils.checkArgument(partition != null, "Partition should not be null"); List nodes = decodeConsistentHashingNodes(clusteringGroup); Integer seqNo = Integer.parseInt(extraMetadata.get().get(BaseConsistentHashingBucketClusteringPlanStrategy.METADATA_SEQUENCE_NUMBER_KEY)); HoodieConsistentHashingMetadata metadata = new HoodieConsistentHashingMetadata((short) 0, partition, instantTime, 0, seqNo + 1, Collections.emptyList()); @@ -205,7 +210,7 @@ private List performBucketSplitForGroup(ReaderContextFactory rea ConsistentBucketIdentifier identifier = new ConsistentBucketIdentifier(metadata); ClusteringOperation operation = clusteringGroup.getOperations().get(0); ClosableIterator> iterator = getRecordIterator(readerContextFactory, operation, instantTime, IOUtils.getMaxMemoryPerCompaction(new SparkTaskContextSupplier(), writeConfig)); - Function, String> fileIdPrefixExtractor = record -> identifier.getBucket(record.getRecordKey(), this.indexKeyFields).getFileIdPrefix(); + Function, String> fileIdPrefixExtractor = record -> identifier.getBucket(record.getRecordKey(), this.indexKeyFieldList).getFileIdPrefix(); HoodieConsumer, List> insertHandler = new InsertHandler(writeConfig, instantTime, getHoodieTable(), taskContextSupplier, new FixedIdSuffixCreateHandleFactory(), false, fileIdPrefixExtractor, readerSchema); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkBinaryCopyClusteringExecutionStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkBinaryCopyClusteringExecutionStrategy.java index f68579e580d7b..efabfeaf5d4ed 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkBinaryCopyClusteringExecutionStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkBinaryCopyClusteringExecutionStrategy.java @@ -95,7 +95,7 @@ public HoodieWriteMetadata> performClustering( JavaSparkContext engineContext = HoodieSparkEngineContext.getSparkContext(getEngineContext()); TaskContextSupplier taskContextSupplier = getEngineContext().getTaskContextSupplier(); JavaRDD groupInfoJavaRDD = engineContext.parallelize(clusteringGroupInfos, clusteringGroupInfos.size()); - log.info("number of partitions for clustering " + groupInfoJavaRDD.getNumPartitions()); + log.info("number of partitions for clustering {}", groupInfoJavaRDD.getNumPartitions()); JavaRDD writeStatusRDD = groupInfoJavaRDD .mapPartitions(clusteringOps -> { Iterable clusteringOpsIterable = () -> clusteringOps; diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkExternalFileClusteringExecutionStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkExternalFileClusteringExecutionStrategy.java index be5c8aaa01ef5..0abcec9c9bee4 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkExternalFileClusteringExecutionStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/run/strategy/SparkExternalFileClusteringExecutionStrategy.java @@ -87,7 +87,7 @@ protected List performClusteringForGroup(ReaderContextFactory re try { getHoodieTable().getStorage().deleteFile(writeHandler.getPath()); } catch (Exception deleteEx) { - LOG.warn("Failed to clean up partial output file: " + writeHandler.getPath(), deleteEx); + LOG.warn("Failed to clean up partial output file: {}", writeHandler.getPath(), deleteEx); } throw new HoodieClusteringException("Failed to transform file: " + dataFilePathStr, e); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java index 751e2a2858bca..3ff660f7dcb10 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/BaseSparkUpdateStrategy.java @@ -25,7 +25,7 @@ import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.cluster.strategy.UpdateStrategy; -import java.util.List; +import java.util.HashSet; import java.util.Set; /** @@ -35,8 +35,9 @@ public abstract class BaseSparkUpdateStrategy extends UpdateStrategy>> { public BaseSparkUpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, - Set fileGroupsInPendingClustering) { - super(engineContext, table, fileGroupsInPendingClustering); + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + super(engineContext, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); } /** @@ -44,9 +45,9 @@ public BaseSparkUpdateStrategy(HoodieEngineContext engineContext, HoodieTable ta * @param inputRecords the records to write, tagged with target file id * @return the records matched file group ids */ - protected List getGroupIdsWithUpdate(HoodieData> inputRecords) { - return inputRecords + protected Set getGroupIdsWithUpdate(HoodieData> inputRecords) { + return new HashSet<>(inputRecords .filter(record -> record.getCurrentLocation() != null) - .map(record -> new HoodieFileGroupId(record.getPartitionPath(), record.getCurrentLocation().getFileId())).distinct().collectAsList(); + .map(record -> new HoodieFileGroupId(record.getPartitionPath(), record.getCurrentLocation().getFileId())).distinct().collectAsList()); } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java index 7de85ae977871..7da558a5d5684 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkAllowUpdateStrategy.java @@ -25,7 +25,6 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.table.HoodieTable; -import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -35,13 +34,17 @@ public class SparkAllowUpdateStrategy extends BaseSparkUpdateStrategy { public SparkAllowUpdateStrategy( - HoodieEngineContext engineContext, HoodieTable table, Set fileGroupsInPendingClustering) { - super(engineContext, table, fileGroupsInPendingClustering); + HoodieEngineContext engineContext, HoodieTable table, + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + super(engineContext, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); } @Override public Pair>, Set> handleUpdate(HoodieData> taggedRecordsRDD) { - List fileGroupIdsWithRecordUpdate = getGroupIdsWithUpdate(taggedRecordsRDD); + // TODO: also consider fileGroupsToBeReplaced so INSERT_OVERWRITE overlapping with pending + // clustering can be rejected/handled here for users who set SparkAllowUpdateStrategy. + Set fileGroupIdsWithRecordUpdate = getGroupIdsWithUpdate(taggedRecordsRDD); Set fileGroupIdsWithUpdatesAndPendingClustering = fileGroupIdsWithRecordUpdate.stream() .filter(fileGroupsInPendingClustering::contains) .collect(Collectors.toSet()); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java index 2d8247de92e77..d63fe860cdacd 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkConsistentBucketDuplicateUpdateStrategy.java @@ -28,11 +28,11 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.index.bucket.ConsistentBucketIdentifier; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.cluster.strategy.UpdateStrategy; import org.apache.hudi.table.action.cluster.util.ConsistentHashingUpdateStrategyUtils; -import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -49,12 +49,16 @@ */ public class SparkConsistentBucketDuplicateUpdateStrategy extends UpdateStrategy>> { - public SparkConsistentBucketDuplicateUpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, Set fileGroupsInPendingClustering) { - super(engineContext, table, fileGroupsInPendingClustering); + public SparkConsistentBucketDuplicateUpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + super(engineContext, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); } @Override public Pair>, Set> handleUpdate(HoodieData> taggedRecordsRDD) { + // TODO: also consider fileGroupsToBeReplaced so INSERT_OVERWRITE overlapping with pending + // clustering is handled here for the consistent-bucket duplicate-update strategy. if (fileGroupsInPendingClustering.isEmpty()) { return Pair.of(taggedRecordsRDD, Collections.emptySet()); } @@ -74,7 +78,7 @@ public Pair>, Set> handleUpdate(Ho ConsistentHashingUpdateStrategyUtils.constructPartitionToIdentifier(partitions, table); // Produce records tagged with new record location - List indexKeyFields = Arrays.asList(table.getConfig().getBucketIndexHashField().split(",")); + List indexKeyFields = KeyGenUtils.getIndexKeyFields(table.getConfig().getBucketIndexHashField()); HoodieData> redirectedRecordsRDD = filteredRecordsRDD.map(r -> { Pair identifierPair = partitionToIdentifier.get(r.getPartitionPath()); ConsistentHashingNode node = identifierPair.getValue().getBucket(r.getKey(), indexKeyFields); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java index 8f943d92fd7d4..0e98747059687 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/clustering/update/strategy/SparkRejectUpdateStrategy.java @@ -29,7 +29,6 @@ import lombok.extern.slf4j.Slf4j; import java.util.Collections; -import java.util.List; import java.util.Set; /** @@ -39,18 +38,22 @@ @Slf4j public class SparkRejectUpdateStrategy extends BaseSparkUpdateStrategy { - public SparkRejectUpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, Set fileGroupsInPendingClustering) { - super(engineContext, table, fileGroupsInPendingClustering); + public SparkRejectUpdateStrategy(HoodieEngineContext engineContext, HoodieTable table, + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + super(engineContext, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); } @Override public Pair>, Set> handleUpdate(HoodieData> taggedRecordsRDD) { - List fileGroupIdsWithRecordUpdate = getGroupIdsWithUpdate(taggedRecordsRDD); - fileGroupIdsWithRecordUpdate.forEach(fileGroupIdWithRecordUpdate -> { - if (fileGroupsInPendingClustering.contains(fileGroupIdWithRecordUpdate)) { + Set allAffectedFileGroups = getGroupIdsWithUpdate(taggedRecordsRDD); + // also treat replaced file groups as potential conflict targets + allAffectedFileGroups.addAll(fileGroupsToBeReplaced); + allAffectedFileGroups.forEach(affectedFileGroup -> { + if (fileGroupsInPendingClustering.contains(affectedFileGroup)) { String msg = String.format("Not allowed to update the clustering file group %s. " + "For pending clustering operations, we are not going to support update for now.", - fileGroupIdWithRecordUpdate.toString()); + affectedFileGroup.toString()); log.error(msg); throw new HoodieClusteringUpdateException(msg); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java index d764dcd3cd54b..0e4fae4f70e6e 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java @@ -408,4 +408,18 @@ public , V extends Comparable> HoodiePairData r new ConditionalRangePartitioner.CompositeKeyComparator<>()) .mapToPair(e -> e._1)); } + + @Override + public Map getEngineProperties() { + Map info = new HashMap<>(); + info.put("spark.application.id", javaSparkContext.sc().applicationId()); + info.put("spark.user", javaSparkContext.sparkUser()); + info.put("spark.master", javaSparkContext.master()); + info.put("spark.application", javaSparkContext.appName()); + info.put("spark.version", javaSparkContext.version()); + info.put("spark.defaultParallelism", String.valueOf(javaSparkContext.defaultParallelism())); + info.put("spark.defaultMinPartitions", String.valueOf(javaSparkContext.defaultMinPartitions())); + info.put("spark.executor.instances", javaSparkContext.getConf().get("spark.executor.instances", "")); + return info; + } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/SparkReaderContextFactory.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/SparkReaderContextFactory.java index 7dd00a9e430c9..f9c99d1db0224 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/SparkReaderContextFactory.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/SparkReaderContextFactory.java @@ -23,6 +23,7 @@ import org.apache.hudi.SparkAdapterSupport$; import org.apache.hudi.SparkFileFormatInternalRowReaderContext; import org.apache.hudi.client.utils.SparkInternalSchemaConverter; +import org.apache.hudi.common.config.HoodieReaderConfig; import org.apache.hudi.common.engine.HoodieReaderContext; import org.apache.hudi.common.engine.ReaderContextFactory; import org.apache.hudi.common.model.HoodieFileFormat; @@ -89,6 +90,10 @@ public SparkReaderContextFactory(HoodieSparkEngineContext hoodieSparkEngineConte // Broadcast: Configuration. Configuration configs = getHadoopConfiguration(jsc.hadoopConfiguration()); schemaEvolutionConfigs.forEach(configs::set); + // Internal write-side reads (compaction, clustering, merge) must materialize INLINE blob bytes + // so they can be rewritten into the new base file. DESCRIPTOR is a query-only optimization; if + // it leaked onto this path the rewrite would persist null blob data and silently drop the bytes. + configs.set(HoodieReaderConfig.BLOB_INLINE_READ_MODE.key(), HoodieReaderConfig.BLOB_INLINE_READ_MODE_CONTENT); configs.set(SQLConf.PARQUET_OUTPUT_TIMESTAMP_TYPE().key(), sqlConf.getConfString(SQLConf.PARQUET_OUTPUT_TIMESTAMP_TYPE().key())); configs.set(SQLConf.PARQUET_WRITE_LEGACY_FORMAT().key(), sqlConf.getConfString(SQLConf.PARQUET_WRITE_LEGACY_FORMAT().key())); configurationBroadcast = jsc.broadcast(new SerializableConfiguration(configs)); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkMetadataWriterUtils.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkMetadataWriterUtils.java index 47155899e2048..2184c24ad136a 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkMetadataWriterUtils.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkMetadataWriterUtils.java @@ -335,7 +335,7 @@ private static Iterator getExpressionIndexRecordsIterator(HoodieReaderConte baseFileOption = Option.empty(); logFileStream = Stream.of(new HoodieLogFile(filePath)); } - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withDataSchema(tableSchema) diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkValidatorUtils.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkValidatorUtils.java index c7804631d3f65..233f622beff68 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkValidatorUtils.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/utils/SparkValidatorUtils.java @@ -23,6 +23,7 @@ import org.apache.hudi.client.common.HoodieSparkEngineContext; import org.apache.hudi.client.validator.SparkPreCommitValidator; import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.engine.ExecutorServiceBasedEngineContext; import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.model.BaseFile; import org.apache.hudi.common.model.HoodieWriteStat; @@ -50,7 +51,6 @@ import java.util.Arrays; import java.util.List; import java.util.Set; -import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -83,13 +83,34 @@ public static void runValidators(HoodieWriteConfig config, Dataset afterState = getRecordsFromPendingCommits(sqlContext, partitionsModified, writeMetadata, table, instantTime); Dataset beforeState = getRecordsFromCommittedFiles(sqlContext, partitionsModified, table, afterState.schema()); - Stream validators = Arrays.stream(config.getPreCommitValidators().split(",")) - .map(validatorClass -> ((SparkPreCommitValidator) ReflectionUtils.loadClass(validatorClass, - new Class[] {HoodieSparkTable.class, HoodieEngineContext.class, HoodieWriteConfig.class}, - table, context, config))); - - boolean allSuccess = validators.map(v -> runValidatorAsync(v, writeMetadata, beforeState, afterState, instantTime)).map(CompletableFuture::join) - .reduce(true, Boolean::logicalAnd); + List validators = Arrays.stream(config.getPreCommitValidators().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .flatMap(validatorClass -> { + try { + Class clazz = Class.forName(validatorClass); + if (!SparkPreCommitValidator.class.isAssignableFrom(clazz)) { + LOG.warn("Skipping validator {} — it does not implement SparkPreCommitValidator. " + + "If this is a streaming offset validator (e.g. SparkKafkaOffsetValidator), " + + "it will be invoked by SparkStreamerValidatorUtils instead.", validatorClass); + return Stream.empty(); + } + SparkPreCommitValidator validator = (SparkPreCommitValidator) ReflectionUtils.loadClass( + validatorClass, + new Class[] {HoodieSparkTable.class, HoodieEngineContext.class, HoodieWriteConfig.class}, + table, context, config); + return Stream.of(validator); + } catch (ClassNotFoundException e) { + throw new HoodieValidationException("Cannot find validator class: " + validatorClass, e); + } catch (ReflectiveOperationException e) { + throw new HoodieValidationException("Failed to instantiate validator: " + validatorClass, e); + } + }) + .collect(Collectors.toList()); + + boolean allSuccess = new ExecutorServiceBasedEngineContext(context.getStorageConf()) + .map(validators, v -> runValidator(v, writeMetadata, beforeState, afterState, instantTime), validators.size()) + .stream().reduce(true, Boolean::logicalAnd); if (allSuccess) { LOG.info("All validations succeeded"); @@ -101,20 +122,20 @@ public static void runValidators(HoodieWriteConfig config, } /** - * Run validators in a separate thread pool for parallelism. Each of validator can submit a distributed spark job if needed. + * Run a single validator synchronously in the calling thread; parallelism across validators is + * provided by the {@link ExecutorServiceBasedEngineContext#map} call site. Each validator may submit a distributed Spark + * job if needed. */ - private static CompletableFuture runValidatorAsync(SparkPreCommitValidator validator, HoodieWriteMetadata writeMetadata, - Dataset beforeState, Dataset afterState, String instantTime) { - return CompletableFuture.supplyAsync(() -> { - try { - validator.validate(instantTime, writeMetadata, beforeState, afterState); - LOG.info("validation complete for {}", validator.getClass().getName()); - return true; - } catch (HoodieValidationException e) { - LOG.error("validation failed for {}", validator.getClass().getName(), e); - return false; - } - }); + private static boolean runValidator(SparkPreCommitValidator validator, HoodieWriteMetadata> writeMetadata, + Dataset beforeState, Dataset afterState, String instantTime) { + try { + validator.validate(instantTime, writeMetadata, beforeState, afterState); + LOG.info("validation complete for {}", validator.getClass().getName()); + return true; + } catch (HoodieValidationException e) { + LOG.error("validation failed for {}", validator.getClass().getName(), e); + return false; + } } /** diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SparkPreCommitValidator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SparkPreCommitValidator.java index 7e1da34c24426..de3ffbf6ae63e 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SparkPreCommitValidator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SparkPreCommitValidator.java @@ -82,9 +82,21 @@ public void validate(String instantTime, HoodieWriteMetadata writeResult, Dat HoodieTimer timer = HoodieTimer.start(); try { validateRecordsBeforeAndAfter(before, after, getPartitionsModified(writeResult)); + } catch (HoodieValidationException e) { + throw e; + } catch (RuntimeException e) { + // Unexpected bug (NPE, ClassCastException, etc.) — re-throw as-is so it propagates + // crash-loud with the original stack trace instead of being silently swallowed as a + // generic "validation failed" message. + log.error("Validator {} threw unexpected exception for instant {}", getClass().getName(), instantTime, e); + throw e; + } catch (Exception e) { + // Checked exception — promote to RuntimeException so it propagates crash-loud. + log.error("Validator {} threw unexpected checked exception for instant {}", getClass().getName(), instantTime, e); + throw new RuntimeException(e); } finally { long duration = timer.endTimer(); - log.info(getClass() + " validator took " + duration + " ms" + ", metrics on? " + getWriteConfig().isMetricsOn()); + log.info("{} validator took {} ms, metrics on? {}", getClass(), duration, getWriteConfig().isMetricsOn()); publishRunStats(instantTime, duration); } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryEqualityPreCommitValidator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryEqualityPreCommitValidator.java index 9959baad30a2c..d5884aaabada8 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryEqualityPreCommitValidator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryEqualityPreCommitValidator.java @@ -57,16 +57,16 @@ protected void validateUsingQuery(String query, String prevTableSnapshot, String try { prevRows = executeSqlQuery( sqlContext, query, prevTableSnapshot, "previous state").cache(); - log.info("Total rows in prevRows " + prevRows.count()); + log.info("Total rows in prevRows {}", prevRows.count()); newRows = executeSqlQuery( sqlContext, query, newTableSnapshot, "new state").cache(); - log.info("Total rows in newRows " + newRows.count()); + log.info("Total rows in newRows {}", newRows.count()); printAllRowsIfDebugEnabled(prevRows); printAllRowsIfDebugEnabled(newRows); boolean areDatasetsEqual = prevRows.intersect(newRows).count() == prevRows.count(); - log.info("Completed Equality Validation, datasets equal? " + areDatasetsEqual); + log.info("Completed Equality Validation, datasets equal? {}", areDatasetsEqual); if (!areDatasetsEqual) { - log.error("query validation failed. See stdout for sample query results. Query: " + query); + log.error("query validation failed. See stdout for sample query results. Query: {}", query); System.out.println("Expected result (sample records only):"); prevRows.show(); System.out.println("Actual result (sample records only):"); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryInequalityPreCommitValidator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryInequalityPreCommitValidator.java index f0aae541d3d3e..0b22b540a79b8 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryInequalityPreCommitValidator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQueryInequalityPreCommitValidator.java @@ -54,16 +54,16 @@ protected String getQueryConfigName() { protected void validateUsingQuery(String query, String prevTableSnapshot, String newTableSnapshot, SQLContext sqlContext) { Dataset prevRows = executeSqlQuery( sqlContext, query, prevTableSnapshot, "previous state").cache(); - log.info("Total rows in prevRows " + prevRows.count()); + log.info("Total rows in prevRows {}", prevRows.count()); Dataset newRows = executeSqlQuery( sqlContext, query, newTableSnapshot, "new state").cache(); - log.info("Total rows in newRows " + newRows.count()); + log.info("Total rows in newRows {}", newRows.count()); printAllRowsIfDebugEnabled(prevRows); printAllRowsIfDebugEnabled(newRows); boolean areDatasetsEqual = prevRows.intersect(newRows).count() == prevRows.count(); - log.info("Completed Inequality Validation, datasets equal? " + areDatasetsEqual); + log.info("Completed Inequality Validation, datasets equal? {}", areDatasetsEqual); if (areDatasetsEqual) { - log.error("query validation failed. See stdout for sample query results. Query: " + query); + log.error("query validation failed. See stdout for sample query results. Query: {}", query); System.out.println("Expected query results to be different, but they are same. Result (sample records only):"); prevRows.show(); throw new HoodieValidationException("Query validation failed for '" + query diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQuerySingleResultPreCommitValidator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQuerySingleResultPreCommitValidator.java index 47f961b88b94f..ef5278f02d3d5 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQuerySingleResultPreCommitValidator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/validator/SqlQuerySingleResultPreCommitValidator.java @@ -66,11 +66,11 @@ protected void validateUsingQuery(String query, String prevTableSnapshot, String } Object result = newRows.get(0).apply(0); if (result == null || !expectedResult.equals(result.toString())) { - log.error("Mismatch query result. Expected: " + expectedResult + " got " + result + " on Query: " + query); + log.error("Mismatch query result. Expected: {} got {} on Query: {}", expectedResult, result, query); throw new HoodieValidationException("Query validation failed for '" + query + "'. Expected " + expectedResult + " row(s), Found " + result); } else { - log.info("Query validation successful. Expected: " + expectedResult + " got " + result); + log.info("Query validation successful. Expected: {} got {}", expectedResult, result); } } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/ConsistentBucketIndexBulkInsertPartitionerWithRows.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/ConsistentBucketIndexBulkInsertPartitionerWithRows.java index 24ef7fd18716a..b681c03f4f033 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/ConsistentBucketIndexBulkInsertPartitionerWithRows.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/bulkinsert/ConsistentBucketIndexBulkInsertPartitionerWithRows.java @@ -29,6 +29,7 @@ import org.apache.hudi.index.bucket.ConsistentBucketIndexUtils; import org.apache.hudi.index.bucket.HoodieSparkConsistentBucketIndex; import org.apache.hudi.keygen.BuiltinKeyGenerator; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.keygen.factory.HoodieSparkKeyGeneratorFactory; import org.apache.hudi.table.BucketSortBulkInsertPartitioner; import org.apache.hudi.table.HoodieTable; @@ -54,7 +55,9 @@ */ public class ConsistentBucketIndexBulkInsertPartitionerWithRows extends BucketSortBulkInsertPartitioner> { - private final String indexKeyFields; + // parsed once; the per-record getBucketId path uses the List overload of getBucket so the + // comma-separated config string is not re-split per record + private final List indexKeyFieldList; private final List fileIdPfxList = new ArrayList<>(); @@ -83,7 +86,7 @@ public ConsistentBucketIndexBulkInsertPartitionerWithRows(HoodieTable table, Map strategyParams, boolean populateMetaFields, Map> hashingChildrenNodes) { super(table, strategyParams.getOrDefault(PLAN_STRATEGY_SORT_COLUMNS.key(), "")); - this.indexKeyFields = table.getConfig().getBucketIndexHashField(); + this.indexKeyFieldList = KeyGenUtils.getIndexKeyFields(table.getConfig().getBucketIndexHashField()); this.populateMetaFields = populateMetaFields; if (!populateMetaFields) { this.keyGeneratorOpt = HoodieSparkKeyGeneratorFactory.getKeyGenerator(table.getConfig().getProps()); @@ -179,7 +182,7 @@ private Map initializeBucketIdentifier(JavaR private int getBucketId(Row row) { String recordKey = extractor.getRecordKey(row); String partitionPath = extractor.getPartitionPath(row); - ConsistentHashingNode node = partitionToIdentifier.get(partitionPath).getBucket(recordKey, indexKeyFields); + ConsistentHashingNode node = partitionToIdentifier.get(partitionPath).getBucket(recordKey, indexKeyFieldList); return partitionToFileIdPfxIdxMap.get(partitionPath).get(node.getFileIdPrefix()); } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobDescriptorTransform.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobDescriptorTransform.java index b893811cf28e2..1032c5d368064 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobDescriptorTransform.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobDescriptorTransform.java @@ -18,7 +18,6 @@ package org.apache.hudi.io.storage; -import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.exception.HoodieException; import org.apache.spark.sql.catalyst.InternalRow; @@ -35,6 +34,14 @@ import java.util.HashSet; import java.util.Set; +import static org.apache.hudi.io.storage.BlobStructLayout.DATA_IDX; +import static org.apache.hudi.io.storage.BlobStructLayout.FIELD_COUNT; +import static org.apache.hudi.io.storage.BlobStructLayout.INLINE_UTF8; +import static org.apache.hudi.io.storage.BlobStructLayout.OUT_OF_LINE_UTF8; +import static org.apache.hudi.io.storage.BlobStructLayout.REF_FIELD_COUNT; +import static org.apache.hudi.io.storage.BlobStructLayout.REF_IDX; +import static org.apache.hudi.io.storage.BlobStructLayout.TYPE_IDX; + /** * Per-row transform that rewrites BLOB columns from Lance's DESCRIPTOR shape into the Hudi BLOB * shape. Composed into {@link LanceRecordIterator} for DESCRIPTOR-mode reads. @@ -52,16 +59,6 @@ */ public final class BlobDescriptorTransform { - private static final UTF8String OUT_OF_LINE_UTF8 = - UTF8String.fromString(HoodieSchema.Blob.OUT_OF_LINE); - private static final UTF8String INLINE_UTF8 = - UTF8String.fromString(HoodieSchema.Blob.INLINE); - - // Child field indices within the Hudi BLOB struct: {type(0), data(1), reference(2)}. - private static final int TYPE_IDX = 0; - private static final int DATA_IDX = 1; - private static final int REF_IDX = 2; - private final Set blobFieldNames; private final UTF8String lanceFilePathUtf8; private final String lanceFilePath; @@ -105,7 +102,7 @@ UnsafeRow transformRow(InternalRow row, int rowId, ColumnVector[] columnVectors, for (int i = 0; i < outputFields.length; i++) { if (blobColumnIndices.contains(i)) { rowBuffer[i] = row.isNullAt(i) ? null - : buildBlobOutputRow(row.getStruct(i, 3), columnVectors[i], rowId); + : buildBlobOutputRow(row.getStruct(i, FIELD_COUNT), columnVectors[i], rowId); } else { rowBuffer[i] = row.isNullAt(i) ? null : row.get(i, outputFields[i].dataType()); } @@ -131,7 +128,8 @@ private InternalRow buildBlobOutputRow(InternalRow blobStruct, ColumnVector blob UTF8String type = blobStruct.getUTF8String(TYPE_IDX); if (type.equals(OUT_OF_LINE_UTF8)) { - InternalRow refRow = blobStruct.isNullAt(REF_IDX) ? null : blobStruct.getStruct(REF_IDX, 4); + InternalRow refRow = blobStruct.isNullAt(REF_IDX) ? null + : blobStruct.getStruct(REF_IDX, REF_FIELD_COUNT); return new GenericInternalRow(new Object[] { OUT_OF_LINE_UTF8, null, refRow }); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobStructLayout.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobStructLayout.java new file mode 100644 index 0000000000000..e49e68e4b9c90 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/BlobStructLayout.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.hudi.io.storage; + +import org.apache.hudi.common.schema.HoodieSchema; + +import org.apache.spark.unsafe.types.UTF8String; + +/** + * Layout of the Hudi BLOB struct {@code {type, data, reference}} as decoded by the Spark-side + * Lance code. Shared by {@link BlobDescriptorTransform} (read) and {@link HoodieSparkLanceWriter} + * (write) so the two cannot drift apart. Ordinals mirror the field order defined by + * {@link HoodieSchema.Blob}. + */ +final class BlobStructLayout { + + // Child field indices within the Hudi BLOB struct: {type(0), data(1), reference(2)}. + static final int TYPE_IDX = 0; + static final int DATA_IDX = 1; + static final int REF_IDX = 2; + static final int FIELD_COUNT = HoodieSchema.Blob.getFieldCount(); + static final int REF_FIELD_COUNT = HoodieSchema.Blob.getReferenceFieldCount(); + + // Precomputed type tokens, compared against each row's type field without per-row allocation. + static final UTF8String INLINE_UTF8 = UTF8String.fromString(HoodieSchema.Blob.INLINE); + static final UTF8String OUT_OF_LINE_UTF8 = UTF8String.fromString(HoodieSchema.Blob.OUT_OF_LINE); + + private BlobStructLayout() { + } +} diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java index bd651a9e9bef0..040c63bd01584 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java @@ -205,11 +205,21 @@ private ClosableIterator getUnsafeRowIterator(HoodieSchema requestedS columnNames.add(field.name()); } - // Pinned to CONTENT: compaction/merge/log-replay need actual bytes to rewrite. - // The user-facing `hoodie.read.blob.inline.mode` is honored by SparkLanceReaderBase. + // Pinned to CONTENT: callers (LanceUtils stats/key reads, bloom-index lookups via + // HoodieReadHandle, old-base-file reads in the legacy HoodieWriteMergeHandle merge path) + // need actual bytes. Default-path compaction/merge reads go through SparkLanceReaderBase + // instead, which honors the user-facing `hoodie.read.blob.inline.mode`. FileReadOptions readOpts = FileReadOptions.builder().blobReadMode(BlobReadMode.CONTENT).build(); - ArrowReader arrowReader = lanceReader.readAll(columnNames, null, DEFAULT_BATCH_SIZE, readOpts); + // BLOB reads must be chunked to dodge a lance-core FFI abort (see LanceRecordIterator). + // containsBlobType() recurses through nested records/arrays/maps/unions, so a BLOB at any + // depth routes through the chunked path; a top-level-only check would silently skip + // chunking (and re-introduce the abort) if the writer ever gains nested-BLOB support. + if (requestedSchema.containsBlobType()) { + return LanceRecordIterator.chunkedBlobReader(allocator, lanceReader, columnNames, readOpts, + lanceReader.numRows(), requestedSparkSchema, path.toString(), null); + } + ArrowReader arrowReader = lanceReader.readAll(columnNames, null, DEFAULT_BATCH_SIZE, readOpts); return new LanceRecordIterator(allocator, lanceReader, arrowReader, requestedSparkSchema, path.toString()); } catch (Exception e) { allocator.close(); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceWriter.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceWriter.java index 3bdf12d9059b9..eb36a1f9a9559 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceWriter.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceWriter.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.common.util.Option; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieNotSupportedException; import org.apache.hudi.io.lance.HoodieBaseLanceWriter; import org.apache.hudi.io.storage.row.HoodieBloomFilterRowWriteSupport; @@ -67,6 +68,11 @@ import static org.apache.hudi.common.model.HoodieRecord.HoodieMetadataField.PARTITION_PATH_METADATA_FIELD; import static org.apache.hudi.common.model.HoodieRecord.HoodieMetadataField.RECORD_KEY_METADATA_FIELD; import static org.apache.hudi.common.util.ValidationUtils.checkArgument; +import static org.apache.hudi.io.storage.BlobStructLayout.DATA_IDX; +import static org.apache.hudi.io.storage.BlobStructLayout.FIELD_COUNT; +import static org.apache.hudi.io.storage.BlobStructLayout.INLINE_UTF8; +import static org.apache.hudi.io.storage.BlobStructLayout.REF_IDX; +import static org.apache.hudi.io.storage.BlobStructLayout.TYPE_IDX; /** * Spark Lance file writer implementing {@link HoodieSparkFileWriter} and {@link HoodieInternalRowFileWriter}. @@ -96,6 +102,10 @@ public class HoodieSparkLanceWriter extends HoodieBaseLanceWriter seqIdGenerator; private final long maxFileSize; + // Top-level BLOB column ordinals and their names (parallel arrays), used by the per-row + // descriptor-leak guard. Empty when the schema has no BLOB columns, making the guard a no-op. + private final int[] blobFieldOrdinals; + private final String[] blobFieldNames; private long recordCountForNextSizeCheck = MIN_RECORDS_FOR_SIZE_CHECK; /** @@ -159,6 +169,20 @@ private HoodieSparkLanceWriter(StoragePath file, super(file, DEFAULT_BATCH_SIZE, allocatorSize, flushByteWatermark, bloomFilterOpt.map(HoodieBloomFilterRowWriteSupport::new)); this.sparkSchema = enrichSparkSchemaForLance(sparkSchema); + StructField[] topFields = this.sparkSchema.fields(); + List blobOrds = new ArrayList<>(); + for (int i = 0; i < topFields.length; i++) { + if (isBlobField(topFields[i])) { + blobOrds.add(i); + } + } + this.blobFieldOrdinals = new int[blobOrds.size()]; + this.blobFieldNames = new String[blobOrds.size()]; + for (int i = 0; i < blobOrds.size(); i++) { + int ord = blobOrds.get(i); + this.blobFieldOrdinals[i] = ord; + this.blobFieldNames[i] = topFields[ord].name(); + } Schema baseArrow = LanceArrowUtils.toArrowSchema(this.sparkSchema, DEFAULT_TIMEZONE, true); // Force LargeBinary + `lance-encoding:blob=true` on each BLOB's nested `data` Arrow leaf. // Can't be expressed Spark-side: toArrowSchema drops nested-field metadata, and tagging @@ -367,7 +391,7 @@ public void writeRow(InternalRow row) throws IOException { @Override protected ArrowWriter createArrowWriter(VectorSchemaRoot root) { - return SparkArrowWriter.of(LanceArrowWriter.create(root, sparkSchema)); + return SparkArrowWriter.of(LanceArrowWriter.create(root, sparkSchema), blobFieldOrdinals, blobFieldNames); } /** @@ -446,12 +470,44 @@ protected void updateRecordMetadata(InternalRow row, @AllArgsConstructor(staticName = "of") private static class SparkArrowWriter implements ArrowWriter { private final LanceArrowWriter lanceArrowWriter; + // Parallel arrays of top-level BLOB column ordinals and names for the per-row guard. + private final int[] blobFieldOrdinals; + private final String[] blobFieldNames; @Override public void write(InternalRow row) { + validateBlobRow(row); lanceArrowWriter.write(row); } + /** + * Reject descriptor-shaped BLOB rows before they are persisted. A row is descriptor-shaped when + * a top-level BLOB struct is non-null, its type is INLINE, its data is null, and its reference + * is non-null; this only arises when a DESCRIPTOR-mode read leaks onto a write path, and writing + * it would silently drop the inline bytes. The legitimate empty-inline shape {INLINE, null, null} + * stays writable. + */ + private void validateBlobRow(InternalRow row) { + for (int i = 0; i < blobFieldOrdinals.length; i++) { + int ordinal = blobFieldOrdinals[i]; + if (row.isNullAt(ordinal)) { + continue; + } + InternalRow blob = row.getStruct(ordinal, FIELD_COUNT); + if (blob.isNullAt(TYPE_IDX) || !INLINE_UTF8.equals(blob.getUTF8String(TYPE_IDX))) { + continue; + } + if (blob.isNullAt(DATA_IDX) && !blob.isNullAt(REF_IDX)) { + throw new HoodieException( + "BLOB column '" + blobFieldNames[i] + "' has an INLINE row with null data but a " + + "populated reference: a DESCRIPTOR-mode read leaked into the write path, and " + + "persisting it would silently drop the blob bytes. Internal rewrites " + + "(compaction, clustering, merge) and any query whose rows are written back " + + "must read with hoodie.read.blob.inline.mode=CONTENT."); + } + } + } + @Override public void reset() { lanceArrowWriter.reset(); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java index 91a0421d01186..2cc6b5c314c72 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java @@ -32,39 +32,58 @@ import org.apache.spark.sql.types.StructType; import org.apache.spark.sql.vectorized.ColumnVector; import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.lance.file.FileReadOptions; import org.lance.file.LanceFileReader; import org.lance.spark.vectorized.LanceArrowColumnVector; +import org.lance.util.Range; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; /** - * Iterator for reading Lance files and converting Arrow batches to Spark {@link UnsafeRow}s. - * Used by both Hudi's internal Lance reader and Spark datasource integration. + * Iterator over a Lance file that converts Arrow batches to Spark {@link UnsafeRow}s, owning the + * {@link BufferAllocator}, {@link LanceFileReader}, {@link ArrowReader}(s) and current + * {@link ColumnarBatch}. Used by both Hudi's internal Lance reader and the Spark datasource. An + * optional {@link BlobDescriptorTransform} rewrites BLOB columns for DESCRIPTOR-mode reads. * - *

The iterator manages the lifecycle of: - *

    - *
  • BufferAllocator - Arrow memory management
  • - *
  • LanceFileReader - Lance file handle
  • - *
  • ArrowReader - Arrow batch reader
  • - *
  • ColumnarBatch - Current batch being iterated
  • - *
- * - *

An optional {@link BlobDescriptorTransform} can be composed in to rewrite BLOB columns - * in DESCRIPTOR mode. + *

BLOB chunked reading: lance-core 4.0.0 aborts the JVM in its Arrow C-stream export whenever a + * single {@code readAll} stream crosses Lance's internal BLOB page boundary (512 rows), and the + * requested {@code batchSize} does not change that. So BLOB reads issue one {@code readAll} per + * row-range chunk of {@link #BLOB_READ_CHUNK_ROWS} rows; non-BLOB reads use one streamed reader. */ public final class LanceRecordIterator implements ClosableIterator { + + /** + * Rows per {@code readAll} for BLOB reads. Must not exceed Lance's internal BLOB page size, + * which is 512 rows in lance-core 4.0.0 but is NOT exposed through any lance API; revalidate + * this constant against the batch-scale BLOB tests whenever {@code lance.version} is bumped. + */ + public static final int BLOB_READ_CHUNK_ROWS = 512; + + /** + * The sequence of {@link ArrowReader}s to drain, one after another. Single-reader + * mode yields one reader; BLOB chunked mode yields one fresh reader per row-range chunk. Each + * returned reader is owned (and closed) by {@link LanceRecordIterator}. + */ + private interface ArrowReaderSequence { + /** @return the next reader to drain, or {@code null} when the sequence is exhausted. */ + ArrowReader next() throws IOException; + } + private final BufferAllocator allocator; private final LanceFileReader lanceReader; - private final ArrowReader arrowReader; + private final ArrowReaderSequence readerSequence; private final StructType sparkSchema; private final UnsafeProjection projection; private final String path; private final BlobDescriptorTransform blobTransform; + /** The reader currently being drained; replaced as chunks are exhausted. */ + private ArrowReader currentReader; private ColumnarBatch currentBatch; private Iterator rowIterator; private ColumnVector[] columnVectors; @@ -84,7 +103,8 @@ public LanceRecordIterator(BufferAllocator allocator, } /** - * Creates a new Lance record iterator. + * Creates a new Lance record iterator that drains a single pre-built {@link ArrowReader}. + * Suitable for non-BLOB reads, where Lance's multi-batch FFI export is well-behaved. * * @param allocator Arrow buffer allocator for memory management * @param lanceReader Lance file reader @@ -100,15 +120,78 @@ public LanceRecordIterator(BufferAllocator allocator, StructType schema, String path, BlobDescriptorTransform blobTransform) { + this(allocator, lanceReader, singleReaderSequence(arrowReader), schema, path, blobTransform); + } + + private LanceRecordIterator(BufferAllocator allocator, + LanceFileReader lanceReader, + ArrowReaderSequence readerSequence, + StructType schema, + String path, + BlobDescriptorTransform blobTransform) { this.allocator = allocator; this.lanceReader = lanceReader; - this.arrowReader = arrowReader; + this.readerSequence = readerSequence; this.sparkSchema = schema; this.projection = UnsafeProjection.create(schema); this.path = path; this.blobTransform = blobTransform; } + /** + * Creates a Lance record iterator that reads a BLOB-containing file in fixed-size row-range + * chunks, issuing a fresh {@code readAll} per chunk to dodge the lance-core multi-page BLOB + * FFI-export panic (see class javadoc). + * + * @param allocator Arrow buffer allocator for memory management + * @param lanceReader open Lance file reader (the iterator takes ownership and closes it) + * @param columnNames columns to project, or {@code null} for all columns + * @param readOpts Lance read options (e.g. blob read mode) + * @param totalRows total rows in the file ({@code lanceReader.numRows()}) + * @param schema Spark schema for the records + * @param path File path (for error messages) + * @param blobTransform optional blob descriptor transform for DESCRIPTOR-mode reads + */ + public static LanceRecordIterator chunkedBlobReader(BufferAllocator allocator, + LanceFileReader lanceReader, + List columnNames, + FileReadOptions readOpts, + long totalRows, + StructType schema, + String path, + BlobDescriptorTransform blobTransform) { + ArrowReaderSequence sequence = new ArrowReaderSequence() { + private long nextStart = 0; + + @Override + public ArrowReader next() throws IOException { + if (nextStart >= totalRows) { + return null; + } + int start = Math.toIntExact(nextStart); + int end = Math.toIntExact(Math.min(nextStart + BLOB_READ_CHUNK_ROWS, totalRows)); + nextStart = end; + // A single range per readAll keeps the FFI stream within one BLOB page (<= 512 rows). + List ranges = Collections.singletonList(new Range(start, end)); + return lanceReader.readAll(columnNames, ranges, BLOB_READ_CHUNK_ROWS, readOpts); + } + }; + return new LanceRecordIterator(allocator, lanceReader, sequence, schema, path, blobTransform); + } + + private static ArrowReaderSequence singleReaderSequence(ArrowReader arrowReader) { + return new ArrowReaderSequence() { + private ArrowReader remaining = arrowReader; + + @Override + public ArrowReader next() { + ArrowReader r = remaining; + remaining = null; + return r; + } + }; + } + @Override public boolean hasNext() { if (rowIterator != null && rowIterator.hasNext()) { @@ -120,33 +203,50 @@ public boolean hasNext() { currentBatch = null; } - // Try to load next batch. Loop so zero-row batches (legitimately returned e.g. after - // filter pushdown) don't silently terminate iteration and drop subsequent non-empty batches. try { - while (arrowReader.loadNextBatch()) { - VectorSchemaRoot root = arrowReader.getVectorSchemaRoot(); - - // Build ColumnVector[] in Spark-schema order by looking each field up by name; - // lance-spark 0.4.0's VectorSchemaRoot may return the file's on-disk order, which - // would misalign the UnsafeProjection. Cached on the first batch and reused thereafter. - if (columnVectors == null) { - buildColumnVectors(root); + while (true) { + if (currentReader == null) { + currentReader = readerSequence.next(); + if (currentReader == null) { + return false; + } + // Each reader (range chunk) returns a distinct VectorSchemaRoot, so the cached + // column vectors must be rebuilt against the new reader's vectors. + columnVectors = null; } - currentBatch = new ColumnarBatch(columnVectors, root.getRowCount()); - rowIterator = currentBatch.rowIterator(); - rowIdInBatch = 0; - if (rowIterator.hasNext()) { - return true; + // Try to load next batch from the current reader. Loop so zero-row batches + // (legitimately returned e.g. after filter pushdown) don't silently terminate. + while (currentReader.loadNextBatch()) { + VectorSchemaRoot root = currentReader.getVectorSchemaRoot(); + + // Build ColumnVector[] in Spark-schema order by looking each field up by name; + // lance-spark 0.4.0's VectorSchemaRoot may return the file's on-disk order, which + // would misalign the UnsafeProjection. Cached per reader and reused thereafter. + if (columnVectors == null) { + buildColumnVectors(root); + } + + currentBatch = new ColumnarBatch(columnVectors, root.getRowCount()); + rowIterator = currentBatch.rowIterator(); + rowIdInBatch = 0; + if (rowIterator.hasNext()) { + return true; + } + currentBatch.close(); + currentBatch = null; } - currentBatch.close(); - currentBatch = null; + + // Current reader exhausted; close it and advance to the next chunk (if any). + currentReader.close(); + currentReader = null; + columnVectors = null; } } catch (IOException e) { throw new HoodieException("Failed to read next batch from Lance file: " + path, e); + } catch (Exception e) { + throw new HoodieException("Failed to advance Lance reader for file: " + path, e); } - - return false; } @Override @@ -197,6 +297,8 @@ public void close() { closed = true; ColumnarBatch batch = currentBatch; currentBatch = null; - LanceResourceCloser.closeAll(batch, arrowReader, lanceReader, allocator); + ArrowReader reader = currentReader; + currentReader = null; + LanceResourceCloser.closeAll(batch, reader, lanceReader, allocator); } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java index 0222506f56faf..c6d20eefb5823 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java @@ -194,7 +194,7 @@ private void writeRow(InternalRow row) { ? HoodieRecordDelegate.create(recordKey.toString(), partitionPath.toString(), null, newRecordLocation) : null; writeStatus.markSuccess(recordDelegate, Option.empty()); } catch (Exception t) { - log.error("Error writing record " + row, t); + log.error("Error writing record {}", row, t); if (!writeConfig.getIgnoreWriteFailed()) { throw new HoodieException(t.getMessage(), t); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java index 262d4acc8ac4d..b8a2ebb360c01 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java @@ -428,6 +428,34 @@ private void writeFields(InternalRow row, StructType schema, ValueWriter[] field } } + /** + * Fixed-length byte width for a decimal column: honor the declared Avro {@code fixed} size when + * present (it may be wider than the precision-minimal width), otherwise the precision-minimal width. + */ + static int resolveDecimalByteLength(HoodieSchema resolvedSchema, int precision) { + if (resolvedSchema instanceof HoodieSchema.Decimal) { + HoodieSchema.Decimal decimalSchema = (HoodieSchema.Decimal) resolvedSchema; + if (decimalSchema.isFixed()) { + int fixedSize = decimalSchema.getFixedSize(); + ValidationUtils.checkArgument(fixedSize >= Decimal.minBytesForPrecision()[precision], + () -> String.format("Avro fixed size %s is too small for decimal precision %s (need >= %s bytes)", + fixedSize, precision, Decimal.minBytesForPrecision()[precision])); + return fixedSize; + } + } + return Decimal.minBytesForPrecision()[precision]; + } + + static byte[] padDecimalToFixedLength(byte[] unscaledBytes, int numBytes, byte[] paddingBuffer) { + if (unscaledBytes.length == numBytes) { + return unscaledBytes; + } + byte signByte = (unscaledBytes[0] < 0) ? (byte) -1 : (byte) 0; + Arrays.fill(paddingBuffer, 0, numBytes - unscaledBytes.length, signByte); + System.arraycopy(unscaledBytes, 0, paddingBuffer, numBytes - unscaledBytes.length, unscaledBytes.length); + return paddingBuffer; + } + private ValueWriter makeWriter(HoodieSchema schema, DataType dataType) { HoodieSchema resolvedSchema = schema == null ? null : schema.getNonNullType(); @@ -496,28 +524,21 @@ private ValueWriter makeWriter(HoodieSchema schema, DataType dataType) { consumeGroup(() -> variantWriter.accept(row, ordinal)); }; } else if (dataType instanceof DecimalType) { + int precision = ((DecimalType) dataType).precision(); + ValidationUtils.checkArgument(precision <= DecimalType.MAX_PRECISION(), + () -> String.format("Decimal precision %s exceeds max precision %s", precision, DecimalType.MAX_PRECISION())); + int scale = ((DecimalType) dataType).scale(); + // Honor the declared Avro `fixed` size so the row/bulk-insert/clustering/compaction write + // path preserves the schema width instead of narrowing to the precision-minimal one. + int numBytes = resolveDecimalByteLength(resolvedSchema, precision); + // Padding buffer resolved once per column, not per record: reuse the shared decimalBuffer when + // it is wide enough, otherwise allocate one dedicated buffer here (an over-allocated Avro fixed + // size can exceed the shared buffer's capacity). + byte[] paddingBuffer = numBytes <= decimalBuffer.length ? decimalBuffer : new byte[numBytes]; return (row, ordinal) -> { - int precision = ((DecimalType) dataType).precision(); - ValidationUtils.checkArgument(precision <= DecimalType.MAX_PRECISION(), - () -> String.format("Decimal precision %s exceeds max precision %s", precision, DecimalType.MAX_PRECISION())); - int scale = ((DecimalType) dataType).scale(); byte[] bytes = row.getDecimal(ordinal, precision, scale).toJavaBigDecimal().unscaledValue().toByteArray(); - int numBytes = Decimal.minBytesForPrecision()[precision]; - byte[] fixedLengthBytes; - if (bytes.length == numBytes) { - // If the length of the underlying byte array of the unscaled `BigInteger` happens to be - // `numBytes`, just reuse it, so that we don't bother copying it to `decimalBuffer`. - fixedLengthBytes = bytes; - } else { - // Otherwise, the length must be less than `numBytes`. In this case we copy contents of - // the underlying bytes with padding sign bytes to `decimalBuffer` to form the result - // fixed-length byte array. - byte signByte = (bytes[0] < 0) ? (byte) -1 : (byte) 0; - Arrays.fill(decimalBuffer, 0, numBytes - bytes.length, signByte); - System.arraycopy(bytes, 0, decimalBuffer, numBytes - bytes.length, bytes.length); - fixedLengthBytes = decimalBuffer; - } - recordConsumer.addBinary(Binary.fromReusedByteArray(fixedLengthBytes, 0, numBytes)); + recordConsumer.addBinary(Binary.fromReusedByteArray( + padDecimalToFixedLength(bytes, numBytes, paddingBuffer), 0, numBytes)); }; } else if (dataType instanceof ArrayType && resolvedSchema != null @@ -776,7 +797,7 @@ private Type convertField(HoodieSchema fieldSchema, StructField structField, Typ return Types .primitive(FIXED_LEN_BYTE_ARRAY, repetition) .as(LogicalTypeAnnotation.decimalType(scale, precision)) - .length(Decimal.minBytesForPrecision()[precision]) + .length(resolveDecimalByteLength(resolvedSchema, precision)) .named(structField.name()); } else if (dataType instanceof ArrayType && resolvedSchema != null diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/CustomKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/CustomKeyGenerator.java index 954af2000c1b1..45f77ff9ce289 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/CustomKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/CustomKeyGenerator.java @@ -63,12 +63,7 @@ public CustomKeyGenerator(TypedProperties props) { // NOTE: We have to strip partition-path configuration, since it could only be interpreted by // this key-gen super(stripPartitionPathConfig(props)); - this.recordKeyFields = Option.ofNullable(props.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), null)) - .map(recordKeyConfigValue -> - Arrays.stream(recordKeyConfigValue.split(",")) - .map(String::trim) - .collect(Collectors.toList()) - ).orElse(Collections.emptyList()); + this.recordKeyFields = KeyGenUtils.getRecordKeyFields(props); String partitionPathFields = props.getString(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key()); this.partitionPathFields = partitionPathFields == null ? Collections.emptyList() @@ -167,4 +162,3 @@ private static TypedProperties stripPartitionPathConfig(TypedProperties props) { return filtered; } } - diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java index dfb295d5d16a3..7ea05926768d0 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java @@ -138,7 +138,7 @@ protected void initRegistry() { } else { registry = Registry.getRegistry("HoodieMetadata"); } - this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage())); + this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage(), dataWriteConfig.getMetadataConfig().isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java index 617b4b4e820f9..facd029657496 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java @@ -106,7 +106,7 @@ protected void initRegistry() { } else { registry = Registry.getRegistry("HoodieMetadata"); } - this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage())); + this.metrics = Option.of(new HoodieMetadataMetrics(metadataWriteConfig.getMetricsConfig(), dataMetaClient.getStorage(), dataWriteConfig.getMetadataConfig().isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java index 3fd5f346ce110..38ff2f4a77c14 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java @@ -68,7 +68,7 @@ public int numPartitions() { @Override public JavaRDD repartitionRecords(JavaRDD records, int outputSparkPartitions) { Comparator> keyComparator = - (Comparator> & Serializable)(t1, t2) -> t1._2.compareTo(t2._2); + (Comparator> & Serializable)(t1, t2) -> StringUtils.compareUtf8Bytes(t1._2, t2._2); // Partition the records by their file group JavaRDD partitionedRDD = records diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/BaseBootstrapMetadataHandler.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/BaseBootstrapMetadataHandler.java index c0babd7248739..6e24f34fa1c81 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/BaseBootstrapMetadataHandler.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/BaseBootstrapMetadataHandler.java @@ -65,7 +65,7 @@ public BootstrapWriteStatus runMetadataBootstrap(String srcPartitionPath, String .collect(Collectors.toList()); HoodieSchema recordKeySchema = HoodieSchemaUtils.generateProjectionSchema(schema, recordKeyColumns); - LOG.info("Schema to be used for reading record keys: " + recordKeySchema); + LOG.info("Schema to be used for reading record keys: {}", recordKeySchema); executeBootstrap(bootstrapHandle, sourceFilePath, keyGenerator, partitionPath, recordKeySchema); } catch (Exception e) { diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java index f611e71d43f39..eb61b2699b371 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java @@ -204,14 +204,12 @@ protected void commit(HoodieWriteMetadata> result) { HoodieTableMetaClient metaClient = table.getMetaClient(); try (BootstrapIndex.IndexWriter indexWriter = BootstrapIndex.getBootstrapIndex(metaClient) .createWriter(metaClient.getTableConfig().getBootstrapBasePath().get())) { - log.info("Starting to write bootstrap index for source " + config.getBootstrapSourceBasePath() + " in table " - + config.getBasePath()); + log.info("Starting to write bootstrap index for source {} in table {}", config.getBootstrapSourceBasePath(), config.getBasePath()); indexWriter.begin(); bootstrapSourceAndStats.forEach((key, value) -> indexWriter.appendNextPartition(key, value.stream().map(Pair::getKey).collect(Collectors.toList()))); indexWriter.finish(); - log.info("Finished writing bootstrap index for source " + config.getBootstrapSourceBasePath() + " in table " - + config.getBasePath()); + log.info("Finished writing bootstrap index for source {} in table {}", config.getBootstrapSourceBasePath(), config.getBasePath()); } commit(result, bootstrapSourceAndStats.values().stream() .flatMap(f -> f.stream().map(Pair::getValue)).collect(Collectors.toList())); @@ -278,7 +276,7 @@ private Map>>> listAndPr log.info("Fetching Bootstrap Schema !!"); HoodieBootstrapSchemaProvider sourceSchemaProvider = new HoodieSparkBootstrapSchemaProvider(config); bootstrapSchema = sourceSchemaProvider.getBootstrapSchema(context, folders).toString(); - log.info("Bootstrap Schema :" + bootstrapSchema); + log.info("Bootstrap Schema :{}", bootstrapSchema); BootstrapModeSelector selector = (BootstrapModeSelector) ReflectionUtils.loadClass(config.getBootstrapModeSelectorClass(), config); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java index e79ee136e98fd..1659c43a2bc19 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java @@ -40,10 +40,12 @@ import org.apache.hudi.common.util.HoodieTimer; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.data.HoodieJavaPairRDD; import org.apache.hudi.data.HoodieJavaRDD; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieUpsertException; import org.apache.hudi.execution.SparkLazyInsertIterable; import org.apache.hudi.index.HoodieIndex; @@ -121,9 +123,11 @@ protected HoodieData> clusteringHandleUpdate(HoodieData>> updateStrategy = (UpdateStrategy>>) ReflectionUtils - .loadClass(config.getClusteringUpdatesStrategyClass(), new Class[] {HoodieEngineContext.class, HoodieTable.class, Set.class}, - this.context, table, fileGroupsInPendingClustering); + // Get file groups that will be replaced by this operation (for INSERT_OVERWRITE, etc.) + Set fileGroupsToBeReplaced = getFileGroupsBeingReplaced(inputRecords); + + UpdateStrategy>> updateStrategy = + loadClusteringUpdateStrategy(fileGroupsInPendingClustering, fileGroupsToBeReplaced); // For SparkAllowUpdateStrategy with rollback pending clustering as false, need not handle // the file group intersection between current ingestion and pending clustering file groups. // This will be handled at the conflict resolution strategy. @@ -163,6 +167,50 @@ protected HoodieData> clusteringHandleUpdate(HoodieData getFileGroupsBeingReplaced(HoodieData> inputRecords) { + // Default implementation returns empty set. Subclasses should override as needed. + return Collections.emptySet(); + } + + /** + * Loads {@code hoodie.clustering.updates.strategy} via reflection, preferring the new 4-arg + * constructor (with {@code fileGroupsToBeReplaced}) and falling back to the legacy 3-arg + * constructor for custom strategies that pre-date this PR. + */ + @SuppressWarnings("unchecked") + private UpdateStrategy>> loadClusteringUpdateStrategy( + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + String strategyClass = config.getClusteringUpdatesStrategyClass(); + try { + return (UpdateStrategy>>) ReflectionUtils.loadClass( + strategyClass, + new Class[] {HoodieEngineContext.class, HoodieTable.class, Set.class, Set.class}, + this.context, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); + } catch (HoodieException ex) { + if (!(ex.getCause() instanceof NoSuchMethodException)) { + throw ex; + } + // Legacy custom strategies only have the 3-arg constructor. INSERT_OVERWRITE overlap with + // pending clustering will not be detected for these classes (they never see + // fileGroupsToBeReplaced); recommend bumping to the 4-arg constructor. + log.warn("Clustering update strategy {} is missing the 4-arg constructor with " + + "fileGroupsToBeReplaced; falling back to the 3-arg constructor. INSERT_OVERWRITE " + + "overlap with pending clustering will not be detected for this strategy.", strategyClass); + return (UpdateStrategy>>) ReflectionUtils.loadClass( + strategyClass, + new Class[] {HoodieEngineContext.class, HoodieTable.class, Set.class}, + this.context, table, fileGroupsInPendingClustering); + } + } + @Override public HoodieWriteMetadata> execute(HoodieData> inputRecords) { return this.execute(inputRecords, Option.empty()); @@ -279,10 +327,12 @@ protected HoodieData mapPartitionsAsRDD(HoodieData> if (table.requireSortedRecords()) { // Partition and sort within each partition as a single step. This is faster than partitioning first and then // applying a sort. + // requireSortedRecords() is true only for HFile base files, which order keys by UTF-8 bytes, + // not String (UTF-16) order, so sort with the matching comparator. Comparator>> comparator = (Comparator>> & Serializable) (t1, t2) -> { HoodieKey key1 = t1._1; HoodieKey key2 = t2._1; - return key1.getRecordKey().compareTo(key2.getRecordKey()); + return StringUtils.compareUtf8Bytes(key1.getRecordKey(), key2.getRecordKey()); }; partitionedRDD = mappedRDD.repartitionAndSortWithinPartitions(partitioner, comparator); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BucketBulkInsertDataInternalWriterHelper.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BucketBulkInsertDataInternalWriterHelper.java index 15d973743fd16..6a3c5dd4912c4 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BucketBulkInsertDataInternalWriterHelper.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BucketBulkInsertDataInternalWriterHelper.java @@ -25,6 +25,7 @@ import org.apache.hudi.index.bucket.BucketIdentifier; import org.apache.hudi.index.bucket.partition.NumBucketsFunction; import org.apache.hudi.io.storage.row.HoodieRowCreateHandle; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; import org.apache.hudi.table.HoodieTable; @@ -35,6 +36,7 @@ import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -47,7 +49,9 @@ public class BucketBulkInsertDataInternalWriterHelper extends BulkInsertDataInte private Pair lastFileId; // for efficient code path // p -> (fileId -> handle) private final Map, HoodieRowCreateHandle> handles; - protected final String indexKeyFields; + // parsed once; the per-row write path uses the List overloads so the comma-separated config + // string is not re-split per row + protected final List indexKeyFieldList; protected final int bucketNum; private final boolean isNonBlockingConcurrencyControl; private final NumBucketsFunction numBucketsFunction; @@ -62,7 +66,8 @@ public BucketBulkInsertDataInternalWriterHelper(HoodieTable hoodieTable, HoodieW String instantTime, int taskPartitionId, long taskId, long taskEpochId, StructType structType, boolean populateMetaFields, boolean arePartitionRecordsSorted, boolean shouldPreserveHoodieMetadata) { super(hoodieTable, writeConfig, instantTime, taskPartitionId, taskId, taskEpochId, structType, populateMetaFields, arePartitionRecordsSorted, shouldPreserveHoodieMetadata); - this.indexKeyFields = writeConfig.getStringOrDefault(HoodieIndexConfig.BUCKET_INDEX_HASH_FIELD, writeConfig.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key())); + this.indexKeyFieldList = KeyGenUtils.getIndexKeyFields( + writeConfig.getStringOrDefault(HoodieIndexConfig.BUCKET_INDEX_HASH_FIELD, writeConfig.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key()))); this.bucketNum = writeConfig.getInt(HoodieIndexConfig.BUCKET_INDEX_NUM_BUCKETS); this.handles = new HashMap<>(); this.isNonBlockingConcurrencyControl = writeConfig.isNonBlockingConcurrencyControl(); @@ -73,7 +78,7 @@ public void write(InternalRow row) throws IOException { try { UTF8String partitionPath = extractPartitionPath(row); UTF8String recordKey = extractRecordKey(row); - int bucketId = BucketIdentifier.getBucketId(String.valueOf(recordKey), indexKeyFields, numBucketsFunction.getNumBuckets(partitionPath.toString())); + int bucketId = BucketIdentifier.getBucketId(String.valueOf(recordKey), indexKeyFieldList, numBucketsFunction.getNumBuckets(partitionPath.toString())); if (lastFileId == null || !Objects.equals(lastFileId.getKey(), partitionPath) || !Objects.equals(lastFileId.getValue(), bucketId)) { // NOTE: It's crucial to make a copy here, since [[UTF8String]] could be pointing into // a mutable underlying buffer diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BulkInsertDataInternalWriterHelper.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BulkInsertDataInternalWriterHelper.java index 57847faedb82e..712a27f81833e 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BulkInsertDataInternalWriterHelper.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BulkInsertDataInternalWriterHelper.java @@ -32,6 +32,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; import org.apache.spark.sql.types.DataType; import org.apache.spark.sql.types.StructType; import org.apache.spark.unsafe.types.UTF8String; @@ -69,6 +70,13 @@ public class BulkInsertDataInternalWriterHelper { protected final boolean simpleKeyGen; protected final int simplePartitionFieldIndex; protected final DataType simplePartitionFieldDataType; + protected final boolean shouldDropPartitionColumns; + // Ordinals and types of the non-partition fields, computed once on the first write() instead of + // in the constructor: bucket-index subclasses override write() and never drop columns, and the + // partition-column resolution must stay unreachable for them (and for tasks that write no rows) + // exactly as before. The helper is confined to a single task thread, so plain lazy init is safe. + private int[] retainedOrdinals; + private DataType[] retainedTypes; /** * NOTE: This is stored as Catalyst's internal {@link UTF8String} to avoid * conversion (deserialization) b/w {@link UTF8String} and {@link String} @@ -114,6 +122,36 @@ public BulkInsertDataInternalWriterHelper(HoodieTable hoodieTable, HoodieWriteCo this.simplePartitionFieldIndex = -1; this.simplePartitionFieldDataType = null; } + + this.shouldDropPartitionColumns = writeConfig.shouldDropPartitionColumns(); + } + + /** + * Resolves the ordinals and types of the non-partition fields. The partition columns are a pure + * function of the write config and schema, both immutable for the helper's lifetime, so this + * runs once per helper instead of once per row (getPartitionPathCols instantiates a key + * generator reflectively). + */ + private void initRetainedFields() { + List partitionCols = JavaScalaConverters.convertScalaListToJavaList( + HoodieDatasetBulkInsertHelper.getPartitionPathCols(this.writeConfig)); + Set partitionIdx = new HashSet<>(); + for (String col : partitionCols) { + partitionIdx.add(this.structType.fieldIndex(col)); + } + int numRetained = structType.fields().length - partitionIdx.size(); + int[] ordinals = new int[numRetained]; + DataType[] types = new DataType[numRetained]; + int retained = 0; + for (int i = 0; i < structType.fields().length; i++) { + if (!partitionIdx.contains(i)) { + ordinals[retained] = i; + types[retained] = structType.fields()[i].dataType(); + retained++; + } + } + this.retainedOrdinals = ordinals; + this.retainedTypes = types; } public void write(InternalRow row) throws IOException { @@ -126,27 +164,17 @@ public void write(InternalRow row) throws IOException { lastKnownPartitionPath = partitionPath.clone(); } - boolean shouldDropPartitionColumns = writeConfig.shouldDropPartitionColumns(); if (shouldDropPartitionColumns) { - // Drop the partition columns from the row - List partitionCols = JavaScalaConverters.convertScalaListToJavaList(HoodieDatasetBulkInsertHelper.getPartitionPathCols(this.writeConfig)); - Set partitionIdx = new HashSet<>(); - for (String col : partitionCols) { - partitionIdx.add(this.structType.fieldIndex(col)); + if (retainedOrdinals == null) { + initRetainedFields(); } - - // Relies on InternalRow::toSeq(...) preserving the column ordering based on the supplied schema - List cols = JavaScalaConverters.convertScalaListToJavaList(row.toSeq(structType)); - int idx = 0; - List newCols = new ArrayList<>(); - for (Object o : cols) { - if (!partitionIdx.contains(idx)) { - newCols.add(o); - } - idx += 1; + // Drop the partition columns from the row by copying the retained fields; a fresh row is + // allocated per record so values keep the same aliasing behavior as InternalRow.fromSeq + Object[] values = new Object[retainedOrdinals.length]; + for (int i = 0; i < retainedOrdinals.length; i++) { + values[i] = row.get(retainedOrdinals[i], retainedTypes[i]); } - InternalRow newRow = InternalRow.fromSeq(JavaScalaConverters.convertJavaListToScalaSeq(newCols)); - handle.write(newRow); + handle.write(new GenericInternalRow(values)); } else { handle.write(row); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/ConsistentBucketBulkInsertDataInternalWriterHelper.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/ConsistentBucketBulkInsertDataInternalWriterHelper.java index 9072e32939d70..19c11caa13690 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/ConsistentBucketBulkInsertDataInternalWriterHelper.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/ConsistentBucketBulkInsertDataInternalWriterHelper.java @@ -75,7 +75,7 @@ public void write(InternalRow row) throws IOException { private HoodieRowCreateHandle getBucketRowCreateHandle(String partitionPath, String recordKey) { ConsistentBucketIdentifier identifier = getBucketIdentifier(partitionPath); - final ConsistentHashingNode node = identifier.getBucket(recordKey, indexKeyFields); + final ConsistentHashingNode node = identifier.getBucket(recordKey, indexKeyFieldList); String fileId = FSUtils.createNewFileId(node.getFileIdPrefix(), 0); ValidationUtils.checkArgument(node.getTag() != ConsistentHashingNode.NodeTag.NORMAL diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBucketIndexPartitioner.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBucketIndexPartitioner.java index a0f778f17e6f4..db7261eb8286a 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBucketIndexPartitioner.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkBucketIndexPartitioner.java @@ -28,6 +28,7 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.index.bucket.BucketIdentifier; import org.apache.hudi.index.bucket.HoodieBucketIndex; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.WorkloadProfile; import org.apache.hudi.table.WorkloadStat; @@ -52,7 +53,9 @@ public class SparkBucketIndexPartitioner extends SparkHoodiePartitioner { private final int numBuckets; - private final String indexKeyField; + // parsed once; the per-record getPartition path uses the List overload of getBucketId so the + // comma-separated config string is not re-split per record + private final List indexKeyFieldList; private final int totalPartitionPaths; private final List partitionPaths; /** @@ -80,7 +83,7 @@ public SparkBucketIndexPartitioner(WorkloadProfile profile, + table.getIndex().getClass().getSimpleName()); } this.numBuckets = ((HoodieBucketIndex) table.getIndex()).getNumBuckets(); - this.indexKeyField = config.getBucketIndexHashField(); + this.indexKeyFieldList = KeyGenUtils.getIndexKeyFields(config.getBucketIndexHashField()); this.totalPartitionPaths = profile.getPartitionPaths().size(); partitionPaths = new ArrayList<>(profile.getPartitionPaths()); partitionPathOffset = new HashMap<>(); @@ -129,7 +132,7 @@ public int getPartition(Object key) { Option location = keyLocation._2; int bucketId = location.isPresent() ? BucketIdentifier.bucketIdFromFileId(location.get().getFileId()) - : BucketIdentifier.getBucketId(keyLocation._1.getRecordKey(), indexKeyField, numBuckets); + : BucketIdentifier.getBucketId(keyLocation._1.getRecordKey(), indexKeyFieldList, numBuckets); return partitionPathOffset.get(partitionPath) + bucketId; } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java index 6ac976f2e5442..0b699ae540e85 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteCommitActionExecutor.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieFileGroupId; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.timeline.HoodieTimeline; @@ -41,6 +42,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; public class SparkInsertOverwriteCommitActionExecutor @@ -82,10 +84,10 @@ protected String getCommitActionType() { @Override protected Map> getPartitionToReplacedFileIds(HoodieWriteMetadata> writeMetadata) { - String staticOverwritePartition = config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS); - if (StringUtils.nonEmpty(staticOverwritePartition)) { + String staticOverwritePartitionPaths = config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS); + if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) { // static insert overwrite partitions - List partitionPaths = Arrays.asList(staticOverwritePartition.split(",")); + List partitionPaths = Arrays.asList(staticOverwritePartitionPaths.split(",")); context.setJobStatus(this.getClass().getSimpleName(), "Getting ExistingFileIds of matching static partitions"); return HoodieJavaPairRDD.getJavaPairRDD(context.parallelize(partitionPaths, partitionPaths.size()).mapToPair( partitionPath -> Pair.of(partitionPath, getAllExistingFileIds(partitionPath)))).collectAsMap(); @@ -101,6 +103,26 @@ protected List getAllExistingFileIds(String partitionPath) { return table.getSliceView().getLatestFileSlices(partitionPath).map(FileSlice::getFileId).distinct().collect(Collectors.toList()); } + @Override + protected Set getFileGroupsBeingReplaced(HoodieData> inputRecords) { + String staticOverwritePartitionPaths = config.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS); + List partitionPaths; + + if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) { + // Static insert overwrite: use the configured partitions + partitionPaths = Arrays.asList(staticOverwritePartitionPaths.split(",")); + } else { + // Dynamic insert overwrite: determine partitions from input records + partitionPaths = inputRecords.map(HoodieRecord::getPartitionPath).distinct().collectAsList(); + } + + // Get all file groups in the partitions to be overwritten + return partitionPaths.stream() + .flatMap(partitionPath -> getAllExistingFileIds(partitionPath).stream() + .map(fileId -> new HoodieFileGroupId(partitionPath, fileId))) + .collect(Collectors.toSet()); + } + @Override protected Iterator> handleInsertPartition(String instantTime, Integer partition, Iterator recordItr, Broadcast bucketInfoGetter) { BucketInfo binfo = bucketInfoGetter.getValue().getBucketInfo(partition); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java index d300ea683a900..67f1ab3520731 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkInsertOverwriteTableCommitActionExecutor.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.HoodieFileGroupId; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.util.collection.Pair; @@ -31,8 +32,10 @@ import org.apache.hudi.table.action.HoodieWriteMetadata; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; public class SparkInsertOverwriteTableCommitActionExecutor extends SparkInsertOverwriteCommitActionExecutor { @@ -53,4 +56,23 @@ protected Map> getPartitionToReplacedFileIds(HoodieWriteMet return HoodieJavaPairRDD.getJavaPairRDD(context.parallelize(partitionPaths, partitionPaths.size()).mapToPair( partitionPath -> Pair.of(partitionPath, getAllExistingFileIds(partitionPath)))).collectAsMap(); } + + @Override + protected Set getFileGroupsBeingReplaced(HoodieData> inputRecords) { + // INSERT_OVERWRITE_TABLE replaces every file group across every partition, not just the + // partitions present in the input records. Enumerate all partitions in parallel via the + // engine context (matches the parallelization in getPartitionToReplacedFileIds above and + // avoids a sequential driver-side walk for tables with many partitions whose file system + // view isn't fully cached). + List partitionPaths = FSUtils.getAllPartitionPaths(context, table.getMetaClient(), config.getMetadataConfig()); + if (partitionPaths == null || partitionPaths.isEmpty()) { + return Collections.emptySet(); + } + context.setJobStatus(this.getClass().getSimpleName(), "Resolving file groups being replaced across all partitions"); + return new HashSet<>(context.parallelize(partitionPaths, partitionPaths.size()) + .flatMap(partitionPath -> table.getSliceView().getLatestFileSlices(partitionPath) + .map(fileSlice -> new HoodieFileGroupId(partitionPath, fileSlice.getFileId())) + .iterator()) + .collectAsList()); + } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkPartitionBucketIndexPartitioner.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkPartitionBucketIndexPartitioner.java index 3873835989877..292410d35d771 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkPartitionBucketIndexPartitioner.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/SparkPartitionBucketIndexPartitioner.java @@ -29,6 +29,7 @@ import org.apache.hudi.index.bucket.BucketIdentifier; import org.apache.hudi.index.bucket.HoodieBucketIndex; import org.apache.hudi.index.bucket.partition.NumBucketsFunction; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.WorkloadProfile; import org.apache.hudi.table.WorkloadStat; @@ -57,7 +58,9 @@ public class SparkPartitionBucketIndexPartitioner extends SparkHoodiePartitio private final int totalPartitions; private final NumBucketsFunction numBucketsFunction; - private final String indexKeyField; + // parsed once; the per-record getPartition path uses the List overload of getBucketId so the + // comma-separated config string is not re-split per record + private final List indexKeyFieldList; private final int totalPartitionPaths; private final List partitionPaths; /** @@ -96,7 +99,7 @@ public SparkPartitionBucketIndexPartitioner(WorkloadProfile profile, HoodieWriteConfig writeConfig = table.getConfig(); this.numBucketsFunction = NumBucketsFunction.fromWriteConfig(writeConfig); - this.indexKeyField = config.getBucketIndexHashField(); + this.indexKeyFieldList = KeyGenUtils.getIndexKeyFields(config.getBucketIndexHashField()); this.totalPartitionPaths = profile.getPartitionPaths().size(); partitionPaths = new ArrayList<>(profile.getPartitionPaths()); partitionPathOffset = new HashMap<>(); @@ -160,7 +163,7 @@ public int getPartition(Object key) { Option location = keyLocation._2; int bucketId = location.isPresent() ? BucketIdentifier.bucketIdFromFileId(location.get().getFileId()) - : BucketIdentifier.getBucketId(keyLocation._1.getRecordKey(), indexKeyField, numBucketsFunction.getNumBuckets(partitionPath)); + : BucketIdentifier.getBucketId(keyLocation._1.getRecordKey(), indexKeyFieldList, numBucketsFunction.getNumBuckets(partitionPath)); return partitionPathOffset.get(partitionPath) + bucketId; } } diff --git a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRecordContext.scala b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRecordContext.scala index 3a4cf4642bb8e..edf0a4eee3dbd 100644 --- a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRecordContext.scala +++ b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRecordContext.scala @@ -21,7 +21,7 @@ package org.apache.hudi import org.apache.avro.generic.{GenericRecord, IndexedRecord} import org.apache.hudi.common.engine.RecordContext -import org.apache.hudi.common.schema.HoodieSchema +import org.apache.hudi.common.schema.{HoodieAvroSchemaCache, HoodieSchema} import org.apache.hudi.common.table.HoodieTableConfig import org.apache.spark.sql.HoodieInternalRowUtils import org.apache.spark.sql.avro.{HoodieAvroDeserializer, HoodieAvroSerializer} @@ -47,7 +47,7 @@ trait SparkFileFormatInternalRecordContext extends BaseSparkInternalRecordContex * @return An [[InternalRow]]. */ override def convertAvroRecord(avroRecord: IndexedRecord): InternalRow = { - val schema = HoodieSchema.fromAvroSchema(avroRecord.getSchema) + val schema = HoodieAvroSchemaCache.intern(avroRecord.getSchema) val structType = HoodieInternalRowUtils.getCachedSchema(schema) val deserializer = deserializerMap.getOrElseUpdate(schema, { sparkAdapter.createAvroDeserializer(schema, structType) diff --git a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/BucketPartitionUtils.scala b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/BucketPartitionUtils.scala index da7e8c682e4e6..14aff571c238c 100644 --- a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/BucketPartitionUtils.scala +++ b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/BucketPartitionUtils.scala @@ -25,16 +25,20 @@ import org.apache.hudi.common.util.{Functions, RemotePartitionHelper} import org.apache.hudi.common.util.hash.BucketIndexUtil import org.apache.hudi.index.bucket.BucketIdentifier import org.apache.hudi.index.bucket.partition.NumBucketsFunction +import org.apache.hudi.keygen.KeyGenUtils import org.apache.spark.Partitioner import org.apache.spark.sql.catalyst.InternalRow object BucketPartitionUtils extends SparkAdapterSupport { def createDataFrame(df: DataFrame, indexKeyFields: String, numBucketsFunction: NumBucketsFunction, partitioner: Partitioner): DataFrame = { + // parse the comma-separated config once outside the per-row closure; the list is a + // serializable java.util.List, safe to capture + val indexKeyFieldList = KeyGenUtils.getIndexKeyFields(indexKeyFields) def getPartitionKeyExtractor(): InternalRow => (String, Int) = row => { val partition = row.getString(HoodieRecord.PARTITION_PATH_META_FIELD_ORD) val kb = BucketIdentifier - .getBucketId(row.getString(HoodieRecord.RECORD_KEY_META_FIELD_ORD), indexKeyFields, numBucketsFunction.getNumBuckets(partition)) + .getBucketId(row.getString(HoodieRecord.RECORD_KEY_META_FIELD_ORD), indexKeyFieldList, numBucketsFunction.getNumBuckets(partition)) if (partition == null || partition.trim.isEmpty) { ("", kb) diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestCoalescingPartitioner.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestCoalescingPartitioner.java index 0f3b972574ce9..9f99742e0c0e4 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestCoalescingPartitioner.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestCoalescingPartitioner.java @@ -123,7 +123,7 @@ public void testCoalescingPartitionerWithRDD(int inputNumPartitions, int targetP }, true).collect(); assertEquals(targetPartitions, countsPerPartition.size()); - // lets validate that atleast we have 50% of data in each spark partition compared to ideal scenario (we can't assume hash of strings will evenly distribute). + // lets validate that at least we have 50% of data in each spark partition compared to ideal scenario (we can't assume hash of strings will evenly distribute). countsPerPartition.forEach(pair -> { int numElements = pair.getValue(); int idealExpectedCount = inputNumPartitions / targetPartitions; diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestFileBasedLockProvider.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestFileBasedLockProvider.java deleted file mode 100644 index 0fcc9dadea18d..0000000000000 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestFileBasedLockProvider.java +++ /dev/null @@ -1,114 +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.hudi.client; - -import org.apache.hudi.client.transaction.lock.FileSystemBasedLockProvider; -import org.apache.hudi.common.config.LockConfiguration; -import org.apache.hudi.config.HoodieWriteConfig; -import org.apache.hudi.storage.StorageConfiguration; - -import org.apache.hadoop.conf.Configuration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.Properties; -import java.util.concurrent.TimeUnit; - -import static org.apache.hudi.common.config.LockConfiguration.FILESYSTEM_LOCK_EXPIRE_PROP_KEY; -import static org.apache.hudi.common.config.LockConfiguration.FILESYSTEM_LOCK_PATH_PROP_KEY; -import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY; -import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY; -import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY; -import static org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class TestFileBasedLockProvider { - - @TempDir - Path tempDir; - String basePath; - LockConfiguration lockConfiguration; - StorageConfiguration storageConf; - - @BeforeEach - public void setUp() throws IOException { - basePath = tempDir.toUri().getPath(); - Properties properties = new Properties(); - properties.setProperty(FILESYSTEM_LOCK_PATH_PROP_KEY, basePath); - properties.setProperty(FILESYSTEM_LOCK_EXPIRE_PROP_KEY, "1"); - properties.setProperty(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY, "1000"); - properties.setProperty(LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY, "1000"); - properties.setProperty(LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY, "3"); - lockConfiguration = new LockConfiguration(properties); - storageConf = getDefaultStorageConf(); - } - - @Test - public void testAcquireLock() { - FileSystemBasedLockProvider fileBasedLockProvider = new FileSystemBasedLockProvider(lockConfiguration, storageConf); - assertTrue(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - fileBasedLockProvider.unlock(); - } - - @Test - public void testAcquireLockWithDefaultPath() { - lockConfiguration.getConfig().remove(FILESYSTEM_LOCK_PATH_PROP_KEY); - lockConfiguration.getConfig().setProperty(HoodieWriteConfig.BASE_PATH.key(), basePath); - FileSystemBasedLockProvider fileBasedLockProvider = new FileSystemBasedLockProvider(lockConfiguration, storageConf); - assertTrue(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - fileBasedLockProvider.unlock(); - lockConfiguration.getConfig().setProperty(FILESYSTEM_LOCK_PATH_PROP_KEY, basePath); - } - - @Test - public void testUnLock() { - FileSystemBasedLockProvider fileBasedLockProvider = new FileSystemBasedLockProvider(lockConfiguration, storageConf); - assertTrue(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - fileBasedLockProvider.unlock(); - assertTrue(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - } - - @Test - public void testReentrantLock() { - FileSystemBasedLockProvider fileBasedLockProvider = new FileSystemBasedLockProvider(lockConfiguration, storageConf); - assertTrue(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - assertFalse(fileBasedLockProvider.tryLock(lockConfiguration.getConfig() - .getLong(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY), TimeUnit.MILLISECONDS)); - fileBasedLockProvider.unlock(); - } - - @Test - public void testUnlockWithoutLock() { - assertDoesNotThrow(() -> { - FileSystemBasedLockProvider fileBasedLockProvider = new FileSystemBasedLockProvider(lockConfiguration, storageConf); - fileBasedLockProvider.unlock(); - }); - } -} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSimpleTransactionDirectMarkerBasedDetectionStrategyWithZKLockProvider.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSimpleTransactionDirectMarkerBasedDetectionStrategyWithZKLockProvider.java index deb551e9d2458..a2a5a92f69eb4 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSimpleTransactionDirectMarkerBasedDetectionStrategyWithZKLockProvider.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSimpleTransactionDirectMarkerBasedDetectionStrategyWithZKLockProvider.java @@ -51,9 +51,16 @@ import java.util.List; import java.util.Properties; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_CLIENT_NUM_RETRIES_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_RETRY_MAX_WAIT_TIME_IN_MILLIS_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY; import static org.apache.hudi.common.config.LockConfiguration.ZK_BASE_PATH_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.ZK_CONNECTION_TIMEOUT_MS_PROP_KEY; import static org.apache.hudi.common.config.LockConfiguration.ZK_CONNECT_URL_PROP_KEY; import static org.apache.hudi.common.config.LockConfiguration.ZK_LOCK_KEY_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.ZK_SESSION_TIMEOUT_MS_PROP_KEY; import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -82,6 +89,15 @@ private void setUp(boolean partitioned) throws Exception { properties.setProperty(ZK_CONNECT_URL_PROP_KEY, server.getConnectString()); properties.setProperty(ZK_BASE_PATH_PROP_KEY, server.getTempDirectory().getAbsolutePath()); properties.setProperty(ZK_LOCK_KEY_PROP_KEY, "key"); + // Bound lock retries and ZK timeouts so a transient connection failure fails fast in seconds + // instead of being amplified by the production-default retry layers into a multi-minute hang. + properties.setProperty(LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY, "1000"); + properties.setProperty(LOCK_ACQUIRE_RETRY_MAX_WAIT_TIME_IN_MILLIS_PROP_KEY, "3000"); + properties.setProperty(LOCK_ACQUIRE_CLIENT_NUM_RETRIES_PROP_KEY, "3"); + properties.setProperty(LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY, "3"); + properties.setProperty(ZK_SESSION_TIMEOUT_MS_PROP_KEY, "10000"); + properties.setProperty(ZK_CONNECTION_TIMEOUT_MS_PROP_KEY, "10000"); + properties.setProperty(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY, "1000"); config = getConfigBuilder() .withFileSystemViewConfig(FileSystemViewStorageConfig.newBuilder() @@ -104,10 +120,15 @@ private void setUp(boolean partitioned) throws Exception { @AfterEach public void clean() throws IOException { - cleanupResources(); - FileIOUtils.deleteDirectory(new File(basePath)); - if (server != null) { - server.close(); + try { + cleanupResources(); + FileIOUtils.deleteDirectory(new File(basePath)); + } finally { + // Always stop the embedded ZooKeeper server, even if resource cleanup or directory + // deletion above throws, so the server is not leaked across parameterized runs. + if (server != null) { + server.close(); + } } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java index aa46a177ac4ea..0a8b7918d8503 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java @@ -19,8 +19,10 @@ package org.apache.hudi.client; +import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.metadata.DefaultMetadataTableFileGroupIndexParser; import org.apache.hudi.metadata.HoodieMetadataPayload; import org.apache.hudi.metadata.MetadataPartitionType; @@ -31,6 +33,7 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -108,4 +111,62 @@ public void testPartitioner() { Set fileIDPrefixes = IntStream.of(0, 1, 2, 4).mapToObj(partitioner::getFileIdPfx).collect(Collectors.toSet()); assertEquals(fileIDPrefixes, recordsPerFileGroup.keySet(), "fileIDPrefixes should match the name of the MDT fileGroups"); } + + @Test + public void testPartitionerSortsBinaryKeysByUtf8Bytes() { + // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte 0xF0) in raw UTF-8 byte + // order, but AFTER it under String.compareTo (UTF-16). All records target a single MDT file group + // so the partitioner's only job here is the within-partition UTF-8 sort. + String fileGroupId = MetadataPartitionType.FILES.getFileIdPrefix() + "000"; + String bmpPrivateUse = new String(Character.toChars(0xE000)); + String supplementary = new String(Character.toChars(0x20000)); + + // Shuffled input mixing both prefixes plus an ascii key. + List inputKeys = Arrays.asList( + supplementary + "-b", + "ascii-key", + bmpPrivateUse + "-a", + supplementary + "-a", + bmpPrivateUse + "-b"); + + List records = new ArrayList<>(); + for (String key : inputKeys) { + // createPartitionListRecord fixes the record key, so start from it (for a valid MDT payload) + // and rebind an explicitly chosen HoodieKey via newInstance. + HoodieRecord r = HoodieMetadataPayload.createPartitionListRecord(Collections.EMPTY_LIST) + .newInstance(new HoodieKey(key, "")); + r.unseal(); + r.setCurrentLocation(new HoodieRecordLocation("001", fileGroupId)); + r.seal(); + records.add(r); + } + + SparkHoodieMetadataBulkInsertPartitioner partitioner = + new SparkHoodieMetadataBulkInsertPartitioner(new DefaultMetadataTableFileGroupIndexParser(1)); + JavaRDD partitionedRecords = + partitioner.repartitionRecords(jsc().parallelize(records, records.size()), 0); + + // All records map to one file group, hence a single partition. + assertEquals(1, partitionedRecords.getNumPartitions(), "All records map to a single file group"); + assertTrue(partitioner.arePartitionRecordsSorted(), "Must be sorted"); + + List actualKeys = partitionedRecords.map(r -> r.getRecordKey()).collect(); + List expectedKeys = new ArrayList<>(inputKeys); + expectedKeys.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); + assertEquals(expectedKeys, actualKeys, "Records must be sorted by UTF-8 byte order within the file group"); + + // The divergent pair: every U+E000-prefixed key precedes every U+20000-prefixed key in UTF-8 + // byte order, the opposite of String.compareTo (UTF-16) order. + int lastBmpIndex = -1; + int firstSupplementaryIndex = actualKeys.size(); + for (int i = 0; i < actualKeys.size(); i++) { + if (actualKeys.get(i).startsWith(bmpPrivateUse)) { + lastBmpIndex = i; + } else if (actualKeys.get(i).startsWith(supplementary) && firstSupplementaryIndex == actualKeys.size()) { + firstSupplementaryIndex = i; + } + } + assertTrue(lastBmpIndex < firstSupplementaryIndex, + "All U+E000-prefixed keys should sort before U+20000-prefixed keys in UTF-8 order"); + } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkRDDMetadataWriteClient.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkRDDMetadataWriteClient.java index 8a88dd8135887..65e45ce96f368 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkRDDMetadataWriteClient.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkRDDMetadataWriteClient.java @@ -218,10 +218,12 @@ private void readFromMDTFileSliceAndValidate(HoodieTableMetaClient metadataMetaC HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(metadataSchema); HoodieAvroReaderContext readerContext = new HoodieAvroReaderContext(metadataMetaClient.getStorageConf(), metadataMetaClient.getTableConfig(), Option.of(instantRange), Option.of(predicate)); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metadataMetaClient) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withLatestCommitTime(validMetadataInstant) .withRequestedSchema(metadataSchema) .withDataSchema(schema) diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestHoodieSparkEngineDynamicRepartition.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestHoodieSparkEngineDynamicRepartition.java index 217b2c4dd6a5c..86bd48f20ac4f 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestHoodieSparkEngineDynamicRepartition.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestHoodieSparkEngineDynamicRepartition.java @@ -210,7 +210,7 @@ private static void validateRepartitionedRDDProperties( } catch (AssertionError e) { logRDDContent("Original RDD", originalRdd); logRDDContent("Repartitioned RDD", repartitionedRdd); - LOG.error("Validation failed: " + e.getMessage(), e); + LOG.error("Validation failed: {}", e.getMessage(), e); throw e; // rethrow to fail the test } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestSparkReaderContextFactory.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestSparkReaderContextFactory.java index 77b280cdd91f6..f48df51c47f21 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestSparkReaderContextFactory.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/common/TestSparkReaderContextFactory.java @@ -20,6 +20,7 @@ import org.apache.hudi.HoodieSparkUtils; import org.apache.hudi.client.utils.SparkInternalSchemaConverter; +import org.apache.hudi.common.config.HoodieReaderConfig; import org.apache.hudi.common.engine.HoodieReaderContext; import org.apache.hudi.common.model.ActionType; import org.apache.hudi.common.table.HoodieTableConfig; @@ -113,6 +114,11 @@ void testGetSchemaEvolutionConfigurations() { String inlineClassName = createdConfig.get("fs." + InLineFileSystem.SCHEME + ".impl"); assertEquals(InLineFileSystem.class.getName(), inlineClassName); + // Internal write-side reads must pin CONTENT; a DESCRIPTOR leak here drops blob bytes (#19232). + assertEquals( + HoodieReaderConfig.BLOB_INLINE_READ_MODE_CONTENT, + createdConfig.get(HoodieReaderConfig.BLOB_INLINE_READ_MODE.key())); + assertEquals( "0001_0005.deltacommit,0002_0006.deltacommit,0003_0007.commit", createdConfig.get(SparkInternalSchemaConverter.HOODIE_VALID_COMMITS_LIST)); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestExternalPathHandling.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestExternalPathHandling.java index ce94187aacbcf..5d7c4aac24165 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestExternalPathHandling.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestExternalPathHandling.java @@ -339,8 +339,14 @@ private WriteStatus createWriteStatus(String commitTime, String partitionPath, S } private HoodieCleanStat createCleanStat(String partitionPath, List deletePaths, String earliestCommitToRetain, String lastCompletedCommitTimestamp) { - return new HoodieCleanStat(HoodieCleaningPolicy.KEEP_LATEST_COMMITS, partitionPath, deletePaths, deletePaths, Collections.emptyList(), - earliestCommitToRetain, lastCompletedCommitTimestamp); + return HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_COMMITS) + .withPartitionPath(partitionPath) + .withDeletePathPatterns(deletePaths) + .withSuccessDeleteFiles(deletePaths) + .withEarliestCommitToRetain(earliestCommitToRetain) + .withLastCompletedCommitTimestamp(lastCompletedCommitTimestamp) + .build(); } private HoodieCleanerPlan cleanerPlan(HoodieActionInstant earliestInstantToRetain, String latestCommit, Map> filePathsToBeDeletedPerPartition) { diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieBackedTableMetadata.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieBackedTableMetadata.java index 6bb71f07de614..fcbd689f74471 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieBackedTableMetadata.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieBackedTableMetadata.java @@ -560,7 +560,7 @@ private void verifyMetadataRawRecords(HoodieTable table, List log private void verifyMetadataMergedRecords(HoodieTableMetaClient metadataMetaClient, List logFiles, String latestCommitTimestamp, HoodieWriteConfig metadataTableWriteConfig) { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())); HoodieAvroReaderContext readerContext = new HoodieAvroReaderContext(metadataMetaClient.getStorageConf(), metadataMetaClient.getTableConfig(), Option.empty(), Option.empty()); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metadataMetaClient) .withLogFiles(logFiles.stream()) diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBase.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBase.java index f28de3266c7f0..4a210227bb743 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBase.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBase.java @@ -344,6 +344,7 @@ protected HoodieWriteConfig.Builder getWriteConfigBuilder(HoodieFailedWritesClea .withMetadataConfig(HoodieMetadataConfig.newBuilder() .enable(useFileListingMetadata) .enableMetrics(enableMetrics) + .enableDetailedMetadataMetrics(enableMetrics) .ignoreSpuriousDeletes(validateMetadataPayloadConsistency) .build()) .withMetricsConfig(HoodieMetricsConfig.newBuilder().on(enableMetrics) diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBootstrap.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBootstrap.java index 28b325889072a..742e14874377b 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBootstrap.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieMetadataBootstrap.java @@ -108,6 +108,34 @@ public void testMetadataBootstrapWithExtraFiles() throws Exception { validateMetadata(testTable); } + @Test + public void testMetadataSkipsZeroSizeFilesOnInitialize() throws Exception { + HoodieTableType tableType = COPY_ON_WRITE; + init(tableType, false); + doPreBootstrapWriteOperation(testTable, INSERT, "0000001"); + doPreBootstrapWriteOperation(testTable, "0000002"); + // Add a zero-size base file — bootstrap should skip it without failing. + String fileName = UUID.randomUUID().toString(); + Path zeroSizeFilePath = FileCreateUtilsLegacy.getBaseFilePath(basePath, "p1", "0000003", fileName); + FileCreateUtilsLegacy.createBaseFile(basePath, "p1", "0000003", fileName, 0); + + writeConfig = getWriteConfigBuilder(true, true, false) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withSkipZeroSizeFilesOnInitialize(true) + .build()) + .build(); + initWriteConfigAndMetatableWriter(writeConfig, true); + syncTableMetadata(writeConfig); + + // Delete the zero-size file before validation — it was skipped in MDT and must not + // exist on disk for the filesystem-vs-MDT consistency check to pass. + Files.delete(zeroSizeFilePath); + validateMetadata(testTable); + doWriteInsertAndUpsert(testTable); + validateMetadata(testTable); + } + @ParameterizedTest @EnumSource(HoodieTableType.class) public void testMetadataBootstrapInsertUpsertRollback(HoodieTableType tableType) throws Exception { diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestSavepointRestoreCopyOnWrite.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestSavepointRestoreCopyOnWrite.java index 0be971630c346..5c3c832260736 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestSavepointRestoreCopyOnWrite.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestSavepointRestoreCopyOnWrite.java @@ -29,6 +29,8 @@ import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.storage.HoodieStorageUtils; +import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.HoodieSparkTable; import org.apache.hudi.testutils.HoodieClientTestBase; @@ -41,6 +43,7 @@ import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.apache.hudi.metadata.HoodieTableMetadata.SOLO_COMMIT_TIMESTAMP; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -217,4 +220,90 @@ void testCleaningRollbackInstants(boolean commitRollback) throws Exception { assertRowNumberEqualsTo(20); } } + + /** + * Two-assertion test for the MDT pre-check guard: + *
    + *
  1. {@code deleteMdtIfNecessaryBeforeRestore(firstCommit)} returns {@code true} (and + * deletes the MDT) when the target is at or before the oldest MDT compaction, + * confirming the guard logic fires for this table setup.
  2. + *
  3. {@code restoreToInstant(firstCommit, false)} does NOT invoke the pre-check, so a + * caller that explicitly opts out of MDT integration is never surprised by an implicit + * MDT deletion.
  4. + *
+ */ + @Test + void testRestoreToInstantSkipsMdtCheckWhenMetadataDisabled() throws Exception { + HoodieWriteConfig hoodieWriteConfig = getConfigBuilder(HoodieFailedWritesCleaningPolicy.LAZY) + .withRollbackUsingMarkers(true) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withMaxNumDeltaCommitsBeforeCompaction(4) + .build()) + .build(); + try (SparkRDDWriteClient client = getHoodieWriteClient(hoodieWriteConfig)) { + String firstCommit = null; + String prevInstant = HoodieTimeline.INIT_INSTANT_TS; + final int numRecords = 10; + // 5 inserts so the MDT goes through one compaction with maxDeltaCommits=4. + for (int i = 1; i <= 5; i++) { + String newCommitTime = WriteClientTestUtils.createNewInstantTime(); + insertBatch(hoodieWriteConfig, client, newCommitTime, prevInstant, numRecords, SparkRDDWriteClient::insert, + false, true, numRecords, numRecords * i, 1, Option.empty(), INSTANT_GENERATOR); + prevInstant = newCommitTime; + if (i == 1) { + firstCommit = newCommitTime; + } + } + assertRowNumberEqualsTo(50); + + String mdtBasePath = HoodieTableMetadata.getMetadataTableBasePath(hoodieWriteConfig.getBasePath()); + assertTrue(HoodieStorageUtils.getStorage(mdtBasePath, storageConf).exists(new StoragePath(mdtBasePath)), + "MDT directory should exist before any pre-check"); + + // Assertion 1: deleteMdtIfNecessaryBeforeRestore detects that firstCommit is at or before + // the oldest MDT compaction, deletes the MDT, and returns true. + boolean mdtDeleted = client.deleteMdtIfNecessaryBeforeRestore( + Objects.requireNonNull(firstCommit, "first commit should not be null")); + assertTrue(mdtDeleted, + "deleteMdtIfNecessaryBeforeRestore should return true when target is at or before the oldest MDT compaction"); + assertFalse(HoodieStorageUtils.getStorage(mdtBasePath, storageConf).exists(new StoragePath(mdtBasePath)), + "MDT directory should have been deleted by deleteMdtIfNecessaryBeforeRestore"); + + // Assertion 2: restoreToInstant with initialMetadataTableIfNecessary=false does NOT invoke + // the pre-check — the MDT (now absent) is not touched. The restore proceeds without MDT. + client.restoreToInstant(firstCommit, false); + assertRowNumberEqualsTo(numRecords); + } + } + + /** + * Regression coverage for the {@code restoreToSavepoint} refactor. After replacing the inline + * MDT pre-check with a call to the centralized helper, the common case (target after the + * oldest MDT compaction, no MDT delete needed) must still work end-to-end. + */ + @Test + void testRestoreToSavepointStillWorksAfterRefactor() throws Exception { + HoodieWriteConfig hoodieWriteConfig = getConfigBuilder(HoodieFailedWritesCleaningPolicy.LAZY) + .withRollbackUsingMarkers(true) + .build(); + try (SparkRDDWriteClient client = getHoodieWriteClient(hoodieWriteConfig)) { + String savepointCommit = null; + String prevInstant = HoodieTimeline.INIT_INSTANT_TS; + final int numRecords = 10; + for (int i = 1; i <= 4; i++) { + String newCommitTime = WriteClientTestUtils.createNewInstantTime(); + insertBatch(hoodieWriteConfig, client, newCommitTime, prevInstant, numRecords, SparkRDDWriteClient::insert, + false, true, numRecords, numRecords * i, 1, Option.empty(), INSTANT_GENERATOR); + prevInstant = newCommitTime; + if (i == 2) { + savepointCommit = newCommitTime; + client.savepoint("user1", "Savepoint for 2nd commit"); + } + } + assertRowNumberEqualsTo(40); + client.restoreToSavepoint(Objects.requireNonNull(savepointCommit, "restore commit should not be null")); + assertRowNumberEqualsTo(20); + } + } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/utils/TestSparkValidatorUtils.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/utils/TestSparkValidatorUtils.java index c53b5ceb6d1c0..ac54baea9c34c 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/utils/TestSparkValidatorUtils.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/utils/TestSparkValidatorUtils.java @@ -20,18 +20,30 @@ import org.apache.hudi.client.SparkRDDWriteClient; import org.apache.hudi.client.WriteClientTestUtils; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.client.validator.SparkPreCommitValidator; import org.apache.hudi.client.validator.SqlQuerySingleResultPreCommitValidator; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodiePreCommitValidatorConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieValidationException; +import org.apache.hudi.table.HoodieSparkTable; import org.apache.hudi.testutils.HoodieClientTestBase; + +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import org.apache.spark.api.java.JavaRDD; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Tests for {@link SparkValidatorUtils}. @@ -95,4 +107,201 @@ public void testSqlQueryValidatorWithNoRecords() throws Exception { "Should have 2 commits (one with data, one empty)"); } } + + /** + * Verifies that two custom validators are both invoked in parallel via ExecutorServiceBasedEngineContext + * without ClassNotFoundException, confirming that classloader-loaded user validator classes execute correctly. + */ + @Test + public void testTwoValidatorsBothInvoked() throws Exception { + CountingValidator.INVOCATION_COUNT.set(0); + + HoodieWriteConfig configWithTwoValidators = getConfigBuilder() + .withPreCommitValidatorConfig( + HoodiePreCommitValidatorConfig.newBuilder() + .withPreCommitValidator( + CountingValidator.class.getName() + "," + CountingValidator.class.getName()) + .build()) + .build(); + + try (SparkRDDWriteClient writeClient = getHoodieWriteClient(configWithTwoValidators)) { + String commit = "001"; + writeBatch( + writeClient, + commit, + "000", + Option.empty(), + "000", + 5, + generateWrapRecordsFn(false, configWithTwoValidators, dataGen::generateInserts), + SparkRDDWriteClient::bulkInsert, + true, + 5, + 5, + 1, + false, + INSTANT_GENERATOR); + } + + Assertions.assertEquals(2, CountingValidator.INVOCATION_COUNT.get(), + "Both configured validators must have been invoked in parallel"); + } + + /** + * Verifies that when a validator throws {@link HoodieValidationException}, it surfaces somewhere + * in the exception cause chain of the write operation. + * The write client wraps validator exceptions in a {@code HoodieInsertException}, so we walk + * the cause chain rather than asserting the top-level type. + */ + @Test + public void testValidatorFailurePropagatesException() throws Exception { + HoodieWriteConfig configWithFailingValidator = getConfigBuilder() + .withPreCommitValidatorConfig( + HoodiePreCommitValidatorConfig.newBuilder() + .withPreCommitValidator(FailingValidator.class.getName()) + .build()) + .build(); + + try (SparkRDDWriteClient writeClient = getHoodieWriteClient(configWithFailingValidator)) { + String commit = "001"; + Exception thrown = assertThrows(Exception.class, () -> + writeBatch( + writeClient, + commit, + "000", + Option.empty(), + "000", + 5, + generateWrapRecordsFn(false, configWithFailingValidator, dataGen::generateInserts), + SparkRDDWriteClient::bulkInsert, + true, + 5, + 5, + 1, + false, + INSTANT_GENERATOR), + "A failing validator must cause the write operation to throw"); + + // Walk the cause chain: bulkInsert wraps the HoodieValidationException in HoodieInsertException. + Throwable cause = thrown; + while (cause != null && !(cause instanceof HoodieValidationException)) { + cause = cause.getCause(); + } + Assertions.assertNotNull(cause, + "HoodieValidationException must appear somewhere in the exception cause chain"); + Assertions.assertInstanceOf(HoodieValidationException.class, cause); + } + } + + /** + * Verifies that when a validator throws an unexpected RuntimeException (e.g. NPE or + * IllegalStateException — a validator bug), the exception is NOT silently swallowed as a + * generic "validation failed" message. The original exception must appear in the cause chain + * so operators can diagnose the real problem. + */ + @Test + public void testUnexpectedValidatorExceptionIsNotSilenced() throws Exception { + HoodieWriteConfig configWithBuggyValidator = getConfigBuilder() + .withPreCommitValidatorConfig( + HoodiePreCommitValidatorConfig.newBuilder() + .withPreCommitValidator(BuggyValidator.class.getName()) + .build()) + .build(); + + try (SparkRDDWriteClient writeClient = getHoodieWriteClient(configWithBuggyValidator)) { + String commit = "001"; + Exception thrown = assertThrows(Exception.class, () -> + writeBatch( + writeClient, + commit, + "000", + Option.empty(), + "000", + 5, + generateWrapRecordsFn(false, configWithBuggyValidator, dataGen::generateInserts), + SparkRDDWriteClient::bulkInsert, + true, + 5, + 5, + 1, + false, + INSTANT_GENERATOR), + "A buggy validator must still cause the write to fail"); + + // The original IllegalStateException must be visible somewhere in the cause chain. + // It must NOT be silently converted into a plain "At least one pre-commit validation failed". + Throwable cause = thrown; + boolean foundOriginal = false; + while (cause != null) { + if (cause instanceof IllegalStateException + && "simulated bug in validator".equals(cause.getMessage())) { + foundOriginal = true; + break; + } + cause = cause.getCause(); + } + Assertions.assertTrue(foundOriginal, + "The original IllegalStateException from the buggy validator must appear in the cause chain, " + + "not be buried under a generic 'validation failed' message. Full exception: " + thrown); + } + } + + /** + * Minimal validator that records each invocation. Must be a public static class so that + * ReflectionUtils can instantiate it by name during runValidators. + */ + public static class CountingValidator> + extends SparkPreCommitValidator { + + static final AtomicInteger INVOCATION_COUNT = new AtomicInteger(0); + + public CountingValidator(HoodieSparkTable table, HoodieEngineContext context, + HoodieWriteConfig config) { + super(table, context, config); + } + + @Override + protected void validateRecordsBeforeAndAfter(Dataset before, Dataset after, + Set partitionsAffected) { + INVOCATION_COUNT.incrementAndGet(); + } + } + + /** + * Validator that always fails with {@link HoodieValidationException}. Must be a public static + * class so that ReflectionUtils can instantiate it by name during runValidators. + */ + public static class FailingValidator> + extends SparkPreCommitValidator { + + public FailingValidator(HoodieSparkTable table, HoodieEngineContext context, + HoodieWriteConfig config) { + super(table, context, config); + } + + @Override + protected void validateRecordsBeforeAndAfter(Dataset before, Dataset after, + Set partitionsAffected) { + throw new HoodieValidationException("intentional failure from FailingValidator"); + } + } + + /** + * Validator that throws an unexpected RuntimeException (simulates a validator bug such as NPE). + * Must be a public static class so that ReflectionUtils can instantiate it by name. + */ + public static class BuggyValidator> + extends SparkPreCommitValidator { + + public BuggyValidator(HoodieSparkTable table, HoodieEngineContext context, + HoodieWriteConfig config) { + super(table, context, config); + } + + @Override + protected void validateRecordsBeforeAndAfter(Dataset before, Dataset after, + Set partitionsAffected) { + throw new IllegalStateException("simulated bug in validator"); + } + } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/validator/TestSparkPreCommitValidator.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/validator/TestSparkPreCommitValidator.java new file mode 100644 index 0000000000000..83ee85302cbe6 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/validator/TestSparkPreCommitValidator.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.hudi.client.validator; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieValidationException; +import org.apache.hudi.table.HoodieSparkTable; +import org.apache.hudi.table.action.HoodieWriteMetadata; + +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.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +/** + * Unit tests for exception-handling behavior in {@link SparkPreCommitValidator#validate}. + */ +@ExtendWith(MockitoExtension.class) +public class TestSparkPreCommitValidator { + + @Mock + @SuppressWarnings("rawtypes") + private HoodieSparkTable table; + + @Mock + private HoodieEngineContext engineContext; + + @Mock + private HoodieWriteConfig writeConfig; + + @Mock + @SuppressWarnings("rawtypes") + private HoodieWriteMetadata writeMetadata; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + when(writeConfig.getTableName()).thenReturn("test-table"); + when(writeConfig.isMetricsOn()).thenReturn(false); + when(writeMetadata.getWriteStats()).thenReturn(Option.of(Collections.emptyList())); + } + + @Test + @SuppressWarnings("unchecked") + void testValidateSucceeds() { + SparkPreCommitValidator> validator = + new NoOpValidator(table, engineContext, writeConfig); + assertDoesNotThrow(() -> validator.validate("001", writeMetadata, null, null), + "validate must not throw when validateRecordsBeforeAndAfter completes normally"); + } + + @Test + @SuppressWarnings("unchecked") + void testValidateRethrowsUnexpectedRuntimeException() { + RuntimeException cause = new RuntimeException("disk full"); + SparkPreCommitValidator> validator = + new ThrowingValidator(table, engineContext, writeConfig, cause); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> validator.validate("001", writeMetadata, null, null)); + assertSame(cause, ex, + "unexpected RuntimeException must propagate as-is so the operator sees the original stack trace, " + + "not a generic 'validation failed' message"); + } + + @Test + @SuppressWarnings("unchecked") + void testValidateReThrowsValidationException() { + HoodieValidationException original = new HoodieValidationException("bad data"); + SparkPreCommitValidator> validator = + new ThrowingValidator(table, engineContext, writeConfig, original); + + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> validator.validate("001", writeMetadata, null, null)); + assertSame(original, ex, + "HoodieValidationException must be rethrown as-is without additional wrapping"); + } + + /** Minimal concrete validator that completes normally. */ + private static class NoOpValidator> + extends SparkPreCommitValidator { + + NoOpValidator(HoodieSparkTable table, HoodieEngineContext context, HoodieWriteConfig config) { + super(table, context, config); + } + + @Override + protected void validateRecordsBeforeAndAfter(Dataset before, Dataset after, + Set partitionsAffected) { + // no-op — validation always passes + } + } + + /** Minimal concrete validator that throws a fixed exception from validateRecordsBeforeAndAfter. */ + private static class ThrowingValidator> + extends SparkPreCommitValidator { + + private final RuntimeException toThrow; + + ThrowingValidator(HoodieSparkTable table, HoodieEngineContext context, + HoodieWriteConfig config, RuntimeException toThrow) { + super(table, context, config); + this.toThrow = toThrow; + } + + @Override + protected void validateRecordsBeforeAndAfter(Dataset before, Dataset after, + Set partitionsAffected) { + throw toThrow; + } + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/fs/TestHoodieSerializableFileStatus.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/fs/TestHoodieSerializableFileStatus.java index 61857cb9837c4..7759ee5e2fb25 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/fs/TestHoodieSerializableFileStatus.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/fs/TestHoodieSerializableFileStatus.java @@ -18,8 +18,6 @@ package org.apache.hudi.common.fs; -import org.apache.hudi.client.common.HoodieSparkEngineContext; -import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.hadoop.fs.HoodieSerializableFileStatus; import org.apache.hudi.testutils.HoodieSparkClientTestHarness; @@ -45,7 +43,6 @@ @TestInstance(Lifecycle.PER_CLASS) public class TestHoodieSerializableFileStatus extends HoodieSparkClientTestHarness { - HoodieEngineContext engineContext; List testPaths; @BeforeAll @@ -55,7 +52,6 @@ public void setUp() throws IOException { for (int i = 0; i < 5; i++) { testPaths.add(new Path("s3://table-bucket/")); } - engineContext = new HoodieSparkEngineContext(jsc); } @AfterAll @@ -67,7 +63,7 @@ public void tearDown() { public void testNonSerializableFileStatus() { Exception e = Assertions.assertThrows(SparkException.class, () -> { - List statuses = engineContext.flatMap(testPaths, path -> { + List statuses = context.flatMap(testPaths, path -> { FileSystem fileSystem = new NonSerializableFileSystem(); return Arrays.stream(fileSystem.listStatus(path)); }, 5); @@ -78,7 +74,7 @@ public void testNonSerializableFileStatus() { @Test public void testHoodieFileStatusSerialization() { - Assertions.assertDoesNotThrow(() -> engineContext.flatMap(testPaths, path -> { + Assertions.assertDoesNotThrow(() -> context.flatMap(testPaths, path -> { FileSystem fileSystem = new NonSerializableFileSystem(); return Arrays.stream(HoodieSerializableFileStatus.fromFileStatuses(fileSystem.listStatus(path))); }, 5)); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/table/log/TestLogReaderUtils.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/table/log/TestLogReaderUtils.java index 2ddffc27d8750..f1a3f877ab1b9 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/table/log/TestLogReaderUtils.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/common/table/log/TestLogReaderUtils.java @@ -43,6 +43,8 @@ import org.apache.spark.api.java.JavaRDD; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.Arrays; import java.util.List; @@ -50,6 +52,7 @@ import java.util.Properties; import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA; +import static org.apache.hudi.testutils.Assertions.assertFileSizesEqual; import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -131,6 +134,61 @@ public void testGetAllLogFilesWithMaxCommit() throws Exception { } } + @ParameterizedTest + @ValueSource(ints = {6, 9}) // SIX appends to the existing log file (logOffset > 0); NINE writes fresh files (logOffset == 0) + public void testLogFileWriteStatSizeMatchesOnDisk(int writeTableVersion) throws Exception { + // HoodieAppendHandle derives each log file's on-disk size from the AppendResult + // (logOffset + accumulated appended bytes) instead of a getPathInfo per file. Validate that the + // derived size in the write stat matches the actual on-disk log file length. + // + // The "logOffset +" term only contributes when a delta commit appends to a pre-existing log file: + // - table version >= EIGHT (e.g. NINE): each delta commit writes a fresh instant-named log file + // from offset 0, so logOffset is always 0; + // - table version SIX: the second upsert appends to the first commit's log file, so logOffset + // is > 0 and the derived sum is actually exercised. + // Running both covers the derivation with and without a non-zero offset. + Properties props = new Properties(); + props.setProperty(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), String.valueOf(writeTableVersion)); + HoodieTableMetaClient metaClient = getHoodieMetaClient( + storageConf(), basePath(), props, HoodieTableType.MERGE_ON_READ); + + HoodieWriteConfig config = getConfigBuilder(true) + .withPath(basePath()) + .withWriteTableVersion(writeTableVersion) + .withAutoUpgradeVersion(false) + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .withInlineCompaction(false) + .compactionSmallFileSize(0) + .build()) + .build(); + + HoodieTestDataGenerator dataGen = new HoodieTestDataGenerator(); + + try (SparkRDDWriteClient client = getHoodieWriteClient(config)) { + // First commit - insert data (base files) + String firstCommit = "001"; + WriteClientTestUtils.startCommitWithTime(client, firstCommit); + JavaRDD insertRdd = client.insert(jsc().parallelize(dataGen.generateInserts(firstCommit, 100), 1), firstCommit); + assertNoWriteErrors(insertRdd.collect()); + client.commit(firstCommit, insertRdd); + + // Two upsert commits. Under version SIX the second commit appends to the first's log file + // (logOffset > 0); under version >= EIGHT each commit writes a fresh log file (logOffset == 0). + for (String commitTime : new String[] {"002", "003"}) { + WriteClientTestUtils.startCommitWithTime(client, commitTime); + JavaRDD upsertRdd = client.upsert(jsc().parallelize(dataGen.generateUpdates(commitTime, 50), 1), commitTime); + List statuses = upsertRdd.collect(); + assertNoWriteErrors(statuses); + assertLogFilesProduced(statuses); + client.commit(commitTime, upsertRdd); + // Derived log file size (logOffset + appended bytes) must equal the actual on-disk length + assertFileSizesEqual(statuses, status -> FSUtils.getFileSize( + metaClient.getStorage(), + new StoragePath(config.getBasePath(), status.getStat().getPath()))); + } + } + } + @Test public void testGetAllLogFilesWithMaxCommitEmptyPartitions() throws Exception { HoodieTableMetaClient metaClient = getHoodieMetaClient(HoodieTableType.MERGE_ON_READ, new Properties()); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bloom/TestHoodieBloomFilterProbingResult.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bloom/TestHoodieBloomFilterProbingResult.java new file mode 100644 index 0000000000000..ea4f663d2d494 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bloom/TestHoodieBloomFilterProbingResult.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.hudi.index.bloom; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the {@link HoodieBloomFilterProbingResult} value holder. + */ +public class TestHoodieBloomFilterProbingResult { + + @Test + void exposesCandidateKeys() { + Set keys = new HashSet<>(); + keys.add("k1"); + keys.add("k2"); + HoodieBloomFilterProbingResult result = new HoodieBloomFilterProbingResult(keys); + assertEquals(keys, result.getCandidateKeys()); + } + + @Test + void supportsEmptyCandidateSet() { + HoodieBloomFilterProbingResult result = + new HoodieBloomFilterProbingResult(Collections.emptySet()); + assertTrue(result.getCandidateKeys().isEmpty()); + } + + @Test + void valueSemanticsForEqualsAndHashCode() { + HoodieBloomFilterProbingResult a = + new HoodieBloomFilterProbingResult(new HashSet<>(Collections.singletonList("k"))); + HoodieBloomFilterProbingResult same = + new HoodieBloomFilterProbingResult(new HashSet<>(Collections.singletonList("k"))); + HoodieBloomFilterProbingResult different = + new HoodieBloomFilterProbingResult(new HashSet<>(Collections.singletonList("other"))); + assertEquals(a, same); + assertEquals(a.hashCode(), same.hashCode()); + assertNotEquals(a, different); + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/TestHoodieTimelineArchiver.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/TestHoodieTimelineArchiver.java index 60db8b8a13b75..450a1b1d6b418 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/TestHoodieTimelineArchiver.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/TestHoodieTimelineArchiver.java @@ -940,7 +940,7 @@ private static CompletableFuture allOfTerminateOnFailure(List { if (!jobFailed.getAndSet(true)) { - log.warn("One of the job failed. Cancelling all other futures. " + ex.getCause() + ", " + ex.getMessage()); + log.warn("One of the job failed. Cancelling all other futures. {}, {}", ex.getCause(), ex.getMessage()); int secondCounter = 0; while (secondCounter < futures.size()) { if (secondCounter != finalCounter) { @@ -2356,15 +2356,12 @@ private HoodieWriteConfig buildECTRTestConfig(int minCommits, int maxCommits, bo private void addCleanCommitWithECTR(HoodieTestTable testTable, String cleanInstant, String ectr, String lastCompleted) throws Exception { List cleanStatsList = new ArrayList<>(); - cleanStatsList.add(new HoodieCleanStat( - HoodieCleaningPolicy.KEEP_LATEST_COMMITS, - "p1", - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList(), - ectr, - lastCompleted - )); + cleanStatsList.add(HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_COMMITS) + .withPartitionPath("p1") + .withEarliestCommitToRetain(ectr) + .withLastCompletedCommitTimestamp(lastCompleted) + .build()); HoodieCleanMetadata cleanMetadata = CleanerUtils.convertCleanMetadata(cleanInstant, Option.of(0L), cleanStatsList, Collections.emptyMap()); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java index 0c39e99bdf89a..6ec4a4cd2300f 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java @@ -18,16 +18,21 @@ package org.apache.hudi.io.storage.row; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.testutils.HoodieClientTestBase; +import org.apache.spark.sql.types.Decimal; import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.List; import java.util.TimeZone; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; /** * Coverage for {@link HoodieRowParquetWriteSupport#resolveSessionLocalTimeZone()}. @@ -42,6 +47,36 @@ class TestHoodieRowParquetWriteSupport extends HoodieClientTestBase { private static final String SESSION_LOCAL_TIME_ZONE_KEY = "spark.sql.session.timeZone"; + @Test + void testResolveDecimalByteLength() { + int minWidth = Decimal.minBytesForPrecision()[20]; + // A non-decimal schema falls back to the precision-minimal width. + assertEquals(minWidth, + HoodieRowParquetWriteSupport.resolveDecimalByteLength(HoodieSchema.create(HoodieSchemaType.STRING), 20)); + // A bytes-backed decimal (no declared fixed size) also falls back to the minimum. + assertEquals(minWidth, + HoodieRowParquetWriteSupport.resolveDecimalByteLength(HoodieSchema.createDecimal(20, 2), 20)); + // An Avro fixed decimal wider than the minimum is honored. + assertEquals(10, + HoodieRowParquetWriteSupport.resolveDecimalByteLength( + HoodieSchema.createDecimal("dec", null, null, 20, 2, 10), 20)); + } + + @Test + void testPadDecimalToFixedLength() { + byte[] buffer = new byte[16]; + // Already the full width: returned as-is, no copy into the buffer. + byte[] exact = new byte[] {1, 2, 3, 4}; + assertSame(exact, HoodieRowParquetWriteSupport.padDecimalToFixedLength(exact, 4, buffer)); + // Positive magnitude: left-padded with zero sign bytes. + byte[] positive = HoodieRowParquetWriteSupport.padDecimalToFixedLength(new byte[] {0x12, 0x34}, 4, buffer); + assertArrayEquals(new byte[] {0, 0, 0x12, 0x34}, Arrays.copyOf(positive, 4)); + // Negative magnitude: left-padded with 0xFF sign bytes. + byte[] negative = HoodieRowParquetWriteSupport.padDecimalToFixedLength( + new byte[] {(byte) 0xFF, (byte) 0x80}, 4, buffer); + assertArrayEquals(new byte[] {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x80}, Arrays.copyOf(negative, 4)); + } + @Test void testResolveSessionLocalTimeZoneWithoutOverride() { String expected = TimeZone.getDefault().getID(); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.java new file mode 100644 index 0000000000000..370d715125360 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.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.hudi.metadata; + +import org.apache.hudi.client.BaseHoodieWriteClient; +import org.apache.hudi.client.HoodieWriteResult; +import org.apache.hudi.client.SparkRDDMetadataWriteClient; +import org.apache.hudi.client.SparkRDDWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.model.HoodieFileGroupId; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieNotSupportedException; +import org.apache.hudi.index.HoodieSparkIndexClient; + +import org.apache.spark.api.java.JavaRDD; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +import java.util.Collections; + +import static org.apache.hudi.common.table.timeline.HoodieTimeline.DELTA_COMMIT_ACTION; +import static org.apache.hudi.common.table.timeline.HoodieTimeline.REPLACE_COMMIT_ACTION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests Spark-specific behavior shared by current and table-version-six metadata writers. + */ +class TestSparkHoodieBackedTableMetadataWriter { + + @Test + void exposesSparkEngineAndRejectsV6StreamingConversion() { + // Both implementations use Spark, but version 6 has no streaming conversion. + SparkHoodieBackedTableMetadataWriter currentWriter = + mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + SparkHoodieBackedTableMetadataWriterTableVersionSix versionSixWriter = + mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + + assertEquals(EngineType.SPARK, currentWriter.getEngineType()); + assertEquals(EngineType.SPARK, versionSixWriter.getEngineType()); + assertThrows(HoodieNotSupportedException.class, + () -> versionSixWriter.convertEngineSpecificDataToHoodieData(null)); + } + + @Test + void versionSixFileGroupUpsertCommitsWriteStatuses() { + // Version 6 file-group writes use a prepped-record upsert and delta commit. + SparkHoodieBackedTableMetadataWriterTableVersionSix writer = + mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + BaseHoodieWriteClient, ?, JavaRDD> writeClient = + mock(BaseHoodieWriteClient.class); + JavaRDD records = mock(JavaRDD.class); + JavaRDD writeStatuses = mock(JavaRDD.class); + when(writeClient.upsertPreppedRecords(records, "001")).thenReturn(writeStatuses); + + writer.upsertAndCommit(writeClient, "001", records, Collections.emptyList()); + + verify(writeClient).commit( + "001", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, Collections.emptyMap()); + } + + @Test + void currentFileGroupUpsertCommitsFirstUpsertStatuses() { + // Current writer uses first-upsert semantics when file groups are supplied. + SparkHoodieBackedTableMetadataWriter writer = + mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + SparkRDDMetadataWriteClient writeClient = mock(SparkRDDMetadataWriteClient.class); + JavaRDD records = mock(JavaRDD.class); + JavaRDD writeStatuses = mock(JavaRDD.class); + java.util.List fileGroups = Collections.emptyList(); + // The writer resolves its internal client even when one is passed in. + doReturn(writeClient).when(writer).getWriteClient(); + when(writeClient.firstUpsertPreppedRecords(records, "003", fileGroups)).thenReturn(writeStatuses); + + writer.upsertAndCommit(writeClient, "003", records, fileGroups); + + verify(writeClient).commit( + "003", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, Collections.emptyMap()); + } + + @Test + void currentUpsertCoalescesRecordPreparationInput() { + // Record preparation honors its configured parallelism before writing. + SparkHoodieBackedTableMetadataWriter writer = + mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + HoodieMetadataConfig metadataConfig = mock(HoodieMetadataConfig.class); + HoodieWriteConfig dataWriteConfig = mock(HoodieWriteConfig.class); + when(dataWriteConfig.getMetadataConfig()).thenReturn(metadataConfig); + when(metadataConfig.getRecordPreparationParallelism()).thenReturn(1); + writer.dataWriteConfig = dataWriteConfig; + + BaseHoodieWriteClient, ?, JavaRDD> writeClient = + mock(BaseHoodieWriteClient.class); + JavaRDD records = mock(JavaRDD.class); + JavaRDD coalescedRecords = mock(JavaRDD.class); + JavaRDD writeStatuses = mock(JavaRDD.class); + when(records.getNumPartitions()).thenReturn(2); + when(records.coalesce(1)).thenReturn(coalescedRecords); + when(writeClient.upsertPreppedRecords(coalescedRecords, "004")).thenReturn(writeStatuses); + + writer.upsertAndCommit(writeClient, "004", records); + + verify(writeClient).commit( + "004", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, Collections.emptyMap()); + } + + @Test + void bothSparkWritersUpdateColumnStatsDefinition() { + // Both writer versions delegate column-stat definitions to the Spark index client. + SparkHoodieBackedTableMetadataWriter currentWriter = + mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS); + SparkHoodieBackedTableMetadataWriterTableVersionSix versionSixWriter = + mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + java.util.List columns = Collections.singletonList("rider"); + + // Capture index clients created internally by both writer versions. + try (MockedConstruction construction = + mockConstruction(HoodieSparkIndexClient.class)) { + currentWriter.updateColumnsToIndexWithColStats(columns); + versionSixWriter.updateColumnsToIndexWithColStats(columns); + + assertEquals(2, construction.constructed().size()); + verify(construction.constructed().get(0)) + .createOrUpdateColumnStatsIndexDefinition(null, columns); + verify(construction.constructed().get(1)) + .createOrUpdateColumnStatsIndexDefinition(null, columns); + } + } + + @Test + void versionSixDeletePartitionsCommitsReplaceCommit() { + // Deleting an MDT partition is committed as a replace commit. + SparkHoodieBackedTableMetadataWriterTableVersionSix writer = + mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS); + SparkRDDWriteClient writeClient = mock(SparkRDDWriteClient.class); + HoodieWriteResult writeResult = mock(HoodieWriteResult.class); + JavaRDD writeStatuses = mock(JavaRDD.class); + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + writer.metadataMetaClient = metadataMetaClient; + doReturn(writeClient).when(writer).getWriteClient(); + when(writeResult.getWriteStatuses()).thenReturn(writeStatuses); + when(writeResult.getPartitionToReplaceFileIds()).thenReturn(Collections.emptyMap()); + when(writeClient.deletePartitions( + Collections.singletonList(MetadataPartitionType.RECORD_INDEX.getPartitionPath()), "002")) + .thenReturn(writeResult); + + writer.deletePartitions("002", Collections.singletonList(MetadataPartitionType.RECORD_INDEX)); + + verify(writeClient).startCommitForMetadataTable(eq(metadataMetaClient), eq("002"), any()); + verify(writeClient).commit( + "002", writeStatuses, Option.empty(), REPLACE_COMMIT_ACTION, Collections.emptyMap()); + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/stats/TestSparkValueMetadataUtils.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/stats/TestSparkValueMetadataUtils.java new file mode 100644 index 0000000000000..45f6ad3dd3fa8 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/stats/TestSparkValueMetadataUtils.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.hudi.stats; + +import org.apache.hudi.metadata.HoodieIndexVersion; + +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.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit coverage for the Spark value-metadata conversion helpers. Exercises the + * Spark-type to {@link ValueType} matrix, decimal precision/scale carry-through, + * and the value conversion round-trips using only in-process objects. + */ +class TestSparkValueMetadataUtils { + + private static ValueMetadata metadataFor(DataType dataType) { + return SparkValueMetadataUtils.getValueMetadata(dataType, HoodieIndexVersion.V2); + } + + private static Stream dataTypeToValueType() { + return Stream.of( + Arguments.of(DataTypes.BooleanType, ValueType.BOOLEAN), + Arguments.of(DataTypes.IntegerType, ValueType.INT), + Arguments.of(DataTypes.ShortType, ValueType.INT), + Arguments.of(DataTypes.ByteType, ValueType.INT), + Arguments.of(DataTypes.LongType, ValueType.LONG), + Arguments.of(DataTypes.FloatType, ValueType.FLOAT), + Arguments.of(DataTypes.DoubleType, ValueType.DOUBLE), + Arguments.of(DataTypes.StringType, ValueType.STRING), + Arguments.of(DataTypes.TimestampType, ValueType.TIMESTAMP_MICROS), + Arguments.of(DataTypes.DateType, ValueType.DATE), + Arguments.of(DataTypes.BinaryType, ValueType.BYTES), + Arguments.of(DataTypes.NullType, ValueType.NULL)); + } + + @ParameterizedTest + @MethodSource("dataTypeToValueType") + void getValueMetadataMapsSparkTypeToValueType(DataType dataType, ValueType expected) { + ValueMetadata metadata = SparkValueMetadataUtils.getValueMetadata(dataType, HoodieIndexVersion.V2); + assertEquals(expected, metadata.getValueType(), + "Spark type " + dataType.typeName() + " must map to value type " + expected); + } + + @Test + void getValueMetadataCarriesDecimalPrecisionAndScale() { + DecimalType decimalType = new DecimalType(12, 4); + ValueMetadata metadata = SparkValueMetadataUtils.getValueMetadata(decimalType, HoodieIndexVersion.V2); + + assertEquals(ValueType.DECIMAL, metadata.getValueType()); + assertEquals("12,4", metadata.getValueTypeInfo().getAdditionalInfo(), + "precision and scale must be encoded in the value type info"); + } + + @Test + void getValueMetadataReturnsV1EmptyBelowV2() { + // Any index version lower than V2 is unversioned column stats and yields the shared empty metadata. + ValueMetadata metadata = SparkValueMetadataUtils.getValueMetadata(DataTypes.IntegerType, HoodieIndexVersion.V1); + assertSame(ValueMetadata.V1EmptyMetadata.get(), metadata, + "index versions below V2 must return the shared V1 empty metadata"); + assertTrue(metadata.isV1()); + } + + @Test + void getValueMetadataReturnsNullMetadataForNullType() { + // A null Spark data type is distinct from NullType and maps to the shared NULL metadata singleton. + ValueMetadata metadata = SparkValueMetadataUtils.getValueMetadata(null, HoodieIndexVersion.V2); + assertSame(ValueMetadata.NULL_METADATA, metadata); + assertEquals(ValueType.NULL, metadata.getValueType()); + } + + @Test + void convertSparkToJavaReturnsNullForNullInput() { + ValueMetadata metadata = metadataFor(DataTypes.IntegerType); + assertNull(SparkValueMetadataUtils.convertSparkToJava(metadata, null)); + } + + @Test + void convertSparkToJavaPassesThroughPrimitives() { + assertEquals(Boolean.TRUE, + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.BooleanType), true)); + assertEquals(42, + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.IntegerType), 42)); + assertEquals(42L, + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.LongType), 42L)); + assertEquals(1.5f, + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.FloatType), 1.5f)); + assertEquals(2.5d, + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.DoubleType), 2.5d)); + assertEquals("hudi", + SparkValueMetadataUtils.convertSparkToJava(metadataFor(DataTypes.StringType), "hudi")); + } + + @Test + void convertSparkToJavaHandlesDecimal() { + ValueMetadata metadata = metadataFor(new DecimalType(10, 2)); + Decimal sparkDecimal = Decimal.apply(new BigDecimal("123.45")); + Comparable result = SparkValueMetadataUtils.convertSparkToJava(metadata, sparkDecimal); + assertEquals(new BigDecimal("123.45"), result, "decimal must convert to a java BigDecimal of equal value"); + } + + @Test + void convertSparkToJavaHandlesBytes() { + ValueMetadata metadata = metadataFor(DataTypes.BinaryType); + byte[] input = new byte[] {1, 2, 3, 4}; + Comparable result = SparkValueMetadataUtils.convertSparkToJava(metadata, input); + assertTrue(result instanceof ByteBuffer, "bytes must convert to a ByteBuffer"); + ByteBuffer buffer = (ByteBuffer) result; + byte[] roundTripped = new byte[buffer.remaining()]; + buffer.get(roundTripped); + assertArrayEquals(input, roundTripped, + "byte content must survive the conversion"); + } + + @Test + void convertSparkToJavaHandlesDateFromEpochDays() { + ValueMetadata metadata = metadataFor(DataTypes.DateType); + // Spark stores dates internally as epoch-day integers. + int epochDays = (int) LocalDate.of(2021, 3, 15).toEpochDay(); + Comparable result = SparkValueMetadataUtils.convertSparkToJava(metadata, epochDays); + assertEquals(LocalDate.of(2021, 3, 15), result, "epoch-day int must convert to the matching LocalDate"); + } + + @Test + void convertSparkToJavaHandlesTimestampMicros() { + ValueMetadata metadata = metadataFor(DataTypes.TimestampType); + Instant expected = Instant.ofEpochSecond(1_600_000_000L); + long micros = expected.getEpochSecond() * 1_000_000L; + Comparable result = SparkValueMetadataUtils.convertSparkToJava(metadata, micros); + assertEquals(expected, result, "micros-since-epoch must convert to the matching Instant"); + } + + @Test + void convertJavaTypeToSparkTypeConvertsInstantWhenLegacyApi() { + Instant instant = Instant.ofEpochSecond(1_600_000_000L); + // With the java8 API disabled Spark expects java.sql.Timestamp for timestamp values. + Object legacy = SparkValueMetadataUtils.convertJavaTypeToSparkType(instant, false); + assertEquals(Timestamp.from(instant), legacy); + + // With the java8 API enabled the Instant is passed through untouched. + assertSame(instant, SparkValueMetadataUtils.convertJavaTypeToSparkType(instant, true)); + } + + @Test + void convertJavaTypeToSparkTypeConvertsLocalDateWhenLegacyApi() { + LocalDate date = LocalDate.of(2022, 6, 1); + Object legacy = SparkValueMetadataUtils.convertJavaTypeToSparkType(date, false); + assertEquals(Date.valueOf(date), legacy); + + assertSame(date, SparkValueMetadataUtils.convertJavaTypeToSparkType(date, true)); + } + + @Test + void convertJavaTypeToSparkTypeLeavesOtherTypesUntouched() { + assertSame("plain", SparkValueMetadataUtils.convertJavaTypeToSparkType("plain", false)); + Integer value = 7; + assertSame(value, SparkValueMetadataUtils.convertJavaTypeToSparkType(value, false)); + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestCleaner.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestCleaner.java index f57ee82385f6c..dde8ac5729b39 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestCleaner.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestCleaner.java @@ -724,18 +724,30 @@ public void testCleanMetadataUpgradeDowngrade() { List failedDeleteFiles1 = Collections.singletonList(filePath2); // create partition1 clean stat. - HoodieCleanStat cleanStat1 = new HoodieCleanStat(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS, - partition1, deletePathPatterns1, successDeleteFiles1, - failedDeleteFiles1, instantTime, ""); + HoodieCleanStat cleanStat1 = HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS) + .withPartitionPath(partition1) + .withDeletePathPatterns(deletePathPatterns1) + .withSuccessDeleteFiles(successDeleteFiles1) + .withFailedDeleteFiles(failedDeleteFiles1) + .withEarliestCommitToRetain(instantTime) + .withLastCompletedCommitTimestamp("") + .build(); List deletePathPatterns2 = new ArrayList<>(); List successDeleteFiles2 = new ArrayList<>(); List failedDeleteFiles2 = new ArrayList<>(); // create partition2 empty clean stat. - HoodieCleanStat cleanStat2 = new HoodieCleanStat(HoodieCleaningPolicy.KEEP_LATEST_COMMITS, - partition2, deletePathPatterns2, successDeleteFiles2, - failedDeleteFiles2, instantTime, ""); + HoodieCleanStat cleanStat2 = HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_COMMITS) + .withPartitionPath(partition2) + .withDeletePathPatterns(deletePathPatterns2) + .withSuccessDeleteFiles(successDeleteFiles2) + .withFailedDeleteFiles(failedDeleteFiles2) + .withEarliestCommitToRetain(instantTime) + .withLastCompletedCommitTimestamp("") + .build(); // map with absolute file path. Map oldExpected = new HashMap<>(); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestHoodieSparkTable.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestHoodieSparkTable.java index e50bb9e5ecb71..9808de708374d 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestHoodieSparkTable.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/TestHoodieSparkTable.java @@ -107,7 +107,7 @@ public void testDeleteFailureDuringMarkerReconciliation(DeleteFailureType failur // lets create the data file. so that we can validate later. localStorage.create(storagePath); } catch (IOException e) { - throw new HoodieException("Failed to check data file existance " + fileName); + throw new HoodieException("Failed to check data file existence " + fileName); } }); HoodieTable hoodieTable = HoodieSparkTable.create(writeConfig, getEngineContext(), metaClient); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/clean/TestCleanerInsertAndCleanByVersions.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/clean/TestCleanerInsertAndCleanByVersions.java index 583135830d5ba..aa26921c174f8 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/clean/TestCleanerInsertAndCleanByVersions.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/clean/TestCleanerInsertAndCleanByVersions.java @@ -133,6 +133,10 @@ private void testInsertAndCleanByVersions( .withBulkInsertParallelism(PARALLELISM) .withFinalizeWriteParallelism(PARALLELISM) .withDeleteParallelism(PARALLELISM) + // #17714: enabling the consistency check makes this test slow on macOS local file://. The cleaner's + // getFileStatus calls the guard's waitTillFileAppears, which misses the translated path and burns the + // full ~25.2s backoff per deleted file. Does NOT reproduce on CI (Linux), where the path resolves and + // it stays fast, so this gotcha is only visible locally. .withConsistencyGuardConfig(ConsistencyGuardConfig.newBuilder().withConsistencyCheckEnabled(true).build()) .build(); try (final SparkRDDWriteClient client = getHoodieWriteClient(cfg)) { diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java new file mode 100644 index 0000000000000..955a8160c264c --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestInsertOverwriteWithClustering.java @@ -0,0 +1,834 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.action.commit; + +import org.apache.hudi.client.HoodieWriteResult; +import org.apache.hudi.client.SparkRDDWriteClient; +import org.apache.hudi.client.WriteClientTestUtils; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.client.clustering.plan.strategy.SparkSingleFileSortPlanStrategy; +import org.apache.hudi.client.clustering.run.strategy.SparkSingleFileSortExecutionStrategy; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.model.HoodieFileGroupId; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieReplaceCommitMetadata; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.testutils.HoodieTestDataGenerator; +import org.apache.hudi.common.util.ClusteringUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieClusteringConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.data.HoodieJavaRDD; +import org.apache.hudi.exception.HoodieUpsertException; +import org.apache.hudi.table.HoodieSparkTable; +import org.apache.hudi.table.HoodieTable; +import org.apache.hudi.testutils.HoodieClientTestBase; + +import org.apache.spark.api.java.JavaRDD; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for INSERT_OVERWRITE, INSERT_OVERWRITE_TABLE, and DELETE_PARTITION operations + * when there are pending clustering operations on the file groups being replaced. + */ +public class TestInsertOverwriteWithClustering extends HoodieClientTestBase { + + private HoodieTestDataGenerator dataGen; + + @BeforeEach + public void setUp() throws Exception { + initPath(); + initSparkContexts(); + initTestDataGenerator(); + initMetaClient(HoodieTableType.COPY_ON_WRITE); + dataGen = new HoodieTestDataGenerator(); + } + + @AfterEach + public void tearDown() throws Exception { + cleanupResources(); + } + + private HoodieClusteringConfig.Builder baseClusteringConfigBuilder(boolean rollbackPendingClustering) { + return HoodieClusteringConfig.newBuilder() + .withClusteringPlanStrategyClass(SparkSingleFileSortPlanStrategy.class.getName()) + .withClusteringExecutionStrategyClass(SparkSingleFileSortExecutionStrategy.class.getName()) + .withClusteringMaxNumGroups(10) + .withRollbackPendingClustering(rollbackPendingClustering); + } + + private HoodieWriteConfig.Builder getConfigBuilder(boolean rollbackPendingClustering) { + return HoodieWriteConfig.newBuilder() + .withPath(basePath) + .withSchema(TRIP_EXAMPLE_SCHEMA) + .withParallelism(2, 2) + .withBulkInsertParallelism(2) + .withFinalizeWriteParallelism(2) + .withDeleteParallelism(2) + .withRollbackParallelism(2) + .withClusteringConfig(baseClusteringConfigBuilder(rollbackPendingClustering).build()); + } + + private HoodieWriteConfig.Builder getConfigBuilderWithPartitionFilter(boolean rollbackPendingClustering, String partitionFilter) { + return HoodieWriteConfig.newBuilder() + .withPath(basePath) + .withSchema(TRIP_EXAMPLE_SCHEMA) + .withParallelism(2, 2) + .withBulkInsertParallelism(2) + .withFinalizeWriteParallelism(2) + .withDeleteParallelism(2) + .withRollbackParallelism(2) + .withClusteringConfig(baseClusteringConfigBuilder(rollbackPendingClustering) + .withClusteringPartitionSelected(partitionFilter) + .build()); + } + + /** + * Test that INSERT_OVERWRITE operation throws an exception when file groups + * to be replaced conflict with pending clustering. + */ + @Test + public void testStaticInsertOverwriteWithPendingClusteringRejectsUpdate() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Initial insert to create some data + String instant1 = nextInstant(); + List records1 = dataGen.generateInserts(instant1, 100); + JavaRDD writeRecords1 = jsc.parallelize(records1, 2); + commitInsert(client, writeRecords1, instant1); + + // Get partition path from the first record + String partitionPath = records1.get(0).getPartitionPath(); + + // Step 2: Schedule clustering for the partition + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline() + .filterPendingClusteringTimeline(); + assertEquals(1, pendingReplaceTimeline.countInstants()); + + // Get file groups involved in pending clustering + Set pendingClusteringFileGroups = ClusteringUtils + .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet(); + assertFalse(pendingClusteringFileGroups.isEmpty()); + + // Step 3: Perform static INSERT_OVERWRITE on the same partition + // This should throw HoodieUpsertException because file groups to be replaced + // conflict with pending clustering + String instant3 = nextInstant(); + List records3 = dataGen.generateInsertsForPartition(instant3, 50, partitionPath); + JavaRDD writeRecords3 = jsc.parallelize(records3, 2); + + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieUpsertException upsertException = assertThrows(HoodieUpsertException.class, () -> + commitInsertOverwrite(client, writeRecords3, instant3) + ); + assertTrue(upsertException.getCause().getMessage().contains("Not allowed to update the clustering file group")); + + // Verify clustering is still pending (NOT rolled back) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertEquals(1, pendingReplaceTimeline.countInstants(), + "Pending clustering should remain pending after failed INSERT_OVERWRITE"); + assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant)); + + // Verify the INSERT_OVERWRITE did NOT complete + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertFalse(completedReplaceTimeline.containsInstant(instant3)); + } + + /** + * Test that dynamic INSERT_OVERWRITE_TABLE operation gets aborted when it overlaps w/ pending clustering. + */ + @Test + public void testDynamicInsertOverwriteWithPendingClustering() throws Exception { + HoodieWriteConfig config = getConfigBuilder(true).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Initial insert to create data in multiple partitions + String instant1 = nextInstant(); + List records1 = dataGen.generateInserts(instant1, 100); + JavaRDD writeRecords1 = jsc.parallelize(records1, 2); + commitInsert(client, writeRecords1, instant1); + + // Get partitions that have data + Set partitionsWithData = records1.stream() + .map(HoodieRecord::getPartitionPath) + .collect(Collectors.toSet()); + + // Step 2: Schedule clustering + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants()); + + // Get file groups involved in pending clustering + Set pendingClusteringFileGroups = ClusteringUtils + .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet(); + assertFalse(pendingClusteringFileGroups.isEmpty()); + + // Step 3: Perform dynamic INSERT_OVERWRITE_TABLE on overlapping partitions + String instant3 = nextInstant(); + String targetPartition = partitionsWithData.iterator().next(); + List records3 = dataGen.generateInsertsForPartition(instant3, 50, targetPartition); + JavaRDD writeRecords3 = jsc.parallelize(records3, 2); + HoodieUpsertException upsertException = assertThrows(HoodieUpsertException.class, () -> + commitInsertOverwrite(client, writeRecords3, instant3) + ); + assertTrue(upsertException.getCause().getMessage().contains("Not allowed to update the clustering file group")); + + // Verify clustering was never rolled back. + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant), + "Pending clustering should not be rolled back"); + + // Verify the INSERT_OVERWRITE did NOT complete + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertFalse(completedReplaceTimeline.containsInstant(instant3)); + } + + /** + * Test that DELETE_PARTITION operation succeeds when pending clustering does not overlap. + */ + @Test + public void testDeletePartitionWithNonOverlappingPendingClustering() throws Exception { + HoodieWriteConfig config = getConfigBuilderWithPartitionFilter(false, "partition1").build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: insert into partition1 + String instant1 = nextInstant(); + List records1 = dataGen.generateInsertsForPartition(instant1, 100, "partition1"); + JavaRDD writeRecords1 = jsc.parallelize(records1, 2); + commitInsert(client, writeRecords1, instant1); + + String instant2 = nextInstant(); + List records2 = dataGen.generateInsertsForPartition(instant2, 100, "partition2"); + JavaRDD writeRecords2 = jsc.parallelize(records2, 2); + commitInsert(client, writeRecords2, instant2); + + // Step 3: Schedule clustering for the partition1 + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants()); + + // Get file groups involved in pending clustering + Set pendingClusteringFileGroups = ClusteringUtils + .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet(); + assertFalse(pendingClusteringFileGroups.isEmpty()); + + client.close(); + SparkRDDWriteClient client2 = getHoodieWriteClient(config); + + // Step 3: Delete the partition2 + String instant3 = nextInstant(); + commitDeletePartitions(client2, Arrays.asList("partition2"), instant3); + + // Verify clustering is still pending (NOT rolled back) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant), + "Pending clustering should remain pending after successful DELETE_PARTITION"); + + // Verify the DELETE_PARTITION is completed + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertTrue(completedReplaceTimeline.containsInstant(instant3)); + } + + /** + * Test getFileGroupsBeingReplaced method in SparkInsertOverwriteCommitActionExecutor + * for static INSERT_OVERWRITE scenario. + */ + @Test + public void testGetFileGroupsBeingReplacedForStaticOverwrite() throws Exception { + HoodieWriteConfig config = getConfigBuilder(true).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Initial insert to create some data + String instant1 = nextInstant(); + String partitionPath = "2023/01/01"; + List records1 = dataGen.generateInsertsForPartition(instant1, 100, partitionPath); + JavaRDD writeRecords1 = jsc.parallelize(records1, 2); + commitInsert(client, writeRecords1, instant1); + + // Step 2: Get the file groups in the partition + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTable table = HoodieSparkTable.create(config, context, metaClient); + List existingFileIds = table.getSliceView() + .getLatestFileSlices(partitionPath) + .map(fileSlice -> fileSlice.getFileId()) + .collect(Collectors.toList()); + assertFalse(existingFileIds.isEmpty(), "Should have at least one file group"); + + // Step 3: Create INSERT_OVERWRITE executor and test getFileGroupsBeingReplaced + String instant2 = nextInstant(); + List records = dataGen.generateInsertsForPartition(instant2, 10, partitionPath); + HoodieData inputRecords = HoodieJavaRDD.of(jsc.parallelize(records, 1)); + + SparkInsertOverwriteCommitActionExecutor executor = + new SparkInsertOverwriteCommitActionExecutor( + context, config, table, instant2, inputRecords); + + // Invoke the method - it's protected so we test it indirectly through the workflow + Set fileGroupsBeingReplaced = executor.getFileGroupsBeingReplaced(inputRecords); + + // Verify that the file groups in the partition are identified as being replaced + assertEquals(existingFileIds.size(), fileGroupsBeingReplaced.size(), + "Should identify all existing file groups in the partition as being replaced"); + assertTrue(fileGroupsBeingReplaced.stream() + .allMatch(fg -> fg.getPartitionPath().equals(partitionPath)), + "All identified file groups should be in the target partition"); + } + + /** + * Test that INSERT_OVERWRITE on a non-overlapping partition succeeds + * even when there is pending clustering on a different partition. + */ + @Test + public void testInsertOverwriteNonOverlappingPartitionWithPendingClustering() throws Exception { + HoodieWriteConfig config = getConfigBuilder(true) + .withClusteringConfig(HoodieClusteringConfig.newBuilder() + .withClusteringPlanStrategyClass(SparkSingleFileSortPlanStrategy.class.getName()) + .withClusteringExecutionStrategyClass(SparkSingleFileSortExecutionStrategy.class.getName()) + .withClusteringMaxNumGroups(10) + .withRollbackPendingClustering(true) + .withClusteringPartitionSelected("partition1") + .build()) + .build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into partition1 + String instant1 = nextInstant(); + List records1 = dataGen.generateInsertsForPartition(instant1, 100, "partition1"); + JavaRDD writeRecords1 = jsc.parallelize(records1, 2); + commitInsert(client, writeRecords1, instant1); + + String instant2 = nextInstant(); + List records2 = dataGen.generateInsertsForPartition(instant1, 100, "partition2"); + JavaRDD writeRecords2 = jsc.parallelize(records2, 2); + commitInsert(client, writeRecords2, instant2); + + // Step 2: Schedule clustering for partition1 + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants()); + + // Step 3: Perform INSERT_OVERWRITE on partition2 (non-overlapping) + String instant3 = nextInstant(); + List records3 = dataGen.generateInsertsForPartition(instant3, 50, "partition2"); + JavaRDD writeRecords3 = jsc.parallelize(records3, 2); + commitInsertOverwrite(client, writeRecords3, instant3); + + // Verify clustering was NOT rolled back (no overlap) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertEquals(1, pendingReplaceTimeline.countInstants(), + "Pending clustering should NOT be rolled back for non-overlapping partition"); + + // Verify the INSERT_OVERWRITE completed successfully + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertTrue(completedReplaceTimeline.containsInstant(instant3)); + } + + /** + * Test dynamic INSERT_OVERWRITE that determines partitions from input records. + * If input records target a partition with pending clustering, it should detect the conflict and abort. + */ + @Test + public void testDynamicInsertOverwriteDetectsOverlap() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into multiple partitions + String instant1 = nextInstant(); + List partition1Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01"); + List partition2Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02"); + List partition3Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/03"); + List allRecords = new ArrayList<>(); + allRecords.addAll(partition1Records); + allRecords.addAll(partition2Records); + allRecords.addAll(partition3Records); + JavaRDD writeRecords1 = jsc.parallelize(allRecords, 2); + commitInsert(client, writeRecords1, instant1); + + // Step 2: Schedule clustering + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants()); + + // Get partitions in clustering + Set clusteringFileGroups = ClusteringUtils + .getAllFileGroupsInPendingClusteringPlans(metaClient).keySet(); + Set clusteringPartitions = clusteringFileGroups.stream() + .map(HoodieFileGroupId::getPartitionPath) + .collect(Collectors.toSet()); + assertFalse(clusteringPartitions.isEmpty()); + + // Step 3: Perform dynamic INSERT_OVERWRITE_TABLE with records in overlapping partition + // This should fail + metaClient = HoodieTableMetaClient.reload(this.metaClient); + String instant3 = nextInstant(); + String targetPartition = clusteringPartitions.iterator().next(); + List records3 = dataGen.generateInsertsForPartition(instant3, 50, targetPartition); + JavaRDD writeRecords3 = jsc.parallelize(records3, 2); + HoodieUpsertException upsertException = assertThrows(HoodieUpsertException.class, () -> + commitInsertOverwrite(client, writeRecords3, instant3) + ); + assertTrue(upsertException.getCause().getMessage().contains("Not allowed to update the clustering file group")); + + // Verify clustering is still pending (NOT rolled back) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertEquals(1, pendingReplaceTimeline.countInstants(), + "Pending clustering should remain pending after failed INSERT_OVERWRITE_TABLE"); + assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant)); + + // Verify the INSERT_OVERWRITE_TABLE did NOT complete + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertFalse(completedReplaceTimeline.containsInstant(instant3)); + } + + /** + * Test multiple concurrent INSERT_OVERWRITE operations on different partitions + * with one partition having pending clustering. + */ + @Test + public void testMultipleInsertOverwriteWithSelectiveOverlap() throws Exception { + HoodieWriteConfig config = getConfigBuilderWithPartitionFilter(false,"2023/01/01,2023/01/02").build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into three partitions + String instant1 = nextInstant(); + List partition1Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01"); + List partition2Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02"); + List partition3Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/03"); + List allRecords = new ArrayList<>(); + allRecords.addAll(partition1Records); + allRecords.addAll(partition2Records); + allRecords.addAll(partition3Records); + JavaRDD writeRecords1 = jsc.parallelize(allRecords, 2); + commitInsert(client, writeRecords1, instant1); + + // Step 2: Schedule clustering (will cluster partition1 and partition2) + Option clusteringInstantOpt = client.scheduleClustering(Option.empty()); + assertTrue(clusteringInstantOpt.isPresent(), "Expected clustering to be scheduled but returned empty"); + String clusteringInstant = clusteringInstantOpt.get(); + + // Verify clustering is pending + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants()); + + client.close(); + SparkRDDWriteClient client2 = getHoodieWriteClient(config); + + // Step 3: Perform INSERT_OVERWRITE on partition3 (no overlap) - should succeed without rollback + String instant3 = nextInstant(); + List records3 = dataGen.generateInsertsForPartition(instant3, 50, "2023/01/03"); + JavaRDD writeRecords3 = jsc.parallelize(records3, 2); + commitInsertOverwrite(client2, writeRecords3, instant3); + + // Verify clustering is still pending (no rollback for non-overlapping partition) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + assertEquals(1, metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants(), + "Clustering should still be pending after INSERT_OVERWRITE on non-overlapping partition"); + + client2.close(); + SparkRDDWriteClient client3 = getHoodieWriteClient(config); + // Step 4: Now perform INSERT_OVERWRITE on partition1 (with overlap) - should fail + String instant4 = nextInstant(); + List records4 = dataGen.generateInsertsForPartition(instant4, 50, "2023/01/01"); + JavaRDD writeRecords4 = jsc.parallelize(records4, 2); + HoodieUpsertException upsertException = assertThrows(HoodieUpsertException.class, () -> + commitInsertOverwrite(client3, writeRecords4, instant4) + ); + assertTrue(upsertException.getCause().getMessage().contains("Not allowed to update the clustering file group")); + + // Verify clustering is still pending (NOT rolled back) + metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTimeline pendingReplaceTimeline = metaClient.getActiveTimeline().filterPendingClusteringTimeline(); + assertTrue(pendingReplaceTimeline.containsInstant(clusteringInstant), "Pending clustering should remain pending after failed INSERT_OVERWRITE"); + + // Verify the INSERT_OVERWRITE did NOT complete + HoodieTimeline completedReplaceTimeline = metaClient.getCommitTimeline() + .filterCompletedInstants(); + assertFalse(completedReplaceTimeline.containsInstant(instant4)); + } + + /** + * Test getPartitionToReplacedFileIds for static INSERT_OVERWRITE. + * Static overwrite uses configured partition paths, not input records. + */ + @Test + public void testGetPartitionToReplacedFileIdsForStaticOverwrite() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into multiple partitions + String instant1 = nextInstant(); + List partition1Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/01"); + List partition2Records = dataGen.generateInsertsForPartition(instant1, 100, "2023/01/02"); + List allRecords = new ArrayList<>(); + allRecords.addAll(partition1Records); + allRecords.addAll(partition2Records); + commitInsert(client, jsc.parallelize(allRecords, 2), instant1); + + // Insert more data to create multiple file groups per partition + String instant2 = nextInstant(); + List moreRecords1 = dataGen.generateInsertsForPartition(instant2, 50, "2023/01/01"); + List moreRecords2 = dataGen.generateInsertsForPartition(instant2, 50, "2023/01/02"); + List moreRecords = new ArrayList<>(); + moreRecords.addAll(moreRecords1); + moreRecords.addAll(moreRecords2); + commitInsert(client, jsc.parallelize(moreRecords, 2), instant2); + + // Get existing file IDs before overwrite + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTable table = HoodieSparkTable.create(config, context, metaClient); + List partition1FileIds = table.getSliceView() + .getLatestFileSlices("2023/01/01") + .map(slice -> slice.getFileId()) + .collect(Collectors.toList()); + List partition2FileIds = table.getSliceView() + .getLatestFileSlices("2023/01/02") + .map(slice -> slice.getFileId()) + .collect(Collectors.toList()); + + assertFalse(partition1FileIds.isEmpty(), "Partition 2023/01/01 should have file groups"); + assertFalse(partition2FileIds.isEmpty(), "Partition 2023/01/02 should have file groups"); + + // Step 2: Perform static INSERT_OVERWRITE on partition 2023/01/01 only + String instant3 = nextInstant(); + List overwriteRecords = dataGen.generateInsertsForPartition(instant3, 30, "2023/01/01"); + commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), instant3); + + // Step 3: Verify replaced file IDs - should only include partition 2023/01/01 + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieInstant instant3Instant = metaClient.getActiveTimeline().filterCompletedInstants() + .filter(i -> i.requestedTime().equals(instant3)).firstInstant().get(); + HoodieReplaceCommitMetadata replaceMetadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(instant3Instant); + + Map> partitionToReplacedFileIds = replaceMetadata.getPartitionToReplaceFileIds(); + + // Verify partition 2023/01/01 is in replaced file IDs + assertTrue(partitionToReplacedFileIds.containsKey("2023/01/01"), + "Partition 2023/01/01 should be in replaced file IDs"); + + // Verify all file IDs from partition 2023/01/01 are marked as replaced + List replacedFileIds = partitionToReplacedFileIds.get("2023/01/01"); + assertEquals(partition1FileIds.size(), replacedFileIds.size(), + "All file IDs from partition 2023/01/01 should be marked as replaced"); + assertTrue(replacedFileIds.containsAll(partition1FileIds), + "Replaced file IDs should match original file IDs in partition 2023/01/01"); + + // Verify partition 2023/01/02 is NOT in replaced file IDs (was not overwritten) + assertFalse(partitionToReplacedFileIds.containsKey("2023/01/02"), + "Partition 2023/01/02 should NOT be in replaced file IDs"); + } + + /** + * Test getPartitionToReplacedFileIds for dynamic INSERT_OVERWRITE. + * Dynamic overwrite determines partitions from input records. + */ + @Test + public void testGetPartitionToReplacedFileIdsForDynamicOverwrite() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into three partitions + String instant1 = nextInstant(); + List partition1Records = dataGen.generateInsertsForPartition(instant1, 50, "2023/01/01"); + List partition2Records = dataGen.generateInsertsForPartition(instant1, 50, "2023/01/02"); + List partition3Records = dataGen.generateInsertsForPartition(instant1, 50, "2023/01/03"); + List allRecords = new ArrayList<>(); + allRecords.addAll(partition1Records); + allRecords.addAll(partition2Records); + allRecords.addAll(partition3Records); + commitInsert(client, jsc.parallelize(allRecords, 2), instant1); + + // Get existing file IDs + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTable table = HoodieSparkTable.create(config, context, metaClient); + List partition1FileIds = table.getSliceView() + .getLatestFileSlices("2023/01/01") + .map(slice -> slice.getFileId()) + .collect(Collectors.toList()); + List partition2FileIds = table.getSliceView() + .getLatestFileSlices("2023/01/02") + .map(slice -> slice.getFileId()) + .collect(Collectors.toList()); + + // Step 2: Perform dynamic INSERT_OVERWRITE with records for partitions 01/01 and 01/02 + String instant2 = nextInstant(); + List overwriteRecords = new ArrayList<>(); + overwriteRecords.addAll(dataGen.generateInsertsForPartition(instant2, 30, "2023/01/01")); + overwriteRecords.addAll(dataGen.generateInsertsForPartition(instant2, 30, "2023/01/02")); + commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), instant2); + + // Step 3: Verify replaced file IDs include both partitions + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieInstant instant2Instant = metaClient.getActiveTimeline().filterCompletedInstants() + .filter(i -> i.requestedTime().equals(instant2)).firstInstant().get(); + HoodieReplaceCommitMetadata replaceMetadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(instant2Instant); + + Map> partitionToReplacedFileIds = replaceMetadata.getPartitionToReplaceFileIds(); + + // Verify both partitions are in replaced file IDs + assertTrue(partitionToReplacedFileIds.containsKey("2023/01/01"), + "Partition 2023/01/01 should be in replaced file IDs"); + assertTrue(partitionToReplacedFileIds.containsKey("2023/01/02"), + "Partition 2023/01/02 should be in replaced file IDs"); + + // Verify file IDs match + assertEquals(partition1FileIds.size(), partitionToReplacedFileIds.get("2023/01/01").size()); + assertEquals(partition2FileIds.size(), partitionToReplacedFileIds.get("2023/01/02").size()); + + // Verify partition 2023/01/03 is NOT in replaced file IDs + assertFalse(partitionToReplacedFileIds.containsKey("2023/01/03"), + "Partition 2023/01/03 should NOT be in replaced file IDs"); + } + + /** + * Test getPartitionToReplacedFileIds when overwriting a partition with multiple file groups. + */ + @Test + public void testGetPartitionToReplacedFileIdsWithMultipleFileGroups() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Create multiple file groups in a single partition through multiple inserts + String partitionPath = "2023/01/01"; + for (int i = 1; i <= 3; i++) { + String instant = nextInstant(); + List records = dataGen.generateInsertsForPartition(instant, 50, partitionPath); + commitBulkInsert(client, jsc.parallelize(records, 2), instant); + } + + // Get all file IDs in the partition + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTable table = HoodieSparkTable.create(config, context, metaClient); + List existingFileIds = table.getSliceView() + .getLatestFileSlices(partitionPath) + .map(slice -> slice.getFileId()) + .distinct() + .collect(Collectors.toList()); + + assertTrue(existingFileIds.size() >= 2, + "Should have multiple file groups in partition from multiple inserts"); + + // Step 2: Perform INSERT_OVERWRITE + metaClient = HoodieTableMetaClient.reload(metaClient); + String overwriteInstant = nextInstant(); + List overwriteRecords = dataGen.generateInsertsForPartition(overwriteInstant, 100, partitionPath); + commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), overwriteInstant); + + // Step 3: Verify all file groups are marked as replaced + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieInstant overwriteInstantObj = metaClient.getActiveTimeline().filterCompletedInstants() + .filter(i -> i.requestedTime().equals(overwriteInstant)).firstInstant().get(); + HoodieReplaceCommitMetadata replaceMetadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(overwriteInstantObj); + + Map> partitionToReplacedFileIds = replaceMetadata.getPartitionToReplaceFileIds(); + + assertTrue(partitionToReplacedFileIds.containsKey(partitionPath)); + List replacedFileIds = partitionToReplacedFileIds.get(partitionPath); + + // All existing file IDs should be marked as replaced + assertEquals(existingFileIds.size(), replacedFileIds.size(), + "All file groups should be marked as replaced"); + assertTrue(replacedFileIds.containsAll(existingFileIds), + "Replaced file IDs should include all original file groups"); + } + + /** + * Test getPartitionToReplacedFileIds when overwriting an empty partition. + */ + @Test + public void testGetPartitionToReplacedFileIdsForEmptyPartition() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data into partition1 + String instant1 = nextInstant(); + String partition1 = "2023/01/01"; + List records1 = dataGen.generateInsertsForPartition(instant1, 100, partition1); + commitInsert(client, jsc.parallelize(records1, 2), instant1); + + // Step 2: Perform INSERT_OVERWRITE on a different partition (empty partition2) + String instant2 = nextInstant(); + String partition2 = "2023/01/02"; + List overwriteRecords = dataGen.generateInsertsForPartition(instant2, 50, partition2); + commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), instant2); + + // Step 3: Verify partition2 is in replaced file IDs even though it was empty + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieInstant instant2Instant = metaClient.getActiveTimeline().filterCompletedInstants() + .filter(i -> i.requestedTime().equals(instant2)).firstInstant().get(); + HoodieReplaceCommitMetadata replaceMetadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(instant2Instant); + Map> partitionToReplacedFileIds = replaceMetadata.getPartitionToReplaceFileIds(); + + // partition2 should be present with empty list (no existing files to replace) + assertTrue(partitionToReplacedFileIds.containsKey(partition2), + "Empty partition should still be in replaced file IDs map"); + assertTrue(partitionToReplacedFileIds.get(partition2).isEmpty(), + "Empty partition should have empty list of replaced file IDs"); + + // partition1 should NOT be in replaced file IDs + assertFalse(partitionToReplacedFileIds.containsKey(partition1), + "Non-overwritten partition should not be in replaced file IDs"); + } + + /** + * Test getPartitionToReplacedFileIds validates actual file IDs, not just counts. + * Ensures the specific file IDs returned match the file groups that existed. + */ + @Test + public void testGetPartitionToReplacedFileIdsValidatesActualFileIds() throws Exception { + HoodieWriteConfig config = getConfigBuilder(false).build(); + SparkRDDWriteClient client = getHoodieWriteClient(config); + + // Step 1: Insert data + String instant1 = nextInstant(); + String partitionPath = "2023/01/01"; + List records = dataGen.generateInsertsForPartition(instant1, 100, partitionPath); + commitInsert(client, jsc.parallelize(records, 2), instant1); + + // Step 2: Insert more data to create additional file groups + String instant2 = nextInstant(); + List moreRecords = dataGen.generateInsertsForPartition(instant2, 50, partitionPath); + commitInsert(client, jsc.parallelize(moreRecords, 2), instant2); + + // Capture exact file IDs before overwrite + HoodieTableMetaClient metaClient = HoodieTableMetaClient.reload(this.metaClient); + HoodieTable table = HoodieSparkTable.create(config, context, metaClient); + Set expectedFileIds = table.getSliceView() + .getLatestFileSlices(partitionPath) + .map(slice -> slice.getFileId()) + .collect(Collectors.toSet()); + + assertFalse(expectedFileIds.isEmpty(), "Should have file groups before overwrite"); + + // Step 3: Perform INSERT_OVERWRITE + String instant3 = nextInstant(); + List overwriteRecords = dataGen.generateInsertsForPartition(instant3, 75, partitionPath); + commitInsertOverwrite(client, jsc.parallelize(overwriteRecords, 2), instant3); + + // Step 4: Validate exact file IDs in replaced list + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieInstant instant3Instant = metaClient.getActiveTimeline().filterCompletedInstants() + .filter(i -> i.requestedTime().equals(instant3)).firstInstant().get(); + HoodieReplaceCommitMetadata replaceMetadata = metaClient.getActiveTimeline() + .readReplaceCommitMetadata(instant3Instant); + Map> partitionToReplacedFileIds = replaceMetadata.getPartitionToReplaceFileIds(); + + Set actualReplacedFileIds = new HashSet<>(partitionToReplacedFileIds.get(partitionPath)); + + // Validate exact file IDs, not just counts + assertEquals(expectedFileIds, actualReplacedFileIds, + "Replaced file IDs should exactly match the file groups that existed before overwrite"); + } + + // Hudi instant times are millisecond-resolution; sleep briefly between generations so + // back-to-back instants in a single test method are strictly ordered. + private String nextInstant() throws InterruptedException { + Thread.sleep(2); + return WriteClientTestUtils.createNewInstantTime(); + } + + private JavaRDD commitInsert(SparkRDDWriteClient client, JavaRDD records, String instantTime) { + WriteClientTestUtils.startCommitWithTime(client, instantTime); + JavaRDD writeStatuses = client.insert(records, instantTime); + List statusList = writeStatuses.collect(); + client.commit(instantTime, jsc.parallelize(statusList, 1)); + return writeStatuses; + } + + private JavaRDD commitBulkInsert(SparkRDDWriteClient client, JavaRDD records, String instantTime) { + WriteClientTestUtils.startCommitWithTime(client, instantTime); + JavaRDD writeStatuses = client.bulkInsert(records, instantTime); + List statusList = writeStatuses.collect(); + client.commit(instantTime, jsc.parallelize(statusList, 1)); + return writeStatuses; + } + + private HoodieWriteResult commitInsertOverwrite(SparkRDDWriteClient client, JavaRDD records, String instantTime) { + WriteClientTestUtils.startCommitWithTime(client, instantTime, HoodieTimeline.REPLACE_COMMIT_ACTION); + HoodieWriteResult result = client.insertOverwrite(records, instantTime); + List statusList = result.getWriteStatuses().collect(); + client.commit(instantTime, jsc.parallelize(statusList, 1), Option.empty(), HoodieTimeline.REPLACE_COMMIT_ACTION, + result.getPartitionToReplaceFileIds()); + return result; + } + + private HoodieWriteResult commitDeletePartitions(SparkRDDWriteClient client, List partitions, String instantTime) { + WriteClientTestUtils.startCommitWithTime(client, instantTime, HoodieTimeline.REPLACE_COMMIT_ACTION); + HoodieWriteResult result = client.deletePartitions(partitions, instantTime); + List statusList = result.getWriteStatuses().collect(); + client.commit(instantTime, jsc.parallelize(statusList, 1), Option.empty(), HoodieTimeline.REPLACE_COMMIT_ACTION, + result.getPartitionToReplaceFileIds()); + return result; + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestSparkBucketInfoGetter.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestSparkBucketInfoGetter.java new file mode 100644 index 0000000000000..670db2c77c653 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestSparkBucketInfoGetter.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.hudi.table.action.commit; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests the list- and map-backed {@link SparkBucketInfoGetter} implementations. + */ +public class TestSparkBucketInfoGetter { + + private static BucketInfo bucket(String fileIdPrefix) { + return new BucketInfo(BucketType.INSERT, fileIdPrefix, "partition"); + } + + @Test + void listBasedGetterIndexesByPosition() { + BucketInfo first = bucket("f0"); + BucketInfo second = bucket("f1"); + List bucketInfoList = Arrays.asList(first, second); + ListBasedSparkBucketInfoGetter getter = new ListBasedSparkBucketInfoGetter(bucketInfoList); + assertSame(first, getter.getBucketInfo(0)); + assertSame(second, getter.getBucketInfo(1)); + } + + @Test + void listBasedGetterRejectsOutOfRangeIndex() { + ListBasedSparkBucketInfoGetter getter = + new ListBasedSparkBucketInfoGetter(Arrays.asList(bucket("f0"))); + assertThrows(IndexOutOfBoundsException.class, () -> getter.getBucketInfo(5)); + } + + @Test + void mapBasedGetterLooksUpByBucketNumber() { + Map bucketInfoMap = new HashMap<>(); + BucketInfo target = bucket("f7"); + bucketInfoMap.put(7, target); + MapBasedSparkBucketInfoGetter getter = new MapBasedSparkBucketInfoGetter(bucketInfoMap); + assertSame(target, getter.getBucketInfo(7)); + // Missing keys resolve to null rather than throwing. + assertNull(getter.getBucketInfo(0)); + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/compact/CompactionTestBase.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/compact/CompactionTestBase.java index ae6b60ccf679a..677ece46d801b 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/compact/CompactionTestBase.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/compact/CompactionTestBase.java @@ -104,7 +104,7 @@ protected void validateDeltaCommit(String latestDeltaCommit, final Map 0, - "Expect atleast one log file to be present where the latest delta commit was written"); + "Expect at least one log file to be present where the latest delta commit was written"); assertFalse(fileSlice.getBaseFile().isPresent(), "Expect no data-file to be present"); } else { assertTrue(fileSlice.getBaseInstantTime().compareTo(latestDeltaCommit) <= 0, @@ -210,7 +210,7 @@ protected void executeCompaction(String compactionInstantTime, SparkRDDWriteClie if (hasDeltaCommitAfterPendingCompaction) { assertFalse(fileSliceList.stream().anyMatch(fs -> fs.getLogFiles().count() == 0), - "Verify all file-slices have atleast one log-file"); + "Verify all file-slices have at least one log-file"); } else { assertFalse(fileSliceList.stream().anyMatch(fs -> fs.getLogFiles().count() > 0), "Verify all file-slices have no log-files"); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestCleanActionExecutor.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestCleanActionExecutor.java index 10b9891cb27f0..f7fee3437ab00 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestCleanActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestCleanActionExecutor.java @@ -66,7 +66,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -161,23 +163,26 @@ void testPartialCleanFailure(CleanFailureType failureType) throws IOException { CleanActionExecutor cleanActionExecutor = new CleanActionExecutor(context, config, mockHoodieTable, "002"); if (failureType == CleanFailureType.TRUE_ON_DELETE) { - assertCleanExecutionSuccess(cleanActionExecutor, filePath); + assertCleanExecutionSuccess(cleanActionExecutor, filePath, true); } else if (failureType == CleanFailureType.FALSE_ON_DELETE_IS_EXISTS_FALSE) { - assertCleanExecutionSuccess(cleanActionExecutor, filePath); + // a missing file on a retried clean counts as a successful delete so its MDT entry is removed + assertCleanExecutionSuccess(cleanActionExecutor, filePath, true); } else if (failureType == CleanFailureType.FALSE_ON_DELETE_IS_EXISTS_TRUE) { assertCleanExecutionFailure(cleanActionExecutor); } else if (failureType == CleanFailureType.FILE_NOT_FOUND_EXC_ON_DELETE) { - assertCleanExecutionSuccess(cleanActionExecutor, filePath); + assertCleanExecutionSuccess(cleanActionExecutor, filePath, true); } else if (failureType == CleanFailureType.IO_EXCEPTION) { assertCleanExecutionFailure(cleanActionExecutor); } else if (failureType == CleanFailureType.IO_EXCEPTION_AND_EXISTS) { assertCleanExecutionFailure(cleanActionExecutor); } else if (failureType == CleanFailureType.IO_EXCEPTION_BUT_NOT_EXISTS) { - assertCleanExecutionSuccess(cleanActionExecutor, filePath); + assertCleanExecutionSuccess(cleanActionExecutor, filePath, false); } else { // run time exception assertCleanExecutionFailure(cleanActionExecutor); } + // file deletions must not stat the path first; deletes go straight to storage + verify(storage, never()).getPathInfo(filePath); } private void assertCleanExecutionFailure(CleanActionExecutor cleanActionExecutor) { @@ -186,11 +191,16 @@ private void assertCleanExecutionFailure(CleanActionExecutor cleanActionExecutor }); } - private void assertCleanExecutionSuccess(CleanActionExecutor cleanActionExecutor, StoragePath filePath) { + private void assertCleanExecutionSuccess(CleanActionExecutor cleanActionExecutor, StoragePath filePath, boolean expectFileDeleteSuccess) { HoodieCleanMetadata cleanMetadata = cleanActionExecutor.execute(); assertTrue(cleanMetadata.getPartitionMetadata().containsKey(PARTITION1)); HoodieCleanPartitionMetadata cleanPartitionMetadata = cleanMetadata.getPartitionMetadata().get(PARTITION1); assertTrue(cleanPartitionMetadata.getDeletePathPatterns().contains(filePath.getName())); + if (expectFileDeleteSuccess) { + assertTrue(cleanPartitionMetadata.getSuccessDeleteFiles().contains(filePath.getName())); + } else { + assertTrue(cleanPartitionMetadata.getFailedDeleteFiles().contains(filePath.getName())); + } } private static HoodieWriteConfig getCleanByCommitsConfig() { diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java index 182f65100b20b..bd9ddb23bf03a 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java @@ -30,6 +30,7 @@ import org.apache.hudi.common.HoodieRollbackStat; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.IOType; @@ -43,6 +44,7 @@ import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; import org.apache.hudi.table.HoodieSparkTable; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.rollback.MarkerBasedRollbackStrategy; @@ -399,12 +401,16 @@ void testRollbackMultipleAppendLogFilesInOneFileGroupInMOR(HoodieTableVersion ta assertEquals(1, rollbackStats.size()); HoodieRollbackStat rollbackStat = rollbackStats.get(0); if (!tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT)) { - StoragePath rollbackLogPath = new StoragePath(new StoragePath(basePath, partition), - FileCreateUtils.logFileName(instantTime1, fileId, numLogFiles + 2)); + // The rollback log file's write token is determined by Spark's task context at runtime, + // so extract the actual path from the rollback stats rather than constructing it with a + // hardcoded write token. Verify the file exists and its version matches the expected bump. + StoragePathInfo rollbackLogPathInfo = + rollbackStat.getCommandBlocksCount().entrySet().stream().findFirst().get().getKey(); + StoragePath rollbackLogPath = rollbackLogPathInfo.getPath(); assertTrue(storage.exists(rollbackLogPath)); - assertEquals(rollbackLogPath.getPathWithoutSchemeAndAuthority(), - rollbackStat.getCommandBlocksCount().entrySet().stream().findFirst().get() - .getKey().getPath().getPathWithoutSchemeAndAuthority()); + HoodieLogFile rollbackLogFile = new HoodieLogFile(rollbackLogPathInfo); + assertEquals(fileId, rollbackLogFile.getFileId()); + assertEquals(numLogFiles + 2, rollbackLogFile.getLogVersion()); } assertEquals(partition, rollbackStat.getPartitionPath()); assertEquals( diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/marker/TestTimelineServerBasedWriteMarkers.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/marker/TestTimelineServerBasedWriteMarkers.java index e2d769ece032a..9a29e7093fcd1 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/marker/TestTimelineServerBasedWriteMarkers.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/marker/TestTimelineServerBasedWriteMarkers.java @@ -74,7 +74,7 @@ public void setup() throws IOException { this.markerFolderPath = new StoragePath(metaClient.getMarkerFolderPath("000")); restartServerAndClient(0); - log.info("Connecting to Timeline Server :" + timelineService.getServerPort()); + log.info("Connecting to Timeline Server :{}", timelineService.getServerPort()); } @AfterEach @@ -108,7 +108,7 @@ void verifyMarkersInFileSystem(boolean isTablePartitioned) throws IOException { @EnumSource(value = FileSystemViewStorageType.class) public void testCreationWithTimelineServiceRetries(FileSystemViewStorageType storageType) throws Exception { restartServerAndClient(0, storageType); - log.info("Connecting to Timeline Server :" + timelineService.getServerPort()); + log.info("Connecting to Timeline Server :{}", timelineService.getServerPort()); // Validate marker creation/ deletion work without any failures in the timeline service. createSomeMarkers(true); assertTrue(storage.exists(markerFolderPath)); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieCleanerTestBase.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieCleanerTestBase.java index 66f2f595ef4da..5563f39c811b8 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieCleanerTestBase.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieCleanerTestBase.java @@ -164,24 +164,27 @@ protected List runCleaner( } Map cleanStatMap = cleanMetadata1.getPartitionMetadata().values().stream() - .map(x -> new HoodieCleanStat.Builder().withPartitionPath(x.getPartitionPath()) - .withFailedDeletes(x.getFailedDeleteFiles()).withSuccessfulDeletes(x.getSuccessDeleteFiles()) - .withPolicy(HoodieCleaningPolicy.valueOf(x.getPolicy())).withDeletePathPattern(x.getDeletePathPatterns()) - .withEarliestCommitRetained(Option.ofNullable(cleanMetadata1.getEarliestCommitToRetain() != null - ? INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "000") - : null)) + .map(x -> HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.valueOf(x.getPolicy())) + .withPartitionPath(x.getPartitionPath()) + .withDeletePathPatterns(x.getDeletePathPatterns()) + .withSuccessDeleteFiles(x.getSuccessDeleteFiles()) + .withFailedDeleteFiles(x.getFailedDeleteFiles()) + .withEarliestCommitToRetain(cleanMetadata1.getEarliestCommitToRetain() != null ? "000" : "") .build()) .collect(Collectors.toMap(HoodieCleanStat::getPartitionPath, x -> x)); cleanMetadata1.getBootstrapPartitionMetadata().values().forEach(x -> { - HoodieCleanStat s = cleanStatMap.get(x.getPartitionPath()); - cleanStatMap.put(x.getPartitionPath(), new HoodieCleanStat.Builder().withPartitionPath(x.getPartitionPath()) - .withFailedDeletes(s.getFailedDeleteFiles()).withSuccessfulDeletes(s.getSuccessDeleteFiles()) - .withPolicy(HoodieCleaningPolicy.valueOf(x.getPolicy())).withDeletePathPattern(s.getDeletePathPatterns()) - .withEarliestCommitRetained(Option.ofNullable(s.getEarliestCommitToRetain()) - .map(y -> INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, y))) - .withSuccessfulDeleteBootstrapBaseFiles(x.getSuccessDeleteFiles()) + cleanStatMap.compute(x.getPartitionPath(), (k, s) -> HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.valueOf(x.getPolicy())) + .withPartitionPath(x.getPartitionPath()) + .withDeletePathPatterns(s.getDeletePathPatterns()) + .withSuccessDeleteFiles(s.getSuccessDeleteFiles()) + .withFailedDeleteFiles(s.getFailedDeleteFiles()) + .withEarliestCommitToRetain(s.getEarliestCommitToRetain() != null ? s.getEarliestCommitToRetain() : "") + .withDeleteBootstrapBasePathPatterns(x.getDeletePathPatterns()) + .withSuccessDeleteBootstrapBaseFiles(x.getSuccessDeleteFiles()) .withFailedDeleteBootstrapBaseFiles(x.getFailedDeleteFiles()) - .withDeleteBootstrapBasePathPatterns(x.getDeletePathPatterns()).build()); + .build()); }); return new ArrayList<>(cleanStatMap.values()); } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieSparkClientTestHarness.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieSparkClientTestHarness.java index 97bd0e1175d76..296690556327e 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieSparkClientTestHarness.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/HoodieSparkClientTestHarness.java @@ -676,14 +676,12 @@ public HoodieInstant createCleanMetadata(String instantTime, boolean inflightOnl if (inflightOnly) { HoodieTestTable.of(metaClient).addInflightClean(instantTime, cleanerPlan); } else { - HoodieCleanStat cleanStats = new HoodieCleanStat( - HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS, - HoodieTestUtils.DEFAULT_PARTITION_PATHS[new Random().nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)], - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList(), - instantTime, - ""); + HoodieCleanStat cleanStats = HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS) + .withPartitionPath(HoodieTestUtils.DEFAULT_PARTITION_PATHS[new Random().nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)]) + .withEarliestCommitToRetain(instantTime) + .withLastCompletedCommitTimestamp("") + .build(); HoodieCleanMetadata cleanMetadata = convertCleanMetadata(instantTime, Option.of(0L), Collections.singletonList(cleanStats), Collections.EMPTY_MAP); HoodieTestTable.of(metaClient).addClean(instantTime, cleanerPlan, cleanMetadata, isEmptyForAll, isEmptyCompleted); } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/spark/sql/hudi/execution/TestRangeSampleSort.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/spark/sql/hudi/execution/TestRangeSampleSort.java index 3b35900e6626c..2b361202ab719 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/spark/sql/hudi/execution/TestRangeSampleSort.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/spark/sql/hudi/execution/TestRangeSampleSort.java @@ -19,16 +19,28 @@ package org.apache.spark.sql.hudi.execution; +import org.apache.hudi.common.util.BinaryUtil; import org.apache.hudi.config.HoodieClusteringConfig; +import org.apache.hudi.sort.SpaceCurveSortingHelper; import org.apache.hudi.testutils.HoodieClientTestBase; import org.apache.hudi.util.JavaScalaConverters; import org.apache.spark.sql.Dataset; 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.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.apache.spark.sql.functions.spark_partition_id; class TestRangeSampleSort extends HoodieClientTestBase { @@ -54,4 +66,160 @@ void sortDataFrameBySample() { JavaScalaConverters.convertJavaListToScalaSeq(Arrays.asList("id", "content")), 1), "range sort shall not fail when 0 or 1 record incoming"); } } + + /** + * Builds a two-column integer frame; call sites pass an explicit output partition + * count so the resulting ordering is deterministic and can be asserted row by row. + */ + private Dataset buildTwoIntColumnFrame(List rows) { + StructType schema = new StructType(new StructField[] { + new StructField("c1", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("c2", DataTypes.IntegerType, true, Metadata.empty()) + }); + return sparkSession.createDataFrame(rows, schema); + } + + /** + * Recomputes the Z-curve ordinal for a two-int row using the same public byte mapping + * the helper relies on, so the expected ordering is derived independently. + */ + private byte[] expectedZOrdinal(Integer c1, Integer c2) { + byte[] b1 = BinaryUtil.intTo8Byte(c1 == null ? Integer.MAX_VALUE : c1); + byte[] b2 = BinaryUtil.intTo8Byte(c2 == null ? Integer.MAX_VALUE : c2); + return BinaryUtil.interleaving(new byte[][] {b1, b2}, 8); + } + + @Test + void orderByMappingValuesZOrderPreservesSchemaAndSortsByCurve() { + List rows = Arrays.asList( + RowFactory.create(5, 5), + RowFactory.create(1, 9), + RowFactory.create(9, 1), + RowFactory.create(1, 1)); + Dataset df = buildTwoIntColumnFrame(rows); + + Dataset ordered = SpaceCurveSortingHelper.orderDataFrameByMappingValues( + df, HoodieClusteringConfig.LayoutOptimizationStrategy.ZORDER, Arrays.asList("c1", "c2"), 1); + + // The helper drops the internal Index column, so the output schema must match the input exactly. + Assertions.assertArrayEquals( + new String[] {"c1", "c2"}, ordered.schema().fieldNames(), + "z-order output must retain only the original columns"); + + List result = ordered.collectAsList(); + Assertions.assertEquals(rows.size(), result.size(), "no rows should be dropped"); + + // Every emitted row must be non-decreasing under the independently recomputed Z-curve ordinal. + for (int i = 1; i < result.size(); i++) { + byte[] prev = expectedZOrdinal(result.get(i - 1).getInt(0), result.get(i - 1).getInt(1)); + byte[] curr = expectedZOrdinal(result.get(i).getInt(0), result.get(i).getInt(1)); + Assertions.assertTrue(BinaryUtil.compareTo(prev, 0, prev.length, curr, 0, curr.length) <= 0, + "row " + i + " violates ascending Z-curve order"); + } + + // (1,1) has the smallest interleaved ordinal, so it must sort first. + Row first = result.get(0); + Assertions.assertEquals(1, first.getInt(0)); + Assertions.assertEquals(1, first.getInt(1)); + } + + @Test + void orderByMappingValuesZOrderPlacesNullLast() { + List rows = new ArrayList<>(); + rows.add(RowFactory.create(2, 2)); + rows.add(RowFactory.create(null, null)); + rows.add(RowFactory.create(1, 1)); + Dataset df = buildTwoIntColumnFrame(rows); + + Dataset ordered = SpaceCurveSortingHelper.orderDataFrameByMappingValues( + df, HoodieClusteringConfig.LayoutOptimizationStrategy.ZORDER, Arrays.asList("c1", "c2"), 1); + + List result = ordered.collectAsList(); + Assertions.assertEquals(3, result.size()); + // Nulls map to Integer.MAX_VALUE in the byte mapping, so the all-null row sorts last. + Row last = result.get(result.size() - 1); + Assertions.assertTrue(last.isNullAt(0) && last.isNullAt(1), + "the all-null row must sort last under Z-curve mapping"); + } + + @Test + void orderByMappingValuesHilbertPreservesRowsAndSchema() { + List rows = Arrays.asList( + RowFactory.create(7, 3), + RowFactory.create(0, 0), + RowFactory.create(4, 4)); + Dataset df = buildTwoIntColumnFrame(rows); + + Dataset ordered = SpaceCurveSortingHelper.orderDataFrameByMappingValues( + df, HoodieClusteringConfig.LayoutOptimizationStrategy.HILBERT, Arrays.asList("c1", "c2"), 1); + + Assertions.assertArrayEquals(new String[] {"c1", "c2"}, ordered.schema().fieldNames(), + "hilbert output must retain only the original columns"); + List result = ordered.collectAsList(); + Assertions.assertEquals(rows.size(), result.size(), "hilbert ordering must not drop rows"); + // Origin (0,0) maps to the smallest Hilbert index, so it must sort first. + Assertions.assertEquals(0, result.get(0).getInt(0)); + Assertions.assertEquals(0, result.get(0).getInt(1)); + } + + @Test + void orderByMappingValuesSingleColumnRangePartitionsRows() { + List rows = Arrays.asList( + RowFactory.create(3, 30), + RowFactory.create(1, 10), + RowFactory.create(2, 20), + RowFactory.create(4, 40)); + Dataset df = buildTwoIntColumnFrame(rows); + + // A single ordering column short-circuits space-curve mapping into a plain range + // repartition. Spark's repartitionByRange does not sort rows within a partition, + // so only the cross-partition range contract can be asserted. + Dataset ordered = SpaceCurveSortingHelper.orderDataFrameByMappingValues( + df, HoodieClusteringConfig.LayoutOptimizationStrategy.ZORDER, Arrays.asList("c1"), 2); + + Assertions.assertArrayEquals(new String[] {"c1", "c2"}, ordered.schema().fieldNames(), + "single-column ordering must not add an Index column"); + + List result = ordered.withColumn("pid", spark_partition_id()).collectAsList(); + Assertions.assertEquals(rows.size(), result.size(), "no rows should be dropped"); + + // Range partitioning must not overlap ranges across partitions: every c1 in a + // lower-numbered partition is <= every c1 in a higher-numbered partition. + for (Row left : result) { + for (Row right : result) { + if (left.getInt(2) < right.getInt(2)) { + Assertions.assertTrue(left.getInt(0) <= right.getInt(0), + "partition ranges must not overlap"); + } + } + } + + // With four distinct keys and two target partitions the deterministic range + // bounds split the rows across both partitions, so the check above is not vacuous. + Assertions.assertEquals(2, + result.stream().map(r -> r.getInt(2)).collect(Collectors.toSet()).size(), + "rows must be spread across both requested partitions"); + + // The row multiset must be preserved: each c1 appears once with its matching c2. + List c1Values = new ArrayList<>(); + for (Row r : result) { + Assertions.assertEquals(r.getInt(0) * 10, r.getInt(1), "row content must be unchanged"); + c1Values.add(r.getInt(0)); + } + c1Values.sort(Integer::compareTo); + Assertions.assertEquals(Arrays.asList(1, 2, 3, 4), c1Values, + "range repartition must preserve all rows"); + } + + @Test + void orderByMappingValuesUnknownColumnReturnsInputUnchanged() { + List rows = Arrays.asList(RowFactory.create(2, 2), RowFactory.create(1, 1)); + Dataset df = buildTwoIntColumnFrame(rows); + + // Ordering by a column absent from the schema must be a no-op that returns the same frame. + Dataset ordered = SpaceCurveSortingHelper.orderDataFrameByMappingValues( + df, HoodieClusteringConfig.LayoutOptimizationStrategy.ZORDER, Arrays.asList("missing"), 1); + + Assertions.assertSame(df, ordered, "missing order column must return the untouched input frame"); + } } diff --git a/hudi-common/pom.xml b/hudi-common/pom.xml index f0416d3cd8179..7bb76c74ad5d9 100644 --- a/hudi-common/pom.xml +++ b/hudi-common/pom.xml @@ -128,6 +128,7 @@ org.projectlombok lombok + provided diff --git a/hudi-common/src/main/java/org/apache/hudi/BaseHoodieTableFileIndex.java b/hudi-common/src/main/java/org/apache/hudi/BaseHoodieTableFileIndex.java index cbe0e3ccf64a1..ff3e7dbbbe001 100644 --- a/hudi-common/src/main/java/org/apache/hudi/BaseHoodieTableFileIndex.java +++ b/hudi-common/src/main/java/org/apache/hudi/BaseHoodieTableFileIndex.java @@ -314,6 +314,11 @@ private Map> generatePartitionFileSlicesPostROTab Map partitionsMap = new HashMap<>(); partitions.forEach(p -> partitionsMap.put(p.path, p)); Map> partitionToFileSlices = new HashMap<>(); + // Pre-populate so partitions with no files still appear in the result map. + // Without this, the caller's Collectors.toMap(identity, cache::get) NPEs on empty partitions + // because cache.get returns null and toMap rejects null values. This matches the contract + // already honored by filterFiles, which iterates over partitions rather than over files. + partitions.forEach(p -> partitionToFileSlices.put(p, Collections.emptyList())); for (StoragePathInfo pathInfo : allFiles) { // Create FileSlice obj from StoragePathInfo. @@ -326,7 +331,11 @@ private Map> generatePartitionFileSlicesPostROTab // Add the FileSlice to partitionToFileSlices PartitionPath partitionPathObj = partitionsMap.get(relPartitionPath); if (partitionPathObj != null) { - List fileSlices = partitionToFileSlices.computeIfAbsent(partitionPathObj, k -> new ArrayList<>()); + List fileSlices = partitionToFileSlices.get(partitionPathObj); + if (fileSlices.isEmpty()) { + fileSlices = new ArrayList<>(); + partitionToFileSlices.put(partitionPathObj, fileSlices); + } fileSlices.add(fileSlice); } else { log.warn("Could not find partition path object for relative path: {}. Skipping file: {}", diff --git a/hudi-common/src/main/java/org/apache/hudi/HoodieVersion.java b/hudi-common/src/main/java/org/apache/hudi/HoodieVersion.java index dd88836468c92..23cee10158417 100644 --- a/hudi-common/src/main/java/org/apache/hudi/HoodieVersion.java +++ b/hudi-common/src/main/java/org/apache/hudi/HoodieVersion.java @@ -33,23 +33,46 @@ public final class HoodieVersion { public static final String HOODIE_WRITER_VERSION = "hudi_writer_version"; + // Cached result of reading version from the manifest. Null means "not loaded yet". An empty + // string means "manifest absent or unreadable" — fall back to HOODIE_DEFAULT_VERSION so tests + // that swap the default via setVersionOverride continue to work. + private static volatile String cachedManifestVersion = null; + /** * Returns the complete version of HUDI code * Example: 0.12.2 or 0.12.3-snapshot */ public static String get() { - String hudiPropertiesFilePath = "META-INF/maven/org.apache.hudi/hudi-common/pom.properties"; - try (InputStream inputStream = HoodieVersion.class.getClassLoader().getResourceAsStream(hudiPropertiesFilePath)) { - Properties properties = new Properties(); - if (inputStream != null) { - properties.load(inputStream); - // Access properties - return properties.getProperty("version"); + String fromManifest = loadManifestVersion(); + return fromManifest.isEmpty() ? HOODIE_DEFAULT_VERSION : fromManifest; + } + + private static String loadManifestVersion() { + String local = cachedManifestVersion; + if (local != null) { + return local; + } + synchronized (HoodieVersion.class) { + if (cachedManifestVersion != null) { + return cachedManifestVersion; + } + String hudiPropertiesFilePath = "META-INF/maven/org.apache.hudi/hudi-common/pom.properties"; + String resolved = ""; + try (InputStream inputStream = HoodieVersion.class.getClassLoader().getResourceAsStream(hudiPropertiesFilePath)) { + if (inputStream != null) { + Properties properties = new Properties(); + properties.load(inputStream); + String version = properties.getProperty("version"); + if (version != null) { + resolved = version; + } + } + } catch (Exception ignored) { + // Ignoring the exception as there is a fallback to default version } - } catch (Exception ignored) { - // Ignoring the exception as there is as fallback to default version + cachedManifestVersion = resolved; + return resolved; } - return HOODIE_DEFAULT_VERSION; } /** diff --git a/hudi-common/src/main/java/org/apache/hudi/avro/AvroRecordContext.java b/hudi-common/src/main/java/org/apache/hudi/avro/AvroRecordContext.java index def6e6a7003fc..dfc25910037cd 100644 --- a/hudi-common/src/main/java/org/apache/hudi/avro/AvroRecordContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/avro/AvroRecordContext.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieEmptyRecord; import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.table.HoodieTableConfig; @@ -70,11 +71,22 @@ public AvroRecordContext() { public static Object getFieldValueFromIndexedRecord( IndexedRecord record, String fieldName) { - HoodieSchema currentSchema = HoodieSchema.fromAvroSchema(record.getSchema()); + // Interning returns the canonical wrapper for this schema, whose lazily built field list and + // field map survive across calls, so the per-record cost is a cache hit instead of an + // O(schema width) wrapper rebuild. + HoodieSchema currentSchema = HoodieAvroSchemaCache.intern(record.getSchema()); IndexedRecord currentRecord = record; String[] path = fieldName.split("\\."); for (int i = 0; i < path.length; i++) { currentSchema = currentSchema.getNonNullType(); + // Value navigation here can only descend through RECORD fields. Column-stats field paths + // that traverse a MAP (".key_value.key" / ".key_value.value") or ARRAY (".list.element") + // synthetic accessor, or that hit a null intermediate value, cannot be resolved to a single + // value and yield null instead of throwing. This mirrors HoodieAvroUtils.getNestedFieldVal; + // statistics for such nested leaves are still collected from the base-file (Parquet) path. + if (currentRecord == null || !currentSchema.hasFields()) { + return null; + } Option fieldOpt = currentSchema.getField(path[i]); if (fieldOpt.isEmpty()) { return null; @@ -85,7 +97,7 @@ public static Object getFieldValueFromIndexedRecord( return value; } currentSchema = field.schema(); - currentRecord = (IndexedRecord) value; + currentRecord = value instanceof IndexedRecord ? (IndexedRecord) value : null; } return null; } diff --git a/hudi-common/src/main/java/org/apache/hudi/avro/AvroSchemaUtils.java b/hudi-common/src/main/java/org/apache/hudi/avro/AvroSchemaUtils.java index 90a8e9274b8aa..78ffcf5723129 100644 --- a/hudi-common/src/main/java/org/apache/hudi/avro/AvroSchemaUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/avro/AvroSchemaUtils.java @@ -114,10 +114,6 @@ public static Schema createNullableSchema(Schema schema) { return Schema.createUnion(Schema.create(Schema.Type.NULL), schema); } - public static String createSchemaErrorString(String errorMessage, Schema writerSchema, Schema tableSchema) { - return String.format("%s\nwriterSchema: %s\ntableSchema: %s", errorMessage, writerSchema, tableSchema); - } - /** * Create a new schema by force changing all the fields as nullable. * diff --git a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java index 7c55f441d2009..c52960c4abb06 100644 --- a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java @@ -19,6 +19,7 @@ package org.apache.hudi.avro; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.util.DateTimeUtils; @@ -834,7 +835,7 @@ public static Object[] getRecordColumnValues(HoodieRecord record, Schema schema, boolean consistentLogicalTimestampEnabled) { try { - GenericRecord genericRecord = (GenericRecord) (record.toIndexedRecord(HoodieSchema.fromAvroSchema(schema), new Properties()).get()).getData(); + GenericRecord genericRecord = (GenericRecord) (record.toIndexedRecord(HoodieAvroSchemaCache.intern(schema), new Properties()).get()).getData(); List list = new ArrayList<>(); for (String col : columns) { list.add(HoodieAvroUtils.getNestedFieldVal(genericRecord, col, true, consistentLogicalTimestampEnabled)); diff --git a/hudi-common/src/main/java/org/apache/hudi/client/validator/ValidationContext.java b/hudi-common/src/main/java/org/apache/hudi/client/validator/ValidationContext.java index 30fdbb3ba3b4a..b85218e587e87 100644 --- a/hudi-common/src/main/java/org/apache/hudi/client/validator/ValidationContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/client/validator/ValidationContext.java @@ -169,6 +169,20 @@ default long getTotalUpdateRecordsWritten() { .orElse(0L); } + /** + * Calculate total write errors in the current commit. + * Records that failed to write are tracked in {@link org.apache.hudi.common.model.HoodieWriteStat#getTotalWriteErrors()}. + * A non-zero error count alongside a deviation in offset validation indicates write failures + * rather than silent data loss — useful context for distinguishing the two failure modes. + * + * @return Total count of records that failed to write + */ + default long getTotalWriteErrors() { + return getWriteStats() + .map(stats -> stats.stream().mapToLong(HoodieWriteStat::getTotalWriteErrors).sum()) + .orElse(0L); + } + /** * Check if this is the first commit (no previous commits exist). * Derived from {@link #getPreviousCommitInstant()}. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/HoodieCleanStat.java b/hudi-common/src/main/java/org/apache/hudi/common/HoodieCleanStat.java index 498d430b5c3a6..0106dfc96200c 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/HoodieCleanStat.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/HoodieCleanStat.java @@ -19,9 +19,10 @@ package org.apache.hudi.common; import org.apache.hudi.common.model.HoodieCleaningPolicy; -import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.CollectionUtils; -import org.apache.hudi.common.util.Option; + +import lombok.Builder; +import lombok.Value; import java.io.Serializable; import java.util.List; @@ -29,197 +30,34 @@ /** * Collects stats about a single partition clean operation. */ +@Builder(setterPrefix = "with") +@Value public class HoodieCleanStat implements Serializable { // Policy used - private final HoodieCleaningPolicy policy; + HoodieCleaningPolicy policy; // Partition path cleaned - private final String partitionPath; + String partitionPath; // The patterns that were generated for the delete operation - private final List deletePathPatterns; - private final List successDeleteFiles; - // Files that could not be deleted - private final List failedDeleteFiles; - // Bootstrap Base Path patterns that were generated for the delete operation - private final List deleteBootstrapBasePathPatterns; - private final List successDeleteBootstrapBaseFiles; + @Builder.Default + List deletePathPatterns = CollectionUtils.createImmutableList(); + @Builder.Default + List successDeleteFiles = CollectionUtils.createImmutableList(); // Files that could not be deleted - private final List failedDeleteBootstrapBaseFiles; + @Builder.Default + List failedDeleteFiles = CollectionUtils.createImmutableList(); // Earliest commit that was retained in this clean - private final String earliestCommitToRetain; + String earliestCommitToRetain; // Last completed commit timestamp before clean - private final String lastCompletedCommitTimestamp; + String lastCompletedCommitTimestamp; + // Bootstrap Base Path patterns that were generated for the delete operation + @Builder.Default + List deleteBootstrapBasePathPatterns = CollectionUtils.createImmutableList(); + @Builder.Default + List successDeleteBootstrapBaseFiles = CollectionUtils.createImmutableList(); + // Files that could not be deleted + @Builder.Default + List failedDeleteBootstrapBaseFiles = CollectionUtils.createImmutableList(); // set to true if partition is deleted - private final boolean isPartitionDeleted; - - public HoodieCleanStat(HoodieCleaningPolicy policy, String partitionPath, List deletePathPatterns, - List successDeleteFiles, List failedDeleteFiles, String earliestCommitToRetain,String lastCompletedCommitTimestamp) { - this(policy, partitionPath, deletePathPatterns, successDeleteFiles, failedDeleteFiles, earliestCommitToRetain, - lastCompletedCommitTimestamp, CollectionUtils.createImmutableList(), CollectionUtils.createImmutableList(), - CollectionUtils.createImmutableList(), false); - } - - public HoodieCleanStat(HoodieCleaningPolicy policy, String partitionPath, List deletePathPatterns, - List successDeleteFiles, List failedDeleteFiles, - String earliestCommitToRetain,String lastCompletedCommitTimestamp, - List deleteBootstrapBasePathPatterns, - List successDeleteBootstrapBaseFiles, - List failedDeleteBootstrapBaseFiles, - boolean isPartitionDeleted) { - this.policy = policy; - this.partitionPath = partitionPath; - this.deletePathPatterns = deletePathPatterns; - this.successDeleteFiles = successDeleteFiles; - this.failedDeleteFiles = failedDeleteFiles; - this.earliestCommitToRetain = earliestCommitToRetain; - this.lastCompletedCommitTimestamp = lastCompletedCommitTimestamp; - this.deleteBootstrapBasePathPatterns = deleteBootstrapBasePathPatterns; - this.successDeleteBootstrapBaseFiles = successDeleteBootstrapBaseFiles; - this.failedDeleteBootstrapBaseFiles = failedDeleteBootstrapBaseFiles; - this.isPartitionDeleted = isPartitionDeleted; - } - - public HoodieCleaningPolicy getPolicy() { - return policy; - } - - public String getPartitionPath() { - return partitionPath; - } - - public List getDeletePathPatterns() { - return deletePathPatterns; - } - - public List getSuccessDeleteFiles() { - return successDeleteFiles; - } - - public List getFailedDeleteFiles() { - return failedDeleteFiles; - } - - public List getDeleteBootstrapBasePathPatterns() { - return deleteBootstrapBasePathPatterns; - } - - public List getSuccessDeleteBootstrapBaseFiles() { - return successDeleteBootstrapBaseFiles; - } - - public List getFailedDeleteBootstrapBaseFiles() { - return failedDeleteBootstrapBaseFiles; - } - - public String getEarliestCommitToRetain() { - return earliestCommitToRetain; - } - - public String getLastCompletedCommitTimestamp() { - return lastCompletedCommitTimestamp; - } - - public boolean isPartitionDeleted() { - return isPartitionDeleted; - } - - public static Builder newBuilder() { - return new Builder(); - } - - /** - * A builder used to build {@link HoodieCleanStat}. - */ - public static class Builder { - - private HoodieCleaningPolicy policy; - private List deletePathPatterns; - private List successDeleteFiles; - private List failedDeleteFiles; - private String partitionPath; - private String earliestCommitToRetain; - private String lastCompletedCommitTimestamp; - private List deleteBootstrapBasePathPatterns; - private List successDeleteBootstrapBaseFiles; - private List failedDeleteBootstrapBaseFiles; - private boolean isPartitionDeleted; - - public Builder withPolicy(HoodieCleaningPolicy policy) { - this.policy = policy; - return this; - } - - public Builder withDeletePathPattern(List deletePathPatterns) { - this.deletePathPatterns = deletePathPatterns; - return this; - } - - public Builder withSuccessfulDeletes(List successDeleteFiles) { - this.successDeleteFiles = successDeleteFiles; - return this; - } - - public Builder withFailedDeletes(List failedDeleteFiles) { - this.failedDeleteFiles = failedDeleteFiles; - return this; - } - - public Builder withDeleteBootstrapBasePathPatterns(List deletePathPatterns) { - this.deleteBootstrapBasePathPatterns = deletePathPatterns; - return this; - } - - public Builder withSuccessfulDeleteBootstrapBaseFiles(List successDeleteFiles) { - this.successDeleteBootstrapBaseFiles = successDeleteFiles; - return this; - } - - public Builder withFailedDeleteBootstrapBaseFiles(List failedDeleteFiles) { - this.failedDeleteBootstrapBaseFiles = failedDeleteFiles; - return this; - } - - public Builder withPartitionPath(String partitionPath) { - this.partitionPath = partitionPath; - return this; - } - - public Builder withEarliestCommitRetained(Option earliestCommitToRetain) { - this.earliestCommitToRetain = - (earliestCommitToRetain.isPresent()) ? earliestCommitToRetain.get().requestedTime() : ""; - return this; - } - - public Builder withLastCompletedCommitTimestamp(String lastCompletedCommitTimestamp) { - this.lastCompletedCommitTimestamp = lastCompletedCommitTimestamp; - return this; - } - - public Builder isPartitionDeleted(boolean isPartitionDeleted) { - this.isPartitionDeleted = isPartitionDeleted; - return this; - } - - public HoodieCleanStat build() { - return new HoodieCleanStat(policy, partitionPath, deletePathPatterns, successDeleteFiles, failedDeleteFiles, - earliestCommitToRetain, lastCompletedCommitTimestamp, deleteBootstrapBasePathPatterns, - successDeleteBootstrapBaseFiles, failedDeleteBootstrapBaseFiles, isPartitionDeleted); - } - } - - @Override - public String toString() { - return "HoodieCleanStat{" - + "policy=" + policy - + ", partitionPath='" + partitionPath + '\'' - + ", deletePathPatterns=" + deletePathPatterns - + ", successDeleteFiles=" + successDeleteFiles - + ", failedDeleteFiles=" + failedDeleteFiles - + ", earliestCommitToRetain='" + earliestCommitToRetain - + ", deleteBootstrapBasePathPatterns=" + deleteBootstrapBasePathPatterns - + ", successDeleteBootstrapBaseFiles=" + successDeleteBootstrapBaseFiles - + ", failedDeleteBootstrapBaseFiles=" + failedDeleteBootstrapBaseFiles - + ", isPartitionDeleted=" + isPartitionDeleted + '\'' - + '}'; - } + boolean partitionDeleted; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/HoodieJsonPayload.java b/hudi-common/src/main/java/org/apache/hudi/common/HoodieJsonPayload.java index e6667384f582b..463f773b82150 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/HoodieJsonPayload.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/HoodieJsonPayload.java @@ -20,10 +20,10 @@ import org.apache.hudi.avro.MercifulJsonConverter; import org.apache.hudi.common.model.HoodieRecordPayload; -import org.apache.hudi.common.schema.HoodieSchema; -import org.apache.hudi.io.util.FileIOUtils; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.util.Option; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.io.util.FileIOUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -65,7 +65,7 @@ public Option combineAndGetUpdateValue(IndexedRecord oldRec, Sche @Override public Option getInsertValue(Schema schema) throws IOException { MercifulJsonConverter jsonConverter = new MercifulJsonConverter(); - return Option.of(jsonConverter.convert(getJsonData(), HoodieSchema.fromAvroSchema(schema))); + return Option.of(jsonConverter.convert(getJsonData(), HoodieAvroSchemaCache.intern(schema))); } private String getJsonData() throws IOException { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/HoodiePendingRollbackInfo.java b/hudi-common/src/main/java/org/apache/hudi/common/HoodiePendingRollbackInfo.java index c53babf350102..44e61f651b8ce 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/HoodiePendingRollbackInfo.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/HoodiePendingRollbackInfo.java @@ -21,24 +21,16 @@ import org.apache.hudi.avro.model.HoodieRollbackPlan; import org.apache.hudi.common.table.timeline.HoodieInstant; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * Holds rollback instant and rollback plan for a pending rollback. */ +@AllArgsConstructor +@Getter public class HoodiePendingRollbackInfo { private final HoodieInstant rollbackInstant; private final HoodieRollbackPlan rollbackPlan; - - public HoodiePendingRollbackInfo(HoodieInstant rollbackInstant, HoodieRollbackPlan rollbackPlan) { - this.rollbackInstant = rollbackInstant; - this.rollbackPlan = rollbackPlan; - } - - public HoodieInstant getRollbackInstant() { - return rollbackInstant; - } - - public HoodieRollbackPlan getRollbackPlan() { - return rollbackPlan; - } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/HoodieRollbackStat.java b/hudi-common/src/main/java/org/apache/hudi/common/HoodieRollbackStat.java index 59308a43325c2..c9be79687854b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/HoodieRollbackStat.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/HoodieRollbackStat.java @@ -20,6 +20,9 @@ import org.apache.hudi.storage.StoragePathInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; + import java.io.Serializable; import java.util.Collections; import java.util.List; @@ -29,6 +32,8 @@ /** * Collects stats about a single partition clean operation. */ +@AllArgsConstructor +@Getter public class HoodieRollbackStat implements Serializable { // Partition path @@ -41,35 +46,6 @@ public class HoodieRollbackStat implements Serializable { private final Map logFilesFromFailedCommit; - public HoodieRollbackStat(String partitionPath, List successDeleteFiles, List failedDeleteFiles, - Map commandBlocksCount, Map logFilesFromFailedCommit) { - this.partitionPath = partitionPath; - this.successDeleteFiles = successDeleteFiles; - this.failedDeleteFiles = failedDeleteFiles; - this.commandBlocksCount = commandBlocksCount; - this.logFilesFromFailedCommit = logFilesFromFailedCommit; - } - - public Map getCommandBlocksCount() { - return commandBlocksCount; - } - - public String getPartitionPath() { - return partitionPath; - } - - public List getSuccessDeleteFiles() { - return successDeleteFiles; - } - - public List getFailedDeleteFiles() { - return failedDeleteFiles; - } - - public Map getLogFilesFromFailedCommit() { - return logFilesFromFailedCommit; - } - public static HoodieRollbackStat.Builder newBuilder() { return new Builder(); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalBloomFilter.java b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalBloomFilter.java index 7ef766a2a3c5a..af45cb0245c62 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalBloomFilter.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalBloomFilter.java @@ -82,21 +82,15 @@ * @see Space/Time Trade-Offs in Hash Coding with Allowable Errors */ public class InternalBloomFilter extends InternalFilter { - private static final byte[] BIT_VALUES = new byte[] { - (byte) 0x01, - (byte) 0x02, - (byte) 0x04, - (byte) 0x08, - (byte) 0x10, - (byte) 0x20, - (byte) 0x40, - (byte) 0x80 - }; - /** - * The bit vector. + * The bit vector, as little-endian 64-bit words: bit {@code i} lives at + * {@code words[i >> 6]} under mask {@code 1L << (i & 63)}. The serialized layout + * (bit {@code i} at byte {@code i >> 3} under mask {@code 1 << (i & 7)}) is the + * little-endian byte view of this array, so {@link #write} and {@link #readFields} + * translate between the two by byte position alone. Bits at positions greater than + * or equal to {@code vectorSize} are always zero. */ - BitSet bits; + long[] words; /** * Default constructor - use with readFields @@ -116,7 +110,7 @@ public InternalBloomFilter() { public InternalBloomFilter(int vectorSize, int nbHash, int hashType) { super(vectorSize, nbHash, hashType); - bits = new BitSet(this.vectorSize); + words = new long[wordCount(this.vectorSize)]; } /** @@ -134,7 +128,7 @@ public void add(Key key) { hash.clear(); for (int i = 0; i < nbHash; i++) { - bits.set(h[i]); + words[h[i] >>> 6] |= 1L << (h[i] & 63); } } @@ -147,7 +141,10 @@ public void and(InternalFilter filter) { throw new IllegalArgumentException("filters cannot be and-ed"); } - this.bits.and(((InternalBloomFilter) filter).bits); + long[] other = ((InternalBloomFilter) filter).words; + for (int i = 0; i < words.length; i++) { + words[i] &= other[i]; + } } @Override @@ -159,7 +156,7 @@ public boolean membershipTest(Key key) { int[] h = hash.hash(key); hash.clear(); for (int i = 0; i < nbHash; i++) { - if (!bits.get(h[i])) { + if ((words[h[i] >>> 6] & (1L << (h[i] & 63))) == 0) { return false; } } @@ -168,7 +165,10 @@ public boolean membershipTest(Key key) { @Override public void not() { - bits.flip(0, vectorSize); + for (int i = 0; i < words.length; i++) { + words[i] = ~words[i]; + } + clearUnusedBits(); } @Override @@ -179,7 +179,10 @@ public void or(InternalFilter filter) { || filter.nbHash != this.nbHash) { throw new IllegalArgumentException("filters cannot be or-ed"); } - bits.or(((InternalBloomFilter) filter).bits); + long[] other = ((InternalBloomFilter) filter).words; + for (int i = 0; i < words.length; i++) { + words[i] |= other[i]; + } } @Override @@ -190,12 +193,15 @@ public void xor(InternalFilter filter) { || filter.nbHash != this.nbHash) { throw new IllegalArgumentException("filters cannot be xor-ed"); } - bits.xor(((InternalBloomFilter) filter).bits); + long[] other = ((InternalBloomFilter) filter).words; + for (int i = 0; i < words.length; i++) { + words[i] ^= other[i]; + } } @Override public String toString() { - return bits.toString(); + return BitSet.valueOf(words).toString(); } /** @@ -209,17 +215,8 @@ public int getVectorSize() { public void write(DataOutput out) throws IOException { super.write(out); byte[] bytes = new byte[getNBytes()]; - for (int i = 0, byteIndex = 0, bitIndex = 0; i < vectorSize; i++, bitIndex++) { - if (bitIndex == 8) { - bitIndex = 0; - byteIndex++; - } - if (bitIndex == 0) { - bytes[byteIndex] = 0; - } - if (bits.get(i)) { - bytes[byteIndex] |= BIT_VALUES[bitIndex]; - } + for (int byteIndex = 0; byteIndex < bytes.length; byteIndex++) { + bytes[byteIndex] = (byte) (words[byteIndex >>> 3] >>> ((byteIndex & 7) << 3)); } out.write(bytes); } @@ -227,22 +224,32 @@ public void write(DataOutput out) throws IOException { @Override public void readFields(DataInput in) throws IOException { super.readFields(in); - bits = new BitSet(this.vectorSize); + words = new long[wordCount(vectorSize)]; byte[] bytes = new byte[getNBytes()]; in.readFully(bytes); - for (int i = 0, byteIndex = 0, bitIndex = 0; i < vectorSize; i++, bitIndex++) { - if (bitIndex == 8) { - bitIndex = 0; - byteIndex++; - } - if ((bytes[byteIndex] & BIT_VALUES[bitIndex]) != 0) { - bits.set(i); - } + for (int byteIndex = 0; byteIndex < bytes.length; byteIndex++) { + words[byteIndex >>> 3] |= (bytes[byteIndex] & 0xFFL) << ((byteIndex & 7) << 3); } + clearUnusedBits(); } /* @return number of bytes needed to hold bit vector */ private int getNBytes() { return (int) (((long) vectorSize + 7) / 8); } + + private static int wordCount(int vectorSize) { + return (vectorSize + 63) >>> 6; + } + + /** + * Clears bits at positions greater than or equal to {@code vectorSize}, such as the unused + * trailing bits of the last serialized byte, so bitwise ops and serialization stay exact. + */ + private void clearUnusedBits() { + int usedBitsInLastWord = vectorSize & 63; + if (usedBitsInLastWord != 0) { + words[words.length - 1] &= (1L << usedBitsInLastWord) - 1; + } + } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalDynamicBloomFilter.java b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalDynamicBloomFilter.java index bf35aeaee61de..fa14e2976f43c 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalDynamicBloomFilter.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalDynamicBloomFilter.java @@ -18,6 +18,8 @@ package org.apache.hudi.common.bloom; +import lombok.NoArgsConstructor; + import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; @@ -27,6 +29,7 @@ * with bounds on maximum number of entries. Once the max entries is reached, false positive guarantees are not * honored. */ +@NoArgsConstructor class InternalDynamicBloomFilter extends InternalFilter { /** @@ -47,12 +50,6 @@ class InternalDynamicBloomFilter extends InternalFilter { */ private InternalBloomFilter[] matrix; - /** - * Zero-args constructor for the serialization. - */ - public InternalDynamicBloomFilter() { - } - /** * Constructor. *

diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalFilter.java b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalFilter.java index e23255bb4b616..c5b45e7877a8d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalFilter.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bloom/InternalFilter.java @@ -20,6 +20,9 @@ import org.apache.hudi.common.util.hash.Hash; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; @@ -42,6 +45,7 @@ * @see Key The general behavior of a key * @see HashFunction A hash function */ +@NoArgsConstructor(access = AccessLevel.PROTECTED) abstract class InternalFilter { private static final int VERSION = -1; // negative to accommodate for old format /** @@ -64,9 +68,6 @@ abstract class InternalFilter { */ protected int hashType; - protected InternalFilter() { - } - /** * Constructor. * diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bloom/Key.java b/hudi-common/src/main/java/org/apache/hudi/common/bloom/Key.java index 013c0f08ea46f..ed8096edea346 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bloom/Key.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bloom/Key.java @@ -20,6 +20,7 @@ package org.apache.hudi.common.bloom; import lombok.Getter; +import lombok.NoArgsConstructor; import java.io.DataInput; import java.io.DataOutput; @@ -33,6 +34,7 @@ * @see InternalBloomFilter The general behavior of a bloom filter and how the key is used. */ @Getter +@NoArgsConstructor public class Key implements Comparable { /** * Byte value of key @@ -47,12 +49,6 @@ public class Key implements Comparable { */ double weight; - /** - * default constructor - use with readFields - */ - public Key() { - } - /** * Constructor. *

diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndex.java b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndex.java index fd342288cf011..79feafbc47847 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndex.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndex.java @@ -30,8 +30,8 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; @@ -49,12 +49,11 @@ * on these index files to manage multiple file-groups. */ +@Slf4j public class HFileBootstrapIndex extends BootstrapIndex { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HFileBootstrapIndex.class); - public static final String BOOTSTRAP_INDEX_FILE_ID = "00000000-0000-0000-0000-000000000000-0"; private static final String PARTITION_KEY_PREFIX = "part"; @@ -68,6 +67,7 @@ public class HFileBootstrapIndex extends BootstrapIndex { public static final String INDEX_INFO_KEY_STRING = "INDEX_INFO"; public static final byte[] INDEX_INFO_KEY = getUTF8Bytes(INDEX_INFO_KEY_STRING); + @Getter private final boolean isPresent; public HFileBootstrapIndex(HoodieTableMetaClient metaClient) { @@ -152,7 +152,7 @@ public void dropIndex() { StoragePath[] indexPaths = new StoragePath[] {partitionIndexPath(metaClient), fileIdIndexPath(metaClient)}; for (StoragePath indexPath : indexPaths) { if (metaClient.getStorage().exists(indexPath)) { - LOG.info("Dropping bootstrap index. Deleting file: {}", indexPath); + log.info("Dropping bootstrap index. Deleting file: {}", indexPath); metaClient.getStorage().deleteDirectory(indexPath); } } @@ -160,9 +160,4 @@ public void dropIndex() { throw new HoodieIOException(ioe.getMessage(), ioe); } } - - @Override - public boolean isPresent() { - return isPresent; - } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexReader.java b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexReader.java index 53debdb71ba06..320b365e35190 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexReader.java @@ -38,8 +38,8 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.ArrayList; @@ -59,10 +59,11 @@ /** * HFile Based Index Reader. */ +@Slf4j public class HFileBootstrapIndexReader extends BootstrapIndex.IndexReader { - private static final Logger LOG = LoggerFactory.getLogger(HFileBootstrapIndexReader.class); // Base Path of external files. + @Getter private final String bootstrapBasePath; // Well Known Paths for indices private final String indexByPartitionPath; @@ -83,7 +84,7 @@ public HFileBootstrapIndexReader(HoodieTableMetaClient metaClient) { this.indexByFileIdPath = indexByFilePath.toString(); initIndexInfo(); this.bootstrapBasePath = bootstrapIndexInfo.getBootstrapBasePath(); - LOG.info("Loaded HFileBasedBootstrapIndex with source base path :" + bootstrapBasePath); + log.info("Loaded HFileBasedBootstrapIndex with source base path :{}", bootstrapBasePath); } /** @@ -93,7 +94,7 @@ public HFileBootstrapIndexReader(HoodieTableMetaClient metaClient) { * @param storage {@link HoodieStorage} instance. */ private static HFileReader createReader(String hFilePath, HoodieStorage storage) throws IOException { - LOG.info("Opening HFile for reading :" + hFilePath); + log.info("Opening HFile for reading :{}", hFilePath); StoragePath path = new StoragePath(hFilePath); long fileSize = storage.getPathInfo(path).getLength(); SeekableDataInputStream stream = storage.openSeekable(path, false); @@ -118,7 +119,7 @@ private HoodieBootstrapIndexInfo fetchBootstrapIndexInfo() throws IOException { private synchronized HFileReader partitionIndexReader() throws IOException { if (indexByPartitionReader == null) { - LOG.info("Opening partition index :" + indexByPartitionPath); + log.info("Opening partition index :{}", indexByPartitionPath); this.indexByPartitionReader = createReader(indexByPartitionPath, metaClient.getStorage()); } return indexByPartitionReader; @@ -126,7 +127,7 @@ private synchronized HFileReader partitionIndexReader() throws IOException { private synchronized HFileReader fileIdIndexReader() throws IOException { if (indexByFileIdReader == null) { - LOG.info("Opening fileId index :" + indexByFileIdPath); + log.info("Opening fileId index :{}", indexByFileIdPath); this.indexByFileIdReader = createReader(indexByFileIdPath, metaClient.getStorage()); } return indexByFileIdReader; @@ -181,7 +182,7 @@ public List getSourceFileMappingForPartition(String partit .map(e -> new BootstrapFileMapping(bootstrapBasePath, metadata.getBootstrapPartitionPath(), e.getValue(), partition, e.getKey())).collect(Collectors.toList()); } else { - LOG.warn("No value found for partition key ({})", partition); + log.warn("No value found for partition key ({})", partition); return new ArrayList<>(); } } catch (IOException ioe) { @@ -189,11 +190,6 @@ public List getSourceFileMappingForPartition(String partit } } - @Override - public String getBootstrapBasePath() { - return bootstrapBasePath; - } - @Override public Map getSourceFileMappingForFileIds( List ids) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexWriter.java b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexWriter.java index bcd063ff5d0d0..8fd232a87402a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexWriter.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexWriter.java @@ -35,8 +35,7 @@ import org.apache.hudi.io.hfile.HFileWriterImpl; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.io.OutputStream; @@ -53,8 +52,8 @@ import static org.apache.hudi.common.bootstrap.index.hfile.HFileBootstrapIndex.getPartitionKey; import static org.apache.hudi.common.bootstrap.index.hfile.HFileBootstrapIndex.partitionIndexPath; +@Slf4j public class HFileBootstrapIndexWriter extends BootstrapIndex.IndexWriter { - private static final Logger LOG = LoggerFactory.getLogger(HFileBootstrapIndexWriter.class); private final String bootstrapBasePath; private final StoragePath indexByPartitionPath; @@ -80,7 +79,7 @@ public HFileBootstrapIndexWriter(String bootstrapBasePath, HoodieTableMetaClient || metaClient.getStorage().exists(indexByFileIdPath)) { String errMsg = "Previous version of bootstrap index exists. Partition Index Path :" + indexByPartitionPath + ", FileId index Path :" + indexByFileIdPath; - LOG.info(errMsg); + log.info(errMsg); throw new HoodieException(errMsg); } } catch (IOException ioe) { @@ -97,9 +96,9 @@ public HFileBootstrapIndexWriter(String bootstrapBasePath, HoodieTableMetaClient private void writeNextPartition(String partitionPath, String bootstrapPartitionPath, List bootstrapFileMappings) { try { - LOG.info("Adding bootstrap partition Index entry for partition :" + partitionPath - + ", bootstrap Partition :" + bootstrapPartitionPath + ", Num Entries :" + bootstrapFileMappings.size()); - LOG.info("ADDING entries :" + bootstrapFileMappings); + log.info("Adding bootstrap partition Index entry for partition :{}, bootstrap Partition :{}, Num Entries :{}", + partitionPath, bootstrapPartitionPath, bootstrapFileMappings.size()); + log.info("ADDING entries :{}", bootstrapFileMappings); HoodieBootstrapPartitionMetadata bootstrapPartitionMetadata = new HoodieBootstrapPartitionMetadata(); bootstrapPartitionMetadata.setBootstrapPartitionPath(bootstrapPartitionPath); bootstrapPartitionMetadata.setPartitionPath(partitionPath); @@ -148,14 +147,14 @@ private void commit() { .setNumKeys(numPartitionKeysAdded) .setBootstrapBasePath(bootstrapBasePath) .build(); - LOG.info("Adding Partition FileInfo :" + partitionIndexInfo); + log.info("Adding Partition FileInfo :{}", partitionIndexInfo); HoodieBootstrapIndexInfo fileIdIndexInfo = HoodieBootstrapIndexInfo.newBuilder() .setCreatedTimestamp(new Date().getTime()) .setNumKeys(numFileIdKeysAdded) .setBootstrapBasePath(bootstrapBasePath) .build(); - LOG.info("Appending FileId FileInfo :" + fileIdIndexInfo); + log.info("Appending FileId FileInfo :{}", fileIdIndexInfo); indexByPartitionWriter.appendFileInfo( INDEX_INFO_KEY_STRING, diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigGroups.java b/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigGroups.java index f4b6bffd80807..3ebc61a2de79d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigGroups.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigGroups.java @@ -18,6 +18,10 @@ package org.apache.hudi.common.config; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * In Hudi, we have multiple superclasses, aka Config Classes of {@link HoodieConfig} that maintain * several configs. This class group one or more of these superclasses into higher @@ -29,6 +33,7 @@ public class ConfigGroups { * Config group names. Please add the description of each group in * {@link ConfigGroups#getDescription}. */ + @AllArgsConstructor(access = AccessLevel.PACKAGE) public enum Names { TABLE_CONFIG("Hudi Table Config"), ENVIRONMENT_CONFIG("Environment Config"), @@ -44,12 +49,9 @@ public enum Names { HUDI_STREAMER("Hudi Streamer Configs"); public final String name; - - Names(String name) { - this.name = name; - } } + @AllArgsConstructor(access = AccessLevel.PACKAGE) public enum SubGroupNames { INDEX( "Index Configs", @@ -80,16 +82,8 @@ public enum SubGroupNames { "No subgroup. This description should be hidden."); public final String name; + @Getter private final String description; - - SubGroupNames(String name, String description) { - this.name = name; - this.description = description; - } - - public String getDescription() { - return description; - } } public static String getDescription(Names names) { @@ -136,6 +130,10 @@ public static String getDescription(Names names) { + "write schema, cleaning etc. Although Hudi provides sane defaults, from time-time " + "these configs may need to be tweaked to optimize for specific workloads."; break; + case READER: + description = "These set of configs control the behavior of reading Hudi tables, " + + "such as file group reading."; + break; case META_SYNC: description = "Configurations used by the Hudi to sync metadata to external metastores and catalogs."; break; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigProperty.java b/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigProperty.java index 56a087f1e7ea1..b93d980eafc12 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigProperty.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/ConfigProperty.java @@ -22,6 +22,12 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.exception.HoodieException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.experimental.Accessors; + import java.io.Serializable; import java.lang.reflect.Field; import java.util.Arrays; @@ -41,14 +47,21 @@ * * @param The type of the default value. */ +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@Getter public class ConfigProperty implements Serializable { + @NonNull + @Accessors(fluent = true) // Required so that #key() is generated instead of #getKey() by Lombok private final String key; + @Getter(AccessLevel.NONE) private final T defaultValue; + @Getter(AccessLevel.NONE) private final String docOnDefaultValue; + @Getter(AccessLevel.NONE) private final String doc; private final Option sinceVersion; @@ -57,37 +70,16 @@ public class ConfigProperty implements Serializable { private final List supportedVersions; + // provide the ability to infer config value based on other configs + private final Option>> inferFunction; + + @Getter(AccessLevel.NONE) private final Set validValues; private final boolean advanced; private final String[] alternatives; - // provide the ability to infer config value based on other configs - private final Option>> inferFunction; - - ConfigProperty(String key, T defaultValue, String docOnDefaultValue, String doc, - Option sinceVersion, Option deprecatedVersion, - List supportedVersions, - Option>> inferFunc, Set validValues, - boolean advanced, String... alternatives) { - this.key = Objects.requireNonNull(key); - this.defaultValue = defaultValue; - this.docOnDefaultValue = docOnDefaultValue; - this.doc = doc; - this.sinceVersion = sinceVersion; - this.deprecatedVersion = deprecatedVersion; - this.supportedVersions = supportedVersions; - this.inferFunction = inferFunc; - this.validValues = validValues; - this.advanced = advanced; - this.alternatives = alternatives; - } - - public String key() { - return key; - } - public T defaultValue() { if (defaultValue == null) { throw new HoodieException(String.format("There's no default value for this config: %s", key)); @@ -108,26 +100,10 @@ public String doc() { return StringUtils.isNullOrEmpty(doc) ? StringUtils.EMPTY_STRING : doc; } - public Option getSinceVersion() { - return sinceVersion; - } - - public Option getDeprecatedVersion() { - return deprecatedVersion; - } - - public List getSupportedVersions() { - return supportedVersions; - } - public boolean hasInferFunction() { return getInferFunction().isPresent(); } - public Option>> getInferFunction() { - return inferFunction; - } - public void checkValues(String value) { if (!isValid(value)) { throw new IllegalArgumentException( @@ -144,10 +120,6 @@ public List getAlternatives() { return Arrays.asList(alternatives); } - public boolean isAdvanced() { - return advanced; - } - public ConfigProperty withDocumentation(String doc) { Objects.requireNonNull(doc); return new ConfigProperty<>(key, defaultValue, docOnDefaultValue, doc, sinceVersion, deprecatedVersion, supportedVersions, inferFunction, validValues, advanced, alternatives); @@ -271,7 +243,7 @@ public ConfigProperty defaultValue(T value) { public ConfigProperty defaultValue(T value, String docOnDefaultValue) { Objects.requireNonNull(docOnDefaultValue); return new ConfigProperty<>(key, value, docOnDefaultValue, "", Option.empty(), - Option.empty(), Collections.emptyList(), Option.empty(), Collections.emptySet(), false); + Option.empty(), Collections.emptyList(), Option.empty(), Collections.emptySet(), false, new String[0]); } public ConfigProperty noDefaultValue() { @@ -280,7 +252,7 @@ public ConfigProperty noDefaultValue() { public ConfigProperty noDefaultValue(String docOnDefaultValue) { return new ConfigProperty<>(key, null, docOnDefaultValue, "", Option.empty(), - Option.empty(), Collections.emptyList(), Option.empty(), Collections.emptySet(), false); + Option.empty(), Collections.emptyList(), Option.empty(), Collections.emptySet(), false, new String[0]); } } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java index e488324af3412..0eaf9533a7373 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java @@ -83,6 +83,29 @@ public class HoodieCommonConfig extends HoodieConfig { + " operation will fail schema compatibility check. Set this option to true will make the missing " + " column be filled with null values to successfully complete the write operation."); + public static final ConfigProperty TIMESTAMP_LOGICAL_TYPE_OVERRIDES = ConfigProperty + .key("hoodie.write.timestamp.logical.type.overrides") + .defaultValue("") + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("Per-field authority for the timestamp logical type, taking precedence over the " + + "auto-inferred schema. Comma-separated 'field:type' pairs, where type is one of timestamp-micros, " + + "timestamp-millis, local-timestamp-micros, local-timestamp-millis (case-insensitive). A field with an " + + "entry is pinned to that logical type: an incoming value of a different precision is coerced to it, and " + + "the change from the table's current type is permitted. A timestamp precision change with no entry for " + + "the field is rejected with an error, so an unverified micros/millis flip can never happen silently. " + + "An entry also attaches a timestamp logical type (UTC or local) to a column persisted as a bare " + + "long, including one that 0.x stored without a logical type because its converter did not recognize " + + "it. A UTC/local zone change is never authorized by this config, whatever the entry says, since no " + + "rescale can express it. " + + "Derive the value from the stored longs, never from the incoming schema: for instants after 1990 an " + + "epoch-millis value is around 1e12 while epoch-micros is around 1e15, so the two ranges do not " + + "overlap. TimestampLogicalTypeClassifier implements that verdict for inspection tooling to reuse. " + + "NOTE: this corrects the table schema only. Existing base files keep the old logical type; Hudi " + + "readers compensate for it, but external engines (Trino, Athena, BigQuery external, Spark-native " + + "parquet) keep misreading those files until they are rewritten under the corrected schema via " + + "clustering or compaction. Treat this as a one-time migration: set the override, then rewrite."); + public static final ConfigProperty SPILLABLE_DISK_MAP_TYPE = ConfigProperty .key("hoodie.common.spillable.diskmap.type") .defaultValue(ExternalSpillableMap.DiskMapType.BITCASK) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieConfig.java index 498de3821666c..6e6668b0284c2 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieConfig.java @@ -24,8 +24,8 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.exception.HoodieException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.lang.reflect.Modifier; @@ -39,16 +39,16 @@ /** * This class deals with {@link ConfigProperty} and provides get/set functionalities. */ +@Slf4j public class HoodieConfig implements Serializable { - private static final Logger LOG = LoggerFactory.getLogger(HoodieConfig.class); - protected static final String CONFIG_VALUES_DELIMITER = ","; // Number of retries while reading the properties file to deal with parallel updates protected static final int MAX_READ_RETRIES = 5; // Delay between retries while reading the properties file protected static final int READ_RETRY_DELAY_MSEC = 1000; + @Getter protected TypedProperties props; public HoodieConfig() { @@ -246,10 +246,6 @@ public String getStringOrDefault(String key, String defaultVal) { return Option.ofNullable(props.getProperty(key)).orElse(defaultVal); } - public TypedProperties getProps() { - return props; - } - public TypedProperties getProps(boolean includeGlobalProps) { if (includeGlobalProps) { TypedProperties mergedProps = loadGlobalProperties(); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieIndexingConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieIndexingConfig.java index 0ab9158ad6e8c..6582d5ad91535 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieIndexingConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieIndexingConfig.java @@ -95,7 +95,7 @@ public class HoodieIndexingConfig extends HoodieConfig { .withDocumentation("Index definition checksum is used to guard against partial writes in HDFS. " + "It is added as the last entry in index.properties and then used to validate while reading table config."); - private static final String INDEX_DEFINITION_CHECKSUM_FORMAT = "%s.%s"; // . + private static final String INDEX_DEFINITION_CHECKSUM_FORMAT = "%s.%s"; // . public HoodieIndexingConfig() { super(); @@ -208,9 +208,9 @@ public static long generateChecksum(Properties props) { if (!props.containsKey(INDEX_NAME.key())) { throw new IllegalArgumentException(INDEX_NAME.key() + " property needs to be specified"); } - String table = props.getProperty(INDEX_NAME.key()); - String database = props.getProperty(INDEX_TYPE.key(), ""); - return BinaryUtil.generateChecksum(getUTF8Bytes(String.format(INDEX_DEFINITION_CHECKSUM_FORMAT, database, table))); + String indexName = props.getProperty(INDEX_NAME.key()); + String indexType = props.getProperty(INDEX_TYPE.key(), ""); + return BinaryUtil.generateChecksum(getUTF8Bytes(String.format(INDEX_DEFINITION_CHECKSUM_FORMAT, indexType, indexName))); } public static boolean validateChecksum(Properties props) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetadataConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetadataConfig.java index cd4d03ee4ee70..6daac88388549 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetadataConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetadataConfig.java @@ -195,6 +195,16 @@ public final class HoodieMetadataConfig extends HoodieConfig { .sinceVersion("0.7.0") .withDocumentation("Directories matching this regex, will be filtered out when initializing metadata table from lake storage for the first time."); + public static final ConfigProperty SKIP_ZERO_SIZE_FILES_ON_INITIALIZE = ConfigProperty + .key(METADATA_PREFIX + ".skip.zero.size.files.on.initialize") + .defaultValue(false) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("When enabled, zero-size data files encountered while listing the data table during " + + "metadata table initialization and restore sync are skipped instead of being recorded in the metadata " + + "table. Skipped files remain on storage and are not tracked by the metadata table or the cleaner; " + + "remove them manually. The metadata validator will report them as inconsistencies."); + public static final ConfigProperty FILE_LISTING_PARALLELISM_VALUE = ConfigProperty .key("hoodie.file.listing.parallelism") .defaultValue(200) @@ -683,6 +693,15 @@ public final class HoodieMetadataConfig extends HoodieConfig { + "with the actual record count stored in the metadata table. This validation runs in a distributed manner " + "using the compute engine. Disabled by default as it adds overhead to the initialization process."); + public static final ConfigProperty ENABLE_DETAILED_METRICS = ConfigProperty + .key(METADATA_PREFIX + ".enable.detailed.metrics") + .defaultValue(false) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("Enables detailed metadata table metrics — per-metadata-partition file size and base/log " + + "file counts. Emitting these requires building a HoodieTableFileSystemView for the metadata table on " + + "the driver, which adds memory pressure at scale; leave disabled unless you need the breakdown."); + public long getMaxLogFileSize() { return getLong(MAX_LOG_FILE_SIZE_BYTES_PROP); } @@ -791,6 +810,10 @@ public String getDirectoryFilterRegex() { return getString(DIR_FILTER_REGEX); } + public boolean shouldSkipZeroSizeFilesOnInitialize() { + return getBoolean(SKIP_ZERO_SIZE_FILES_ON_INITIALIZE); + } + public boolean shouldIgnoreSpuriousDeletes() { return getBoolean(IGNORE_SPURIOUS_DELETES); } @@ -1020,6 +1043,10 @@ public boolean isDropMetadataIndex(String indexName) { return subIndexNameToDrop.contains(indexName); } + public boolean isDetailedMetricsEnabled() { + return getBoolean(ENABLE_DETAILED_METRICS); + } + public static class Builder { private EngineType engineType = EngineType.SPARK; @@ -1160,6 +1187,11 @@ public Builder withDirectoryFilterRegex(String regex) { return this; } + public Builder withSkipZeroSizeFilesOnInitialize(boolean skipZeroSizeFiles) { + metadataConfig.setValue(SKIP_ZERO_SIZE_FILES_ON_INITIALIZE, String.valueOf(skipZeroSizeFiles)); + return this; + } + public Builder ignoreSpuriousDeletes(boolean validateMetadataPayloadConsistency) { metadataConfig.setValue(IGNORE_SPURIOUS_DELETES, String.valueOf(validateMetadataPayloadConsistency)); return this; @@ -1349,6 +1381,11 @@ public Builder withAutoDeletePartitions(boolean autoDeletePartitions) { return this; } + public Builder enableDetailedMetadataMetrics(boolean enable) { + metadataConfig.setValue(ENABLE_DETAILED_METRICS, String.valueOf(enable)); + return this; + } + public HoodieMetadataConfig build() { metadataConfig.setDefaultValue(ENABLE, getDefaultMetadataEnable(engineType)); metadataConfig.setDefaultValue(ENABLE_METADATA_INDEX_COLUMN_STATS, getDefaultColStatsEnable(engineType)); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java index 9cbab8f4468cd..a1ecf64dc419b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java @@ -112,8 +112,9 @@ public class HoodieReaderConfig extends HoodieConfig { .sinceVersion("1.2.0") .withValidValues(BLOB_INLINE_READ_MODE_CONTENT, BLOB_INLINE_READ_MODE_DESCRIPTOR) .withDocumentation("How Hudi interprets INLINE BLOB values on read. " - + "DESCRIPTOR (default) returns an OUT_OF_LINE-shaped reference pointing at the " - + "backing Lance file with the INLINE payload's position and size, so callers can " - + "skip the byte content read. " - + "CONTENT returns the raw inline bytes directly in the data field on every read."); + + "DESCRIPTOR (default) returns an OUT_OF_LINE-shaped reference (position and size) into " + + "the backing file, skipping the byte read. CONTENT returns the raw inline bytes in the " + + "data field. Materializing INLINE bytes via read_blob() requires CONTENT; under " + + "DESCRIPTOR it fails fast asking for CONTENT. Pass this as a read option, not a session " + + "config. OUT_OF_LINE blobs ignore this option."); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieTableServiceManagerConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieTableServiceManagerConfig.java index a2cef4558e1b6..7ffa5ef6099fa 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieTableServiceManagerConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieTableServiceManagerConfig.java @@ -95,7 +95,7 @@ public class HoodieTableServiceManagerConfig extends HoodieConfig { public static final ConfigProperty TABLE_SERVICE_MANAGER_DEPLOY_EXTRA_PARAMS = ConfigProperty .key(TABLE_SERVICE_MANAGER_PREFIX + ".deploy.extra.params") - .noDefaultValue() + .defaultValue("") .markAdvanced() .sinceVersion("0.13.0") .withDocumentation("The extra params to deploy for table service of this table, split by ';'"); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/conflict/detection/DirectMarkerBasedDetectionStrategy.java b/hudi-common/src/main/java/org/apache/hudi/common/conflict/detection/DirectMarkerBasedDetectionStrategy.java index 4b11a348bb059..466b8c99769e3 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/conflict/detection/DirectMarkerBasedDetectionStrategy.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/conflict/detection/DirectMarkerBasedDetectionStrategy.java @@ -30,8 +30,8 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.List; @@ -42,10 +42,10 @@ * This abstract strategy is used for direct marker writers, trying to do early conflict detection. */ @PublicAPIClass(maturity = ApiMaturityLevel.EVOLVING) +@AllArgsConstructor +@Slf4j public abstract class DirectMarkerBasedDetectionStrategy implements EarlyConflictDetectionStrategy { - private static final Logger LOG = LoggerFactory.getLogger(DirectMarkerBasedDetectionStrategy.class); - protected final HoodieStorage storage; protected final String partitionPath; protected final String fileId; @@ -53,17 +53,6 @@ public abstract class DirectMarkerBasedDetectionStrategy implements EarlyConflic protected final HoodieActiveTimeline activeTimeline; protected final HoodieConfig config; - public DirectMarkerBasedDetectionStrategy(HoodieStorage storage, String partitionPath, String fileId, - String instantTime, - HoodieActiveTimeline activeTimeline, HoodieConfig config) { - this.storage = storage; - this.partitionPath = partitionPath; - this.fileId = fileId; - this.instantTime = instantTime; - this.activeTimeline = activeTimeline; - this.config = config; - } - /** * We need to do list operation here. * In order to reduce the list pressure as much as possible, first we build path prefix in advance: @@ -106,7 +95,7 @@ public boolean checkMarkerConflict(String basePath, long maxAllowableHeartbeatIn }).count(); if (res != 0L) { - LOG.warn("Detected conflict marker files: {}/{} for {}", partitionPath, fileId, instantTime); + log.warn("Detected conflict marker files: {}/{} for {}", partitionPath, fileId, instantTime); return true; } return false; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieBaseListData.java b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieBaseListData.java index b603b99d93022..f31a0072ab9de 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieBaseListData.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieBaseListData.java @@ -21,8 +21,9 @@ import org.apache.hudi.common.util.Either; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.util.Iterator; import java.util.List; @@ -34,6 +35,7 @@ * * @param Object value type. */ +@Slf4j public abstract class HoodieBaseListData { protected final Either, List> data; @@ -86,13 +88,11 @@ protected List collectAsList() { } } + @AllArgsConstructor(access = AccessLevel.PACKAGE) + @Slf4j static class IteratorCloser implements Runnable { - private static final Logger LOG = LoggerFactory.getLogger(IteratorCloser.class); - private final Iterator iterator; - IteratorCloser(Iterator iterator) { - this.iterator = iterator; - } + private final Iterator iterator; @Override public void run() { @@ -100,7 +100,7 @@ public void run() { try { ((AutoCloseable) iterator).close(); } catch (Exception ex) { - LOG.warn("Failed to properly close iterator", ex); + log.warn("Failed to properly close iterator", ex); } } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/engine/ExecutorServiceBasedEngineContext.java b/hudi-common/src/main/java/org/apache/hudi/common/engine/ExecutorServiceBasedEngineContext.java new file mode 100644 index 0000000000000..c6ba0d736acf2 --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/ExecutorServiceBasedEngineContext.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.hudi.common.engine; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.data.HoodieAccumulator; +import org.apache.hudi.common.data.HoodieAtomicLongAccumulator; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.data.HoodieData.HoodieDataCacheKey; +import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.data.HoodieListPairData; +import org.apache.hudi.common.data.HoodiePairData; +import org.apache.hudi.common.function.FunctionWrapper; +import org.apache.hudi.common.function.SerializableBiFunction; +import org.apache.hudi.common.function.SerializableConsumer; +import org.apache.hudi.common.function.SerializableFunction; +import org.apache.hudi.common.function.SerializablePairFlatMapFunction; +import org.apache.hudi.common.function.SerializablePairFunction; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.util.Functions; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ImmutablePair; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.keygen.KeyGenerator; +import org.apache.hudi.storage.StorageConfiguration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A general-purpose {@link HoodieEngineContext} that executes all parallel operations on a + * dedicated classloader-aware {@link ExecutorService}, so classes resolved by the application + * classloader remain visible to worker threads on Java 11+. + * + *

The pool is lazily created once per JVM (JLS §12.4.2) and shared across all instances. + * Worker threads are daemon threads and carry the classloader of this class, preventing + * {@code ClassNotFoundException} that occurs when the common {@link ForkJoinPool} is used + * because its workers do not inherit the submitting thread's context classloader. + */ +public class ExecutorServiceBasedEngineContext extends HoodieEngineContext { + + private static final Logger LOG = LoggerFactory.getLogger(ExecutorServiceBasedEngineContext.class); + + // Lazy-initialized, daemon, fixed thread pool whose workers carry the correct classloader. + // JLS §12.4.2 guarantees thread-safe initialization via class-loading locks. + private static class PoolHolder { + static final ExecutorService INSTANCE = createExecutorService(); + } + + private static ExecutorService createExecutorService() { + int parallelism = ForkJoinPool.commonPool().getParallelism(); + ClassLoader cl = ExecutorServiceBasedEngineContext.class.getClassLoader(); + ExecutorService executor = Executors.newFixedThreadPool(parallelism, r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setContextClassLoader(cl); + t.setDaemon(true); + return t; + }); + LOG.info("Created ExecutorServiceBasedEngineContext pool with {} threads", parallelism); + return executor; + } + + public ExecutorServiceBasedEngineContext(StorageConfiguration conf) { + super(conf, new LocalTaskContextSupplier()); + } + + // ---- Core parallel helpers ---- + + /** + * Submits each element to the executor pool and collects results in input order. + * RuntimeExceptions / Errors from {@code func} are re-thrown as-is after unwrapping + * the {@link CompletionException} wrapper. + */ + private List mapAsync(List data, Function func) { + List> futures = data.stream() + .map(item -> CompletableFuture.supplyAsync(() -> func.apply(item), PoolHolder.INSTANCE)) + .collect(Collectors.toList()); + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } catch (CompletionException e) { + throw rethrowUnwrapped(e); + } + return futures.stream().map(CompletableFuture::join).collect(Collectors.toList()); + } + + private static RuntimeException rethrowUnwrapped(CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw e; + } + + // ---- HoodieEngineContext implementations ---- + + @Override + public HoodieAccumulator newAccumulator() { + return HoodieAtomicLongAccumulator.create(); + } + + @Override + public HoodieData emptyHoodieData() { + return HoodieListData.eager(Collections.emptyList()); + } + + @Override + public HoodiePairData emptyHoodiePairData() { + return HoodieListPairData.eager(Collections.emptyList()); + } + + @Override + public HoodieData parallelize(List data, int parallelism) { + return HoodieListData.eager(data); + } + + @Override + public List map(List data, SerializableFunction func, int parallelism) { + return mapAsync(data, FunctionWrapper.throwingMapWrapper(func)); + } + + @Override + public List mapToPairAndReduceByKey(List data, SerializablePairFunction mapToPairFunc, + SerializableBiFunction reduceFunc, int parallelism) { + List> pairs = mapAsync(data, FunctionWrapper.throwingMapToPairWrapper(mapToPairFunc)); + return pairs.stream() + .collect(Collectors.groupingBy(Pair::getKey)).values().stream() + .map(list -> list.stream().map(Pair::getValue) + .reduce(FunctionWrapper.throwingReduceWrapper(reduceFunc)).orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Override + public Stream> mapPartitionsToPairAndReduceByKey( + Stream data, SerializablePairFlatMapFunction, K, V> flatMapToPairFunc, + SerializableBiFunction reduceFunc, int parallelism) { + try { + return CompletableFuture.supplyAsync(() -> + FunctionWrapper.throwingFlatMapToPairWrapper(flatMapToPairFunc).apply(data.iterator()) + .collect(Collectors.groupingBy(Pair::getKey)).entrySet().stream() + .map(entry -> new ImmutablePair<>(entry.getKey(), + entry.getValue().stream().map(Pair::getValue) + .reduce(FunctionWrapper.throwingReduceWrapper(reduceFunc)).orElse(null))) + .filter(Objects::nonNull), + PoolHolder.INSTANCE).join(); + } catch (CompletionException e) { + throw rethrowUnwrapped(e); + } + } + + @Override + public List reduceByKey(List> data, SerializableBiFunction reduceFunc, + int parallelism) { + // Group by key (sequential), then reduce each group in parallel on the executor. + Map> grouped = data.stream() + .collect(Collectors.groupingBy(Pair::getKey, + Collectors.mapping(Pair::getValue, Collectors.toList()))); + return mapAsync(new ArrayList<>(grouped.entrySet()), + entry -> entry.getValue().stream() + .reduce(FunctionWrapper.throwingReduceWrapper(reduceFunc)).orElse(null)) + .stream().filter(Objects::nonNull).collect(Collectors.toList()); + } + + @Override + public List flatMap(List data, SerializableFunction> func, int parallelism) { + return mapAsync(data, FunctionWrapper.throwingFlatMapWrapper(func)) + .stream().flatMap(s -> s).collect(Collectors.toList()); + } + + @Override + public void foreach(List data, SerializableConsumer consumer, int parallelism) { + List> futures = data.stream() + .map(item -> CompletableFuture.runAsync( + () -> FunctionWrapper.throwingForeachWrapper(consumer).accept(item), PoolHolder.INSTANCE)) + .collect(Collectors.toList()); + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } catch (CompletionException e) { + throw rethrowUnwrapped(e); + } + } + + @Override + public Map mapToPair(List data, SerializablePairFunction func, Integer parallelism) { + return mapAsync(data, FunctionWrapper.throwingMapToPairWrapper(func)) + .stream().collect(Collectors.toMap(Pair::getLeft, Pair::getRight, (oldVal, newVal) -> newVal)); + } + + @Override + public void setProperty(EngineProperty key, String value) { + // no operation + } + + @Override + public Option getProperty(EngineProperty key) { + return Option.empty(); + } + + @Override + public void setJobStatus(String activeModule, String activityDescription) { + // no operation + } + + @Override + public void clearJobStatus() { + // no operation + } + + @Override + public void putCachedDataIds(HoodieDataCacheKey cacheKey, int... ids) { + // no operation + } + + @Override + public List getCachedDataIds(HoodieDataCacheKey cacheKey) { + return Collections.emptyList(); + } + + @Override + public List removeCachedDataIds(HoodieDataCacheKey cacheKey) { + return Collections.emptyList(); + } + + @Override + public void cancelJob(String jobId) { + // no operation + } + + @Override + public void cancelAllJobs() { + // no operation + } + + @Override + public O aggregate(HoodieData data, O zeroValue, Functions.Function2 seqOp, + Functions.Function2 combOp) { + return data.collectAsList().stream().reduce(zeroValue, seqOp::apply, combOp::apply); + } + + @Override + @SuppressWarnings("unchecked") + public ReaderContextFactory getReaderContextFactory(HoodieTableMetaClient metaClient) { + return (ReaderContextFactory) getEngineReaderContextFactory(metaClient); + } + + @Override + public ReaderContextFactory getEngineReaderContextFactory(HoodieTableMetaClient metaClient) { + return new AvroReaderContextFactory(metaClient, new TypedProperties()); + } + + @Override + public KeyGenerator createKeyGenerator(TypedProperties props) throws IOException { + throw new UnsupportedOperationException("Not yet implemented"); + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieEngineContext.java b/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieEngineContext.java index cf2893e1249c0..eea766b56dda0 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieEngineContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieEngineContext.java @@ -41,7 +41,11 @@ import org.apache.hudi.keygen.KeyGenerator; import org.apache.hudi.storage.StorageConfiguration; +import lombok.AllArgsConstructor; +import lombok.Getter; + import java.io.IOException; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -51,6 +55,8 @@ * Base class contains the context information needed by the engine at runtime. It will be extended by different * engine implementation if needed. */ +@AllArgsConstructor +@Getter public abstract class HoodieEngineContext { /** @@ -60,19 +66,6 @@ public abstract class HoodieEngineContext { protected TaskContextSupplier taskContextSupplier; - public HoodieEngineContext(StorageConfiguration storageConf, TaskContextSupplier taskContextSupplier) { - this.storageConf = storageConf; - this.taskContextSupplier = taskContextSupplier; - } - - public StorageConfiguration getStorageConf() { - return storageConf; - } - - public TaskContextSupplier getTaskContextSupplier() { - return taskContextSupplier; - } - public abstract HoodieAccumulator newAccumulator(); public abstract HoodieData emptyHoodieData(); @@ -125,6 +118,21 @@ public abstract List reduceByKey( public abstract void cancelAllJobs(); + /** + * Returns engine-specific properties to be included in commit metadata for debugging. + *

Contract: + *

    + *
  • Implementations must only return safe, non-sensitive values (no credentials, no PII).
  • + *
  • This is invoked on the driver, on every commit. It must be cheap and free of side effects.
  • + *
  • Must not reach into checkpoint / runtime state (e.g. for streaming engines like Flink, + * per-checkpoint metadata is set up via the coordinator, not here).
  • + *
+ * Default returns an empty map so external subclasses are not forced to implement this. + */ + public Map getEngineProperties() { + return Collections.emptyMap(); + } + /** * Returns the application id of the engine (e.g. Spark application id). * Used to populate lock metadata so lock holders can be identified. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieReaderContext.java b/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieReaderContext.java index 953014802cf22..14e412ffcb184 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieReaderContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/HoodieReaderContext.java @@ -53,6 +53,10 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + import java.io.IOException; import java.util.List; import java.util.Map; @@ -77,28 +81,53 @@ * and {@code RowData} in Flink. */ public abstract class HoodieReaderContext { + + @Getter private final StorageConfiguration storageConfiguration; protected final HoodieFileFormat baseFileFormat; // For general predicate pushdown. + @Getter protected final Option keyFilterOpt; protected final HoodieTableConfig tableConfig; + @Setter private String tablePath = null; + @Getter + @Setter private String latestCommitTime = null; + @Getter + @Setter private Option recordMerger = null; + @Getter + @Setter private Boolean hasLogFiles = null; + @Getter + @Setter private Boolean hasBootstrapBaseFile = null; + @Getter + @Setter private Boolean needsBootstrapMerge = null; + @Getter + @Setter // should we do position based merging for mor private Boolean shouldMergeUseRecordPosition = null; protected Option instantRangeOpt; + @Getter private RecordMergeMode mergeMode; + @Getter protected RecordContext recordContext; + @Getter + @Setter private FileGroupReaderSchemaHandler schemaHandler = null; // the default iterator mode is engine-specific record mode + @Setter private IteratorMode iteratorMode = IteratorMode.ENGINE_RECORD; + @Getter protected final HoodieConfig hoodieReaderConfig; - private boolean enableLogicalTimestampFieldRepair = true; + @Getter + @Setter + @Accessors(fluent = true) + private Boolean enableLogicalTimestampFieldRepair = true; protected HoodieReaderContext(StorageConfiguration storageConfiguration, HoodieTableConfig tableConfig, @@ -123,19 +152,6 @@ protected HoodieReaderContext(StorageConfiguration storageConfiguration, this.hoodieReaderConfig = hoodieReaderConfig; } - // Getter and Setter for schemaHandler - public FileGroupReaderSchemaHandler getSchemaHandler() { - return schemaHandler; - } - - public void setSchemaHandler(FileGroupReaderSchemaHandler schemaHandler) { - this.schemaHandler = schemaHandler; - } - - public void setIteratorMode(IteratorMode iteratorMode) { - this.iteratorMode = iteratorMode; - } - public IteratorMode getIteratorMode() { ValidationUtils.checkArgument(iteratorMode != null, "iterator mode should not be null!"); return this.iteratorMode; @@ -148,82 +164,10 @@ public String getTablePath() { return tablePath; } - public void setEnableLogicalTimestampFieldRepair(boolean enableLogicalTimestampFieldRepair) { - this.enableLogicalTimestampFieldRepair = enableLogicalTimestampFieldRepair; - } - - public void setTablePath(String tablePath) { - this.tablePath = tablePath; - } - - public String getLatestCommitTime() { - return latestCommitTime; - } - - public void setLatestCommitTime(String latestCommitTime) { - this.latestCommitTime = latestCommitTime; - } - - public Option getRecordMerger() { - return recordMerger; - } - - public void setRecordMerger(Option recordMerger) { - this.recordMerger = recordMerger; - } - - // Getter and Setter for hasLogFiles - public boolean getHasLogFiles() { - return hasLogFiles; - } - - public void setHasLogFiles(boolean hasLogFiles) { - this.hasLogFiles = hasLogFiles; - } - - // Getter and Setter for hasBootstrapBaseFile - public boolean getHasBootstrapBaseFile() { - return hasBootstrapBaseFile; - } - - public void setHasBootstrapBaseFile(boolean hasBootstrapBaseFile) { - this.hasBootstrapBaseFile = hasBootstrapBaseFile; - } - - // Getter and Setter for needsBootstrapMerge - public boolean getNeedsBootstrapMerge() { - return needsBootstrapMerge; - } - - public boolean enableLogicalTimestampFieldRepair() { - return enableLogicalTimestampFieldRepair; - } - - public void setNeedsBootstrapMerge(boolean needsBootstrapMerge) { - this.needsBootstrapMerge = needsBootstrapMerge; - } - - // Getter and Setter for useRecordPosition - public boolean getShouldMergeUseRecordPosition() { - return shouldMergeUseRecordPosition; - } - - public void setShouldMergeUseRecordPosition(boolean shouldMergeUseRecordPosition) { - this.shouldMergeUseRecordPosition = shouldMergeUseRecordPosition; - } - - public StorageConfiguration getStorageConfiguration() { - return storageConfiguration; - } - public TypedProperties getMergeProps(TypedProperties props) { return ConfigUtils.getMergeProps(props, this.tableConfig); } - public Option getKeyFilterOpt() { - return keyFilterOpt; - } - public SizeEstimator> getRecordSizeEstimator() { return new HoodieRecordSizeEstimator<>(getSchemaHandler().getSchemaForUpdates()); } @@ -232,14 +176,6 @@ public CustomSerializer> getRecordSerializer() { return new DefaultSerializer<>(); } - public RecordContext getRecordContext() { - return recordContext; - } - - public HoodieConfig getHoodieReaderConfig() { - return hoodieReaderConfig; - } - /** * Gets the record iterator based on the type of engine-specific record representation from the * file. @@ -335,10 +271,6 @@ private void initRecordMerger(TypedProperties properties, boolean isIngestion) { properties.getString(RECORD_MERGE_IMPL_CLASSES_DEPRECATED_WRITE_CONFIG_KEY, ""))); } - public RecordMergeMode getMergeMode() { - return mergeMode; - } - /** * Get the {@link InstantRange} filter. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java index b1fa5d97892ed..d63c9bd63408b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java @@ -35,6 +35,8 @@ import org.apache.hudi.exception.HoodieKeyException; import org.apache.hudi.keygen.KeyGenerator; +import lombok.Getter; +import lombok.Setter; import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; @@ -65,7 +67,9 @@ public abstract class RecordContext implements Serializable { // for encoding and decoding schemas to the spillable map private final LocalHoodieSchemaCache localSchemaCache = LocalHoodieSchemaCache.getInstance(); + @Getter protected final JavaTypeConverter typeConverter; + @Setter protected String partitionPath; protected RecordContext(HoodieTableConfig tableConfig, JavaTypeConverter typeConverter) { @@ -84,10 +88,6 @@ protected RecordContext(JavaTypeConverter typeConverter) { this.typeConverter = typeConverter; } - public void setPartitionPath(String partitionPath) { - this.partitionPath = partitionPath; - } - public T extractDataFromRecord(HoodieRecord record, HoodieSchema schema, Properties properties) { return (T) record.getData(); } @@ -170,10 +170,6 @@ public abstract T mergeWithEngineRecord(HoodieSchema schema, */ public abstract T constructEngineRecord(HoodieSchema recordSchema, Object[] fieldValues); - public JavaTypeConverter getTypeConverter() { - return typeConverter; - } - /** * Gets the record key in String. * diff --git a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java index 56c0cc3a5b460..1b5f3ea62e787 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java @@ -45,8 +45,7 @@ import org.apache.hudi.storage.StoragePathInfo; import org.apache.hudi.storage.inline.InLineFSUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.FileNotFoundException; @@ -72,9 +71,9 @@ /** * Utility functions related to accessing the file storage. */ +@Slf4j public class FSUtils { - private static final Logger LOG = LoggerFactory.getLogger(FSUtils.class); // Log files are of this pattern - .b5068208-e1a4-11e6-bf01-fe55135034f3_20170101134598.log.1_1-0-1 // Archive log files are of this pattern - .commits_.archive.1_1-0-1 public static final String PATH_SEPARATOR = "/"; @@ -624,7 +623,7 @@ public static boolean deleteDir( pairOfSubPathAndConf.getKey(), pairOfSubPathAndConf.getValue(), true) ); boolean result = storage.deleteDirectory(dirPath); - LOG.info("Removed directory at {}", dirPath); + log.info("Removed directory at {}", dirPath); return result; } } catch (IOException ioe) { @@ -803,7 +802,7 @@ public static Map deleteFilesParallelize( if (!ignoreFailed) { throw new HoodieIOException("Failed to delete : " + file, e); } else { - LOG.info("Ignore failed deleting : {}", file); + log.info("Ignore failed deleting : {}", file); return true; } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/fs/FailSafeConsistencyGuard.java b/hudi-common/src/main/java/org/apache/hudi/common/fs/FailSafeConsistencyGuard.java index ed82343ee3be7..ada10aaa2fd03 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/fs/FailSafeConsistencyGuard.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/fs/FailSafeConsistencyGuard.java @@ -23,8 +23,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.FileNotFoundException; import java.io.IOException; @@ -36,10 +35,9 @@ /** * A consistency checker that fails if it is unable to meet the required condition within a specified timeout. */ +@Slf4j public class FailSafeConsistencyGuard implements ConsistencyGuard { - private static final Logger LOG = LoggerFactory.getLogger(FailSafeConsistencyGuard.class); - protected final HoodieStorage storage; protected final ConsistencyGuardConfig consistencyGuardConfig; @@ -129,7 +127,7 @@ private void waitForFileVisibility(StoragePath filePath, FileVisibility visibili return; } } catch (IOException ioe) { - LOG.warn("Got IOException waiting for file visibility. Retrying", ioe); + log.warn("Got IOException waiting for file visibility. Retrying", ioe); } sleepSafe(waitMs); @@ -152,7 +150,7 @@ private void retryTillSuccess(StoragePath dir, List files, FileVisibilit throws TimeoutException { long waitMs = consistencyGuardConfig.getInitialConsistencyCheckIntervalMs(); int attempt = 0; - LOG.info("Max Attempts=" + consistencyGuardConfig.getMaxConsistencyChecks()); + log.info("Max Attempts={}", consistencyGuardConfig.getMaxConsistencyChecks()); while (attempt < consistencyGuardConfig.getMaxConsistencyChecks()) { boolean success = checkFilesVisibility(attempt, dir, files, event); if (success) { @@ -178,7 +176,7 @@ private void retryTillSuccess(StoragePath dir, List files, FileVisibilit protected boolean checkFilesVisibility(int retryNum, StoragePath dir, List files, FileVisibility event) { try { - LOG.info("Trying " + retryNum); + log.info("Trying {}", retryNum); List entries = storage.listDirectEntries(dir); List gotFiles = entries.stream() .map(e -> e.getPath().getPathWithoutSchemeAndAuthority()) @@ -188,7 +186,7 @@ protected boolean checkFilesVisibility(int retryNum, StoragePath dir, List files) throws Ti Thread.sleep(consistencyGuardConfig.getOptimisticConsistencyGuardSleepTimeMs()); } } catch (InterruptedException ie) { - LOG.warn("Got InterruptedException waiting for file visibility. Ignoring", ie); + log.warn("Got InterruptedException waiting for file visibility. Ignoring", ie); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/heartbeat/HoodieHeartbeatUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/heartbeat/HoodieHeartbeatUtils.java index a51914554d715..fab4511c703f2 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/heartbeat/HoodieHeartbeatUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/heartbeat/HoodieHeartbeatUtils.java @@ -23,16 +23,15 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; /** * Common utils for Hudi heartbeat */ +@Slf4j public class HoodieHeartbeatUtils { - private static final Logger LOG = LoggerFactory.getLogger(HoodieHeartbeatUtils.class); /** * Use modification time as last heart beat time. @@ -72,7 +71,7 @@ public static boolean isHeartbeatExpired(String instantTime, Long currentTime = System.currentTimeMillis(); Long lastHeartbeatTime = getLastHeartbeatTime(storage, basePath, instantTime); if (currentTime - lastHeartbeatTime > maxAllowableHeartbeatIntervalInMs) { - LOG.warn("Heartbeat expired, for instant: {}", instantTime); + log.warn("Heartbeat expired, for instant: {}", instantTime); return true; } return false; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java index 51171b83b0e6b..949f766e13ccf 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java @@ -220,7 +220,7 @@ public Map getFileIdToInfo(String basePath) { public String toJsonString() throws IOException { if (partitionToWriteStats.containsKey(null)) { - log.info("partition path is null for " + partitionToWriteStats.get(null)); + log.info("partition path is null for {}", partitionToWriteStats.get(null)); partitionToWriteStats.remove(null); } return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/WriteOperationType.java b/hudi-common/src/main/java/org/apache/hudi/common/model/WriteOperationType.java index e794aad60b606..db288339a1b0a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/WriteOperationType.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/WriteOperationType.java @@ -188,6 +188,17 @@ public static boolean isCompactionOrClustering(WriteOperationType operationType) return operationType == COMPACT || operationType == CLUSTER; } + /** + * Checks if the given operation type is a table service operation. + * Table service operations include compaction, clustering, log compaction, and indexing. + */ + public static boolean isTableService(WriteOperationType operationType) { + return operationType == COMPACT + || operationType == CLUSTER + || operationType == LOG_COMPACT + || operationType == INDEX; + } + /** * @return true if streaming writes to metadata table is supported for a given {@link WriteOperationType}. false otherwise. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieAvroSchemaCache.java b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieAvroSchemaCache.java new file mode 100644 index 0000000000000..8b153eb4487e4 --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieAvroSchemaCache.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.hudi.common.schema; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import org.apache.avro.Schema; + +/** + * A global cache mapping Avro {@link Schema} instances to their canonical {@link HoodieSchema}. + * + *

This is an Avro-schema-keyed view onto {@link HoodieSchemaCache} for per-record call sites: + * {@code weakKeys} gives identity-based lookups (records of one file share the same {@link Schema} + * instance), so the hot path is a single cache hit with no wrapper allocation or type dispatch. + * Misses convert and then value-intern through {@link HoodieSchemaCache}, so equal but distinct Avro + * schema instances still converge on one canonical {@link HoodieSchema}. + * + *

This is a global cache which works for a JVM lifecycle. + */ +public class HoodieAvroSchemaCache { + + private static final LoadingCache AVRO_SCHEMA_CACHE = + Caffeine.newBuilder().weakKeys().maximumSize(1024) + .build(avroSchema -> HoodieSchemaCache.intern(HoodieSchema.fromAvroSchema(avroSchema))); + + /** + * Returns the canonical {@link HoodieSchema} wrapping the given Avro schema, converting and + * interning it on first use. + * + * @param avroSchema Avro schema to look up + * @return the canonical HoodieSchema for the given Avro schema + */ + public static HoodieSchema intern(Schema avroSchema) { + return AVRO_SCHEMA_CACHE.get(avroSchema); + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java index dff06a4c0e0f0..8c151af35a47a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java @@ -89,6 +89,7 @@ * * @since 1.2.0 */ +@Getter public class HoodieSchema implements Serializable { private static final long serialVersionUID = 1L; @@ -312,8 +313,11 @@ private static void addVectorColumnName(String s, int start, int end, Set fields; - private transient Map fieldMap; + // interned instances are shared across threads, so the lazily built caches use a benign racy + // single-check (see getFields()/getFieldMap()): lock-free volatile reads, and volatile gives + // safe publication of the immutable, deterministic result + private transient volatile List fields; + private transient volatile Map fieldMap; // Register the Variant logical type with Avro static { @@ -1151,6 +1155,8 @@ public List getFields() { if (!hasFields()) { throw new IllegalStateException("Cannot get fields from schema type: " + type); } + // Benign race: the result is an immutable, deterministic view of avroSchema's fields, so concurrent + // callers may each build it once but converge on equal lists; the volatile field makes publication safe. if (fields == null) { fields = Collections.unmodifiableList(avroSchema.getFields().stream().map(HoodieSchemaField::new).collect(Collectors.toList())); } @@ -1194,9 +1200,10 @@ public Option getField(String name) { } private Map getFieldMap() { + // Benign race, same rationale as getFields(): deterministic immutable result, volatile for safe publication. if (fieldMap == null) { - fieldMap = getFields().stream() - .collect(Collectors.toMap(HoodieSchemaField::name, field -> field)); + fieldMap = Collections.unmodifiableMap(getFields().stream() + .collect(Collectors.toMap(HoodieSchemaField::name, field -> field))); } return fieldMap; } @@ -1383,7 +1390,12 @@ public HoodieSchema getNonNullType() { return HoodieSchema.createUnion(nonNullTypes); } - boolean containsBlobType() { + /** + * Recursively checks whether this schema is or contains a BLOB type at any nesting depth, + * descending through arrays, maps, unions and record fields. Unlike {@link #isBlobField()}, + * this also finds BLOBs nested inside records. + */ + public boolean containsBlobType() { if (getType() == HoodieSchemaType.BLOB) { return true; } else if (getType() == HoodieSchemaType.ARRAY) { @@ -1567,18 +1579,6 @@ private static int getNextOffset(String path, int offset, String component) { return (path.charAt(next) == '.') ? next + 1 : -1; } - /** - * Returns the underlying Avro schema for compatibility purposes. - * - *

This method is provided for gradual migration and should be used - * sparingly. New code should prefer the HoodieSchema API.

- * - * @return the wrapped Avro Schema - */ - public Schema getAvroSchema() { - return avroSchema; - } - /** * Converts this HoodieSchema to an Avro Schema. * This is an alias for getAvroSchema() provided for API consistency. @@ -1909,7 +1909,9 @@ public HoodieSchema build() { } public static class Decimal extends HoodieSchema { + @Getter private final int precision; + @Getter private final int scale; private final Option fixedSize; @@ -1934,14 +1936,6 @@ private Decimal(Schema avroSchema) { } } - public int getPrecision() { - return precision; - } - - public int getScale() { - return scale; - } - @Override public String getName() { return String.format("decimal(%d,%d)", precision, scale); @@ -2224,7 +2218,9 @@ public int hashCode() { } public static class Timestamp extends HoodieSchema { + @Getter private final boolean isUtcAdjusted; + @Getter private final TimePrecision precision; /** @@ -2255,14 +2251,6 @@ private Timestamp(Schema avroSchema) { } } - public TimePrecision getPrecision() { - return precision; - } - - public boolean isUtcAdjusted() { - return isUtcAdjusted; - } - @Override public String getName() { if (isUtcAdjusted) { @@ -2299,6 +2287,7 @@ public int hashCode() { } public static class Time extends HoodieSchema { + @Getter private final TimePrecision precision; /** @@ -2321,10 +2310,6 @@ private Time(Schema avroSchema) { } } - public TimePrecision getPrecision() { - return precision; - } - @Override public String getName() { if (precision == TimePrecision.MILLIS) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java index 9842cdd098c87..0fd96e55233ec 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibility.java @@ -24,6 +24,9 @@ import org.apache.hudi.exception.SchemaBackwardsCompatibilityException; import org.apache.hudi.internal.schema.HoodieSchemaException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; @@ -45,12 +48,9 @@ *
  • Metadata field handling during schema checks
  • * */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class HoodieSchemaCompatibility { - // Prevent instantiation - private HoodieSchemaCompatibility() { - } - public static boolean areSchemasCompatible(HoodieSchema tableSchema, HoodieSchema writerSchema) { return HoodieSchemaCompatibilityChecker.checkReaderWriterCompatibility(tableSchema, writerSchema, false).getType() == HoodieSchemaCompatibilityChecker.SchemaCompatibilityType.COMPATIBLE; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaField.java b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaField.java index 190e180b4968b..9b1d16952192b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaField.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaField.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; +import lombok.Getter; import org.apache.avro.Schema; import java.io.Serializable; @@ -55,6 +56,7 @@ public class HoodieSchemaField implements Serializable { private static final long serialVersionUID = 1L; + @Getter private final Schema.Field avroField; private final HoodieSchema fieldSchema; @@ -251,18 +253,6 @@ public Set aliases() { return avroField.aliases(); } - /** - * Returns the underlying Avro field for compatibility purposes. - * - *

    This method is provided for gradual migration and should be used - * sparingly. New code should prefer the HoodieSchemaField API.

    - * - * @return the wrapped Avro Schema.Field - */ - public Schema.Field getAvroField() { - return avroField; - } - /** * Creates a copy of this field with a new name. * diff --git a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java index 98bd1df034dc8..ddf76e468f6a0 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java @@ -73,6 +73,19 @@ private HoodieSchemaUtils() { throw new UnsupportedOperationException("Utility class cannot be instantiated"); } + /** + * Resolves the schema of a named field, unwrapping a nullable union first. + * + * @param schema the record schema to look the field up in + * @param fieldName the field name + * @return the field's schema + * @throws HoodieSchemaException if the field does not exist in the schema + */ + public static HoodieSchema getFieldSchema(HoodieSchema schema, String fieldName) { + return schema.getNonNullType().getField(fieldName).map(HoodieSchemaField::schema) + .orElseThrow(() -> new HoodieSchemaException("Field " + fieldName + " doesn't exist in schema: " + schema)); + } + /** * Creates a write schema for Hudi operations, adding necessary metadata fields. * diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java index ca775136257c4..1ed99b5d11a0f 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java @@ -18,6 +18,7 @@ package org.apache.hudi.common.table; +import org.apache.hudi.HoodieVersion; import org.apache.hudi.common.HoodieTableFormat; import org.apache.hudi.common.NativeTableFormat; import org.apache.hudi.common.bootstrap.index.hfile.HFileBootstrapIndex; @@ -51,6 +52,7 @@ import org.apache.hudi.common.util.BinaryUtil; import org.apache.hudi.common.util.ConfigUtils; import org.apache.hudi.common.util.HoodieTableConfigUtils; +import org.apache.hudi.common.util.NetworkUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.StringUtils; @@ -65,8 +67,7 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import javax.annotation.concurrent.Immutable; @@ -119,6 +120,7 @@ import static org.apache.hudi.common.util.ValidationUtils.checkArgument; @Immutable +@Slf4j @ConfigClassProperty(name = "Hudi Table Basic Configs", groupName = ConfigGroups.Names.TABLE_CONFIG, description = "Configurations of the Hudi Table like type of ingestion, storage formats, hive table name etc." @@ -126,7 +128,8 @@ + " initializing a path as hoodie base path and never changes during the lifetime of a hoodie table.") public class HoodieTableConfig extends HoodieConfig { - private static final Logger LOG = LoggerFactory.getLogger(HoodieTableConfig.class); + // Cached hostname to avoid repeated synchronized network calls + private static volatile String cachedHostname = null; public static final String HOODIE_PROPERTIES_FILE = "hoodie.properties"; public static final String HOODIE_PROPERTIES_FILE_BACKUP = "hoodie.properties.backup"; @@ -475,7 +478,7 @@ public static HoodieTableConfig loadFromHoodieProps(HoodieStorage storage, Stora public HoodieTableConfig(HoodieStorage storage, StoragePath metaPath) { super(); StoragePath propertyPath = new StoragePath(metaPath, HOODIE_PROPERTIES_FILE); - LOG.info("Loading table properties from " + propertyPath); + log.info("Loading table properties from {}", propertyPath); try { this.props = fetchConfigs(storage, metaPath, HOODIE_PROPERTIES_FILE, HOODIE_PROPERTIES_FILE_BACKUP, MAX_READ_RETRIES, READ_RETRY_DELAY_MSEC); } catch (IOException e) { @@ -502,14 +505,14 @@ private static String storeProperties(Properties props, OutputStream outputStrea final String checksum; if (isValidChecksum(props)) { checksum = props.getProperty(TABLE_CHECKSUM.key()); - props.store(outputStream, "Updated at " + Instant.now()); + props.store(outputStream, getFileComment()); } else { Properties propsWithChecksum = getOrderedPropertiesWithTableChecksum(props); - propsWithChecksum.store(outputStream, "Properties saved on " + Instant.now()); + propsWithChecksum.store(outputStream, getFileComment()); checksum = propsWithChecksum.getProperty(TABLE_CHECKSUM.key()); props.setProperty(TABLE_CHECKSUM.key(), checksum); } - LOG.info("Created properties file at " + propertyPath); + log.info("Created properties file at {}", propertyPath); return checksum; } @@ -556,7 +559,7 @@ private static void modify(HoodieStorage storage, StoragePath metadataFolder, Pr propsToDelete.forEach(propToDelete -> props.remove(propToDelete)); checksum = storeProperties(props, out, cfgPath); } - LOG.warn(String.format("%s modified to: %s (at %s)", cfgPath.getName(), props, cfgPath.getParent())); + log.warn("{} modified to: {} (at {})", cfgPath.getName(), props, cfgPath.getParent()); // 5. verify and remove backup. try (InputStream in = storage.open(cfgPath)) { @@ -582,7 +585,7 @@ private static void modify(HoodieStorage storage, StoragePath metadataFolder, Pr private static void deleteFile(HoodieStorage storage, StoragePath cfgPath) throws IOException { storage.deleteFile(cfgPath); - LOG.info("Deleted properties file at " + cfgPath); + log.info("Deleted properties file at {}", cfgPath); } /** @@ -685,7 +688,7 @@ static boolean validateConfigVersion(ConfigProperty configProperty, HoodieTab boolean valid = tableVersion.greaterThan(firstVersion) || tableVersion.equals(firstVersion); valid = valid || CONFIGS_REQUIRED_FOR_OLDER_VERSIONED_TABLES.contains(configProperty.key()); if (!valid) { - LOG.warn("Table version {} is lower than or equal to config's first version {}. Config {} will be ignored.", + log.warn("Table version {} is lower than or equal to config's first version {}. Config {} will be ignored.", tableVersion, firstVersion, configProperty.key()); } return valid; @@ -727,6 +730,7 @@ public static Option getPartitionFieldProp(HoodieConfig config) { public static Option getPartitionFields(HoodieConfig config) { if (contains(PARTITION_FIELDS, config)) { return Option.of(Arrays.stream(config.getString(PARTITION_FIELDS).split(BaseKeyGenerator.FIELD_SEPARATOR)) + .map(String::trim) .filter(p -> !p.isEmpty()) .map(p -> getPartitionFieldWithoutKeyGenPartitionType(p, config)) .collect(Collectors.toList()).toArray(new String[] {})); @@ -1009,12 +1013,12 @@ public static Triple inferMergingConfigsForPreV // Check ordering field name based on record merge mode if (inferredRecordMergeMode == COMMIT_TIME_ORDERING) { if (nonEmpty(orderingFieldNamesAsString)) { - LOG.warn("The ordering field ({}) is specified. COMMIT_TIME_ORDERING " + log.warn("The ordering field ({}) is specified. COMMIT_TIME_ORDERING " + "merge mode does not use ordering field anymore.", orderingFieldNamesAsString); } } else if (inferredRecordMergeMode == EVENT_TIME_ORDERING) { if (isNullOrEmpty(orderingFieldNamesAsString)) { - LOG.warn("The ordering field is not specified. EVENT_TIME_ORDERING " + log.warn("The ordering field is not specified. EVENT_TIME_ORDERING " + "merge mode requires ordering field to be set for getting the " + "event time. Using commit time-based ordering now."); } @@ -1324,7 +1328,7 @@ public void setMetadataPartitionState(HoodieTableMetaClient metaClient, String p setValue(TABLE_METADATA_PARTITIONS, partitions.stream().sorted().collect(Collectors.joining(CONFIG_VALUES_DELIMITER))); setValue(TABLE_METADATA_PARTITIONS_INFLIGHT, partitionsInflight.stream().sorted().collect(Collectors.joining(CONFIG_VALUES_DELIMITER))); update(metaClient.getStorage(), metaClient.getMetaPath(), getProps()); - LOG.info("MDT {} partition {} has been {}", metaClient.getBasePath(), partitionPath, enabled ? "enabled" : "disabled"); + log.info("MDT {} partition {} has been {}", metaClient.getBasePath(), partitionPath, enabled ? "enabled" : "disabled"); } /** @@ -1342,7 +1346,7 @@ public void setMetadataPartitionsInflight(HoodieTableMetaClient metaClient, List setValue(TABLE_METADATA_PARTITIONS_INFLIGHT, partitionsInflight.stream().sorted().collect(Collectors.joining(CONFIG_VALUES_DELIMITER))); update(metaClient.getStorage(), metaClient.getMetaPath(), getProps()); - LOG.info("MDT {} partitions {} have been set to inflight", metaClient.getBasePath(), partitionPaths); + log.info("MDT {} partitions {} have been set to inflight", metaClient.getBasePath(), partitionPaths); } public void setMetadataPartitionsInflight(HoodieTableMetaClient metaClient, MetadataPartitionType... partitionTypes) { @@ -1367,14 +1371,20 @@ public Option getPartitionMetafileFormat() { return Option.empty(); } - public Map getTableMergeProperties() { + /** + * Returns the record-merge properties for this table, deriving the pre-v9 delete markers from the + * given effective payload class rather than the one persisted in this table config. Callers on the + * write path pass the write-config payload class ({@code hoodie.datasource.write.payload.class}), + * which for a pre-v9 table may be the only place the payload class is set. + */ + public Map getTableMergeProperties(String payloadClass) { Map configs = ConfigUtils.extractWithPrefix(this.props, RECORD_MERGE_PROPERTY_PREFIX); if (getTableVersion().lesserThan(HoodieTableVersion.NINE)) { // Convert legacy payload properties do delete key and delete marker properties - if (getPayloadClass().equals(AWSDmsAvroPayload.class.getName())) { + if (payloadClass.equals(AWSDmsAvroPayload.class.getName())) { configs.put(DELETE_KEY, OP_FIELD); configs.put(DELETE_MARKER, DELETE_OPERATION_VALUE); - } else if (getPayloadClass().equals(MySqlDebeziumAvroPayload.class.getName()) || getPayloadClass().equals(PostgresDebeziumAvroPayload.class.getName())) { + } else if (payloadClass.equals(MySqlDebeziumAvroPayload.class.getName()) || payloadClass.equals(PostgresDebeziumAvroPayload.class.getName())) { configs.put(DELETE_KEY, DebeziumConstants.FLATTENED_OP_COL_NAME); configs.put(DELETE_MARKER, DebeziumConstants.DELETE_OP); } @@ -1387,6 +1397,31 @@ public Map propsMap() { .collect(Collectors.toMap(e -> String.valueOf(e.getKey()), e -> String.valueOf(e.getValue()))); } + /** + * Returns the cached hostname, fetching it lazily on first call. + * Falls back to "unknown" if network resolution fails. + */ + private static String getHostnameSafe() { + if (cachedHostname == null) { + synchronized (HoodieTableConfig.class) { + if (cachedHostname == null) { + try { + cachedHostname = NetworkUtils.getHostname(); + } catch (Exception e) { + log.warn("Failed to resolve hostname, using 'unknown'", e); + cachedHostname = "unknown"; + } + } + } + } + return cachedHostname; + } + + public static String getFileComment() { + return String.format("Updated at %s, host=%s, hudi_version=%s", + Instant.now(), getHostnameSafe(), HoodieVersion.get()); + } + /** * @deprecated Use {@link #BASE_FILE_FORMAT} and its methods. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java index e20d9d3a12209..f248dd979db8c 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java @@ -74,8 +74,11 @@ import org.apache.hudi.storage.StoragePathFilter; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.io.Serializable; @@ -118,10 +121,12 @@ * @see HoodieTimeline * @since 0.3.0 */ +@NoArgsConstructor +@Getter +@Slf4j public class HoodieTableMetaClient implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HoodieTableMetaClient.class); public static final String METADATA_STR = "metadata"; public static final String METAFOLDER_NAME = ".hoodie"; public static final String TIMELINEFOLDER_NAME = "timeline"; @@ -163,20 +168,27 @@ public class HoodieTableMetaClient implements Serializable { protected StoragePath basePath; protected StoragePath metaPath; + @Getter(AccessLevel.NONE) + @Setter private transient HoodieStorage storage; + @Getter(AccessLevel.NONE) private boolean loadActiveTimelineOnLoad; protected StorageConfiguration storageConf; private HoodieTableType tableType; private TimelineLayoutVersion timelineLayoutVersion; private TimelineLayout timelineLayout; private StoragePath timelinePath; + @Getter(AccessLevel.NONE) private StoragePath timelineHistoryPath; protected HoodieTableConfig tableConfig; + @Getter(AccessLevel.NONE) protected HoodieActiveTimeline activeTimeline; private ConsistencyGuardConfig consistencyGuardConfig = ConsistencyGuardConfig.newBuilder().build(); private FileSystemRetryConfig fileSystemRetryConfig = FileSystemRetryConfig.newBuilder().build(); + @Getter(AccessLevel.NONE) protected HoodieMetaserverConfig metaserverConfig; private HoodieTimeGeneratorConfig timeGeneratorConfig; + @Getter(AccessLevel.NONE) private Option indexMetadataOpt; private HoodieTableFormat tableFormat; @@ -187,7 +199,7 @@ public class HoodieTableMetaClient implements Serializable { protected HoodieTableMetaClient(HoodieStorage storage, String basePath, boolean loadActiveTimelineOnLoad, ConsistencyGuardConfig consistencyGuardConfig, Option layoutVersion, HoodieTimeGeneratorConfig timeGeneratorConfig, FileSystemRetryConfig fileSystemRetryConfig) { - LOG.debug("Loading HoodieTableMetaClient from " + basePath); + log.debug("Loading HoodieTableMetaClient from {}", basePath); this.timeGeneratorConfig = timeGeneratorConfig; this.consistencyGuardConfig = consistencyGuardConfig; this.fileSystemRetryConfig = fileSystemRetryConfig; @@ -213,21 +225,13 @@ protected HoodieTableMetaClient(HoodieStorage storage, String basePath, boolean this.timelinePath = timelineLayout.getTimelinePathProvider().getTimelinePath(tableConfig, this.basePath); this.timelineHistoryPath = timelineLayout.getTimelinePathProvider().getTimelineHistoryPath(tableConfig, this.basePath); this.loadActiveTimelineOnLoad = loadActiveTimelineOnLoad; - LOG.debug("Finished Loading Table of type " + tableType + "(version=" + timelineLayoutVersion + ") from " + basePath); + log.debug("Finished Loading Table of type {}(version={}) from {}", tableType, timelineLayoutVersion, basePath); if (loadActiveTimelineOnLoad) { - LOG.info("Loading Active commit timeline for " + basePath); + log.info("Loading Active commit timeline for {}", basePath); getActiveTimeline(); } } - /** - * For serializing and de-serializing. - * - * @deprecated - */ - public HoodieTableMetaClient() { - } - public String getIndexDefinitionPath() { return tableConfig.getRelativeIndexDefinitionPath() .map(definitionPath -> new StoragePath(basePath, definitionPath).toString()) @@ -250,7 +254,7 @@ public boolean buildIndexDefinition(HoodieIndexDefinition indexDefinition) { Option existingIndexOpt = indexMetadataOpt.get().getIndex(indexName); if (existingIndexOpt.isPresent()) { if (!existingIndexOpt.get().getSourceFields().equals(indexDefinition.getSourceFields())) { - LOG.info("List of columns to index is changing. Old value {}. New value {}", existingIndexOpt.get().getSourceFields(), + log.info("List of columns to index is changing. Old value {}. New value {}", existingIndexOpt.get().getSourceFields(), indexDefinition.getSourceFields()); indexMetadataOpt.get().getIndexDefinitions().put(indexName, indexDefinition); } else { @@ -386,35 +390,6 @@ private void writeObject(java.io.ObjectOutputStream out) throws IOException { out.defaultWriteObject(); } - /** - * Returns base path of the table - */ - public StoragePath getBasePath() { - return basePath; // this invocation is cached - } - - /** - * @return Hoodie Table Type - */ - public HoodieTableType getTableType() { - return tableType; - } - - /** - * @return Meta path - */ - public StoragePath getMetaPath() { - return metaPath; - } - - public StoragePath getTimelinePath() { - return timelinePath; - } - - public HoodieTableFormat getTableFormat() { - return tableFormat; - } - /** * @return schema folder path */ @@ -492,21 +467,6 @@ public StoragePath getArchivePath() { return timelineHistoryPath; } - /** - * @return Table Config - */ - public HoodieTableConfig getTableConfig() { - return tableConfig; - } - - public TimelineLayoutVersion getTimelineLayoutVersion() { - return timelineLayoutVersion; - } - - public TimelineLayout getTimelineLayout() { - return timelineLayout; - } - public boolean isMetadataTable() { return HoodieTableMetadata.isMetadataTable(getBasePath()); } @@ -540,18 +500,10 @@ private static HoodieStorage getStorage(StoragePath path, consistencyGuard); } - public void setStorage(HoodieStorage storage) { - this.storage = storage; - } - public HoodieStorage getRawStorage() { return getStorage().getRawStorage(); } - public StorageConfiguration getStorageConf() { - return storageConf; - } - /** * Get the active instants as a timeline. * @@ -621,18 +573,6 @@ public String createNewInstantTime(boolean shouldLock) { return TimelineUtils.generateInstantTime(shouldLock, timeGenerator); } - public HoodieTimeGeneratorConfig getTimeGeneratorConfig() { - return timeGeneratorConfig; - } - - public ConsistencyGuardConfig getConsistencyGuardConfig() { - return consistencyGuardConfig; - } - - public FileSystemRetryConfig getFileSystemRetryConfig() { - return fileSystemRetryConfig; - } - /** * Get the archived commits as a timeline. This is costly operation, as all data from the archived files are read. * This should not be used, unless for historical debugging purposes. @@ -696,7 +636,7 @@ public static void createTableLayoutOnStorage(StorageConfiguration storageCon Properties props, Integer timelineLayout, boolean shouldCreateTableConfig) throws IOException { - LOG.info("Initializing {} as hoodie table", basePath); + log.info("Initializing {} as hoodie table", basePath); final HoodieStorage storage = HoodieStorageUtils.getStorage(basePath, storageConf); if (!storage.exists(basePath)) { storage.createDirectory(basePath); @@ -871,31 +811,6 @@ public List scanHoodieInstantsFromFileSystem(StoragePath timeline return instantStream.sorted().collect(Collectors.toList()); } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - HoodieTableMetaClient that = (HoodieTableMetaClient) o; - return Objects.equals(basePath, that.basePath) && tableType == that.tableType; - } - - @Override - public int hashCode() { - return Objects.hash(basePath, tableType); - } - - @Override - public String toString() { - return "HoodieTableMetaClient{" + "basePath='" + basePath + '\'' - + ", metaPath='" + metaPath + '\'' - + ", tableType=" + tableType - + '}'; - } - public void initializeBootstrapDirsIfNotExists() throws IOException { initializeBootstrapDirsIfNotExists(basePath, getStorage()); } @@ -1037,6 +952,34 @@ public CommitMetadataSerDe getCommitMetadataSerDe() { return getTimelineLayout().getCommitMetadataSerDe(); } + // Not using Lombok @EqualsAndHashCode/@ToString here: this class is subclassed (e.g. HoodieTableMetaserverClient), + // and we rely on the runtime subtype - exact-class matching in equals() and the declaring-class behavior below. + // Lombok would switch equals() to instanceof, making a base instance compare equal to a subtype instance. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + HoodieTableMetaClient that = (HoodieTableMetaClient) o; + return Objects.equals(basePath, that.basePath) && tableType == that.tableType; + } + + @Override + public int hashCode() { + return Objects.hash(basePath, tableType); + } + + @Override + public String toString() { + return "HoodieTableMetaClient{" + "basePath='" + basePath + '\'' + + ", metaPath='" + metaPath + '\'' + + ", tableType=" + tableType + + '}'; + } + public static TableBuilder newTableBuilder() { return new TableBuilder(); } @@ -1044,6 +987,7 @@ public static TableBuilder newTableBuilder() { /** * Builder for {@link Properties}. */ + @NoArgsConstructor(access = AccessLevel.PACKAGE) public static class TableBuilder { private HoodieTableType tableType; @@ -1093,9 +1037,6 @@ public static class TableBuilder { */ private final Properties others = new Properties(); - TableBuilder() { - } - public TableBuilder setTableType(HoodieTableType tableType) { this.tableType = tableType; return this; @@ -1667,7 +1608,7 @@ public HoodieTableMetaClient initTable(StorageConfiguration storageConf, Stor HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setConf(storageConf).setBasePath(basePath) .setMetaserverConfig(props) .build(); - LOG.info("Finished initializing Table of type {} from {}", metaClient.getTableConfig().getTableType(), basePath); + log.info("Finished initializing Table of type {} from {}", metaClient.getTableConfig().getTableType(), basePath); return metaClient; } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableVersion.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableVersion.java index 945ed23666077..7ec8445809c9b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableVersion.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableVersion.java @@ -22,6 +22,11 @@ import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.exception.HoodieException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + import java.util.Arrays; import java.util.List; @@ -29,7 +34,10 @@ * Table's version that controls what version of writer/readers can actually read/write * to a given table. */ +@AllArgsConstructor +@Getter public enum HoodieTableVersion { + // < 0.6.0 versions ZERO(0, CollectionUtils.createImmutableList("0.3.0"), TimelineLayoutVersion.LAYOUT_VERSION_0), // 0.6.0 onwards @@ -51,26 +59,14 @@ public enum HoodieTableVersion { // 1.1 NINE(9, CollectionUtils.createImmutableList("1.1.0"), TimelineLayoutVersion.LAYOUT_VERSION_2); + @Accessors(fluent = true) // Required so that #versionCode() is generated instead of #getVersionCode() by Lombok private final int versionCode; + @Getter(AccessLevel.NONE) private final List releaseVersions; private final TimelineLayoutVersion timelineLayoutVersion; - HoodieTableVersion(int versionCode, List releaseVersions, TimelineLayoutVersion timelineLayoutVersion) { - this.versionCode = versionCode; - this.releaseVersions = releaseVersions; - this.timelineLayoutVersion = timelineLayoutVersion; - } - - public TimelineLayoutVersion getTimelineLayoutVersion() { - return timelineLayoutVersion; - } - - public int versionCode() { - return versionCode; - } - public static HoodieTableVersion current() { return NINE; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java b/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java index ee2cfbf0d362c..157f9d4c79ca3 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java @@ -49,8 +49,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.util.Lazy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import javax.annotation.concurrent.ThreadSafe; @@ -67,11 +66,10 @@ /** * Helper class to read schema from data files and log files and to convert it between different formats. */ +@Slf4j @ThreadSafe public class TableSchemaResolver { - private static final Logger LOG = LoggerFactory.getLogger(TableSchemaResolver.class); - protected final HoodieTableMetaClient metaClient; /** @@ -254,11 +252,11 @@ private Option getTableParquetSchemaFromDataFile() { .map(writeStat -> new StoragePath(metaClient.getBasePath(), writeStat.getPath())); return Option.of(fetchSchemaFromFiles(filePaths)); } else { - LOG.debug("Could not find any data file written for commit, so could not get schema for table {}", metaClient.getBasePath()); + log.debug("Could not find any data file written for commit, so could not get schema for table {}", metaClient.getBasePath()); return Option.empty(); } default: - LOG.error("Unknown table type {}", metaClient.getTableType()); + log.error("Unknown table type {}", metaClient.getTableType()); throw new InvalidTableException(metaClient.getBasePath().toString()); } } @@ -373,7 +371,7 @@ public boolean hasOperationField() { HoodieSchema tableSchema = getTableSchemaFromDataFile(); return tableSchema.getField(HoodieRecord.OPERATION_METADATA_FIELD).isPresent(); } catch (Exception e) { - LOG.info("Failed to read operation field from schema ({})", e.getMessage()); + log.info("Failed to read operation field from schema ({})", e.getMessage()); return false; } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCExtractor.java b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCExtractor.java index 612837c4dc4df..beb9b56bc66ad 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCExtractor.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCExtractor.java @@ -219,9 +219,9 @@ private void initInstantAndCommitMetadata() { try { Set requiredActions = new HashSet<>(Arrays.asList(COMMIT_ACTION, DELTA_COMMIT_ACTION, REPLACE_COMMIT_ACTION, CLUSTERING_ACTION)); HoodieActiveTimeline activeTimeLine = metaClient.getActiveTimeline(); - if (instantRange.getStartInstant().isPresent() && !metaClient.getArchivedTimeline().empty() - && InstantComparison.compareTimestamps(metaClient.getArchivedTimeline().lastInstant().get().requestedTime(), InstantComparison.GREATER_THAN, instantRange.getStartInstant().get())) { - throw new HoodieException("Start instant time " + instantRange.getStartInstant().get() + if (instantRange.getStartInstantOpt().isPresent() && !metaClient.getArchivedTimeline().empty() + && InstantComparison.compareTimestamps(metaClient.getArchivedTimeline().lastInstant().get().requestedTime(), InstantComparison.GREATER_THAN, instantRange.getStartInstantOpt().get())) { + throw new HoodieException("Start instant time " + instantRange.getStartInstantOpt().get() + " for CDC query has to be in the active timeline. Beginning of active timeline " + activeTimeLine.firstInstant().get().requestedTime()); } this.commits = activeTimeLine.getInstantsAsStream() diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCFileSplit.java b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCFileSplit.java index c33df8414bd2e..24fb94356a0d3 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCFileSplit.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCFileSplit.java @@ -22,6 +22,8 @@ import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.util.Option; +import lombok.Getter; + import java.io.Serializable; import java.util.Collection; import java.util.Collections; @@ -45,7 +47,9 @@ * For `cdcInferCase` = {@link HoodieCDCInferenceCase#REPLACE_COMMIT}, `cdcFile` is null, * `beforeFileSlice` is the current version of the file slice. */ +@Getter public class HoodieCDCFileSplit implements Serializable, Comparable { + /** * The instant time at which the changes happened. */ @@ -103,26 +107,6 @@ public HoodieCDCFileSplit( this.afterFileSlice = afterFileSlice; } - public String getInstant() { - return this.instant; - } - - public HoodieCDCInferenceCase getCdcInferCase() { - return this.cdcInferCase; - } - - public List getCdcFiles() { - return this.cdcFiles; - } - - public Option getBeforeFileSlice() { - return this.beforeFileSlice; - } - - public Option getAfterFileSlice() { - return this.afterFileSlice; - } - @Override public int compareTo(HoodieCDCFileSplit o) { int cmpResult = this.instant.compareTo(o.instant); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCOperation.java b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCOperation.java index 90540bc05a69b..2cb0f19687e0a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCOperation.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCOperation.java @@ -20,9 +20,15 @@ import org.apache.hudi.exception.HoodieNotSupportedException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * Enumeration of change log operation. */ +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@Getter public enum HoodieCDCOperation { INSERT("i"), UPDATE("u"), @@ -30,14 +36,6 @@ public enum HoodieCDCOperation { private final String value; - HoodieCDCOperation(String value) { - this.value = value; - } - - public String getValue() { - return this.value; - } - public static HoodieCDCOperation parse(String value) { switch (value) { case "i": diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/Checkpoint.java b/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/Checkpoint.java index 67248ba4adcdb..eea1c32dd373a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/Checkpoint.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/Checkpoint.java @@ -19,6 +19,9 @@ package org.apache.hudi.common.table.checkpoint; +import lombok.AccessLevel; +import lombok.Getter; + import java.io.Serializable; import java.util.HashMap; import java.util.Map; @@ -27,13 +30,16 @@ /** * Class for representing checkpoint */ +@Getter public abstract class Checkpoint implements Serializable { + public static final String CHECKPOINT_IGNORE_KEY = "deltastreamer.checkpoint.ignore_key"; protected String checkpointKey; protected String checkpointResetKey; protected String checkpointIgnoreKey; // These are extra props to be written to the commit metadata + @Getter(AccessLevel.NONE) protected Map extraProps = new HashMap<>(); public Checkpoint setCheckpointKey(String newKey) { @@ -41,21 +47,12 @@ public Checkpoint setCheckpointKey(String newKey) { return this; } - public String getCheckpointKey() { - return checkpointKey; - } - - public String getCheckpointResetKey() { - return checkpointResetKey; - } - - public String getCheckpointIgnoreKey() { - return checkpointIgnoreKey; - } - public abstract Map getCheckpointCommitMetadata(String overrideResetKey, String overrideIgnoreKey); + // Not using Lombok @EqualsAndHashCode/@ToString here: this class is subclassed, and we rely on + // the runtime subtype - exact-class matching in equals() and getClass().getSimpleName() in toString(). + // Lombok would bake in the declaring class (Checkpoint) and switch equals() to instanceof. @Override public int hashCode() { return Objects.hashCode(checkpointKey); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/CheckpointUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/CheckpointUtils.java index 15084a74ae46d..443383c77297a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/CheckpointUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/checkpoint/CheckpointUtils.java @@ -21,7 +21,6 @@ import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.TimelineUtils; @@ -71,23 +70,24 @@ public static Checkpoint getCheckpoint(HoodieCommitMetadata commitMetadata) { throw new HoodieException("Checkpoint is not found in the commit metadata: " + commitMetadata.getExtraMetadata()); } - public static Checkpoint buildCheckpointFromGeneralSource( - String sourceClassName, int writeTableVersion, String checkpointToResume) { - return CheckpointUtils.shouldTargetCheckpointV2(writeTableVersion, sourceClassName) - ? new StreamerCheckpointV2(checkpointToResume) : new StreamerCheckpointV1(checkpointToResume); + /** + * For sources that do not have a semantic change in the checkpoint, always use checkpoint V1. + * + * @param checkpointToResume value of the checkpoint to resume + * @return {@link Checkpoint} instance + */ + public static Checkpoint createCheckpoint(String checkpointToResume) { + return new StreamerCheckpointV1(checkpointToResume); } - // Whenever we create checkpoint from streamer config checkpoint override, we should use this function - // to build checkpoints. - public static Checkpoint buildCheckpointFromConfigOverride( - String sourceClassName, int writeTableVersion, String checkpointToResume) { - return CheckpointUtils.shouldTargetCheckpointV2(writeTableVersion, sourceClassName) - ? new UnresolvedStreamerCheckpointBasedOnCfg(checkpointToResume) : new StreamerCheckpointV1(checkpointToResume); - } - - public static boolean shouldTargetCheckpointV2(int writeTableVersion, String sourceClassName) { - return writeTableVersion >= HoodieTableVersion.EIGHT.versionCode() - && !DATASOURCES_NOT_SUPPORTED_WITH_CKPT_V2.contains(sourceClassName); + /** + * For sources that do not have a semantic change in the checkpoint, always use checkpoint V1. + * + * @param checkpointToResume the checkpoint to resume + * @return {@link Checkpoint} instance + */ + public static Checkpoint createCheckpoint(Checkpoint checkpointToResume) { + return new StreamerCheckpointV1(checkpointToResume); } // TODO(yihua): for checkpoint translation, handle cases where the checkpoint is not exactly the diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/AbstractHoodieLogRecordScanner.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/AbstractHoodieLogRecordScanner.java index b1eb9e0e2c985..49c17eda75c83 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/AbstractHoodieLogRecordScanner.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/AbstractHoodieLogRecordScanner.java @@ -48,8 +48,10 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.ArrayDeque; @@ -87,10 +89,9 @@ *

    * This results in two I/O passes over the log file. */ +@Slf4j public abstract class AbstractHoodieLogRecordScanner { - private static final Logger LOG = LoggerFactory.getLogger(AbstractHoodieLogRecordScanner.class); - // Reader schema for the records protected final HoodieSchema readerSchema; // Latest valid instant time @@ -98,14 +99,17 @@ public abstract class AbstractHoodieLogRecordScanner { private final String latestInstantTime; protected final HoodieTableMetaClient hoodieTableMetaClient; // Merge strategy to use when combining records from log + @Getter(AccessLevel.PROTECTED) private final String payloadClassFQN; // Record's key/partition-path fields private final String recordKeyField; private final Option partitionPathFieldOpt; // Partition name override + @Getter private final Option partitionNameOverrideOpt; // Stateless component for merging records protected final HoodieRecordMerger recordMerger; + @Getter(AccessLevel.PROTECTED) private final TypedProperties payloadProps; // Log File Paths protected final List logFilePaths; @@ -117,6 +121,7 @@ public abstract class AbstractHoodieLogRecordScanner { // optional instant range for incremental block filtering private final Option instantRange; // Read the operation metadata field from the avro record + @Getter private final boolean withOperationField; private final HoodieStorage storage; // Total log files read - for metrics @@ -132,16 +137,19 @@ public abstract class AbstractHoodieLogRecordScanner { // Total number of corrupt blocks written across all log files private AtomicLong totalCorruptBlocks = new AtomicLong(0); // Store the last instant log blocks (needed to implement rollback) + @Getter private Deque currentInstantLogBlocks = new ArrayDeque<>(); // Enables full scan of log records protected final boolean forceFullScan; // Progress + @Getter private float progress = 0.0f; // Populate meta fields for the records private final boolean populateMetaFields; // Record type read from log block protected final HoodieRecordType recordType; // Collect all the block instants after scanning all the log files. + @Getter private final List validBlockInstants = new ArrayList<>(); // table version for compatibility private final HoodieTableVersion tableVersion; @@ -301,7 +309,7 @@ protected final synchronized void scanInternal(Option keySpecOpt, boole */ while (logFormatReaderWrapper.hasNext()) { HoodieLogFile logFile = logFormatReaderWrapper.getLogFile(); - LOG.info("Scanning log file {}", logFile); + log.info("Scanning log file {}", logFile); scannedLogFiles.add(logFile); totalLogFiles.set(scannedLogFiles.size()); // Use the HoodieLogFileReader to iterate through the blocks in the log file @@ -310,7 +318,7 @@ protected final synchronized void scanInternal(Option keySpecOpt, boole totalLogBlocks.incrementAndGet(); // Ignore the corrupt blocks. No further handling is required for them. if (logBlock.getBlockType().equals(CORRUPT_BLOCK)) { - LOG.info("Found a corrupt block in {}", logFile.getPath()); + log.info("Found a corrupt block in {}", logFile.getPath()); totalCorruptBlocks.incrementAndGet(); continue; } @@ -347,7 +355,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA instantToBlocksMap.put(instantTime, logBlocksList); break; case COMMAND_BLOCK: - LOG.info("Reading a command block from file {}", logFile.getPath()); + log.info("Reading a command block from file {}", logFile.getPath()); // This is a command block - take appropriate action based on the command HoodieCommandBlock commandBlock = (HoodieCommandBlock) logBlock; @@ -367,8 +375,8 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA } } - if (LOG.isDebugEnabled()) { - LOG.debug("Ordered instant times seen {}", orderedInstantsList); + if (log.isDebugEnabled()) { + log.debug("Ordered instant times seen {}", orderedInstantsList); } int numBlocksRolledBack = 0; @@ -424,24 +432,24 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA validBlockInstants.add(compactedFinalInstantTime); } } - LOG.info("Number of applied rollback blocks {}", numBlocksRolledBack); + log.info("Number of applied rollback blocks {}", numBlocksRolledBack); - if (LOG.isDebugEnabled()) { - LOG.info("Final view of the Block time to compactionBlockMap {}", blockTimeToCompactionBlockTimeMap); + if (log.isDebugEnabled()) { + log.info("Final view of the Block time to compactionBlockMap {}", blockTimeToCompactionBlockTimeMap); } // merge the last read block when all the blocks are done reading if (!currentInstantLogBlocks.isEmpty() && !skipProcessingBlocks) { - LOG.info("Merging the final data blocks"); + log.info("Merging the final data blocks"); processQueuedBlocksForInstant(currentInstantLogBlocks, scannedLogFiles.size(), keySpecOpt); } // Done progress = 1.0f; } catch (IOException e) { - LOG.error("Got IOException when reading log file", e); + log.error("Got IOException when reading log file", e); throw new HoodieIOException("IOException when reading log file ", e); } catch (Exception e) { - LOG.error("Got exception when reading log file", e); + log.error("Got exception when reading log file", e); throw new HoodieException("Exception when reading log file ", e); } finally { try { @@ -450,7 +458,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA } } catch (IOException ioe) { // Eat exception as we do not want to mask the original exception that can happen - LOG.error("Unable to close log format reader", ioe); + log.error("Unable to close log format reader", ioe); } } } @@ -506,7 +514,7 @@ private void processDataBlock(HoodieDataBlock dataBlock, Option keySpec private void processQueuedBlocksForInstant(Deque logBlocks, int numLogFilesSeen, Option keySpecOpt) throws Exception { while (!logBlocks.isEmpty()) { - LOG.info("Number of remaining logblocks to merge {}", logBlocks.size()); + log.info("Number of remaining logblocks to merge {}", logBlocks.size()); // poll the element at the bottom of the stack since that's the order it was inserted HoodieLogBlock lastBlock = logBlocks.pollLast(); switch (lastBlock.getBlockType()) { @@ -519,7 +527,7 @@ private void processQueuedBlocksForInstant(Deque logBlocks, int Arrays.stream(((HoodieDeleteBlock) lastBlock).getRecordsToDelete()).forEach(this::processNextDeletedRecord); break; case CORRUPT_BLOCK: - LOG.warn("Found a corrupt block which was not rolled back"); + log.warn("Found a corrupt block which was not rolled back"); break; default: break; @@ -535,13 +543,6 @@ private boolean shouldLookupRecords() { return !forceFullScan; } - /** - * Return progress of scanning as a float between 0.0 to 1.0. - */ - public float getProgress() { - return progress; - } - public long getTotalLogFiles() { return totalLogFiles.get(); } @@ -554,14 +555,6 @@ public long getTotalLogBlocks() { return totalLogBlocks.get(); } - protected String getPayloadClassFQN() { - return payloadClassFQN; - } - - public Option getPartitionNameOverride() { - return partitionNameOverrideOpt; - } - public long getTotalRollbacks() { return totalRollbacks.get(); } @@ -570,14 +563,6 @@ public long getTotalCorruptBlocks() { return totalCorruptBlocks.get(); } - public boolean isWithOperationField() { - return withOperationField; - } - - protected TypedProperties getPayloadProps() { - return payloadProps; - } - /** * Key specification with a list of column names. */ @@ -595,16 +580,11 @@ static KeySpec prefixKeySpec(List keyPrefixes) { } } + @AllArgsConstructor + @Getter private static class FullKeySpec implements KeySpec { - private final List keys; - private FullKeySpec(List keys) { - this.keys = keys; - } - @Override - public List getKeys() { - return keys; - } + private final List keys; @Override public boolean isFullKey() { @@ -612,12 +592,10 @@ public boolean isFullKey() { } } + @AllArgsConstructor private static class PrefixKeySpec implements KeySpec { - private final List keysPrefixes; - private PrefixKeySpec(List keysPrefixes) { - this.keysPrefixes = keysPrefixes; - } + private final List keysPrefixes; @Override public List getKeys() { @@ -630,14 +608,6 @@ public boolean isFullKey() { } } - public Deque getCurrentInstantLogBlocks() { - return currentInstantLogBlocks; - } - - public List getValidBlockInstants() { - return validBlockInstants; - } - private Pair, HoodieSchema> getRecordsIterator( HoodieDataBlock dataBlock, Option keySpecOpt) throws IOException { ClosableIterator blockRecordsIterator; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/BaseHoodieLogRecordReader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/BaseHoodieLogRecordReader.java index 332a9c920c6ef..159662f73bb97 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/BaseHoodieLogRecordReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/BaseHoodieLogRecordReader.java @@ -43,8 +43,9 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.ArrayDeque; @@ -74,9 +75,9 @@ * * @param type of engine-specific record representation. */ +@Slf4j public abstract class BaseHoodieLogRecordReader { - private static final Logger LOG = LoggerFactory.getLogger(BaseHoodieLogRecordReader.class); public static final String LOG_BLOCK_FULL_READ_DURATION_IN_MILLIS = "logBlockFullReadDurationInMillis"; public static final String BLOCK_SIZE_IN_BYTES = "blockSizeInBytes"; public static final String TOTAL_RECORDS_PRESENT_IN_LOG_BLOCK = "totalRecordsPresentInLogBlock"; @@ -89,13 +90,16 @@ public abstract class BaseHoodieLogRecordReader { protected final HoodieReaderContext readerContext; protected final HoodieTableMetaClient hoodieTableMetaClient; // Merge strategy to use when combining records from log + @Getter(AccessLevel.PROTECTED) private final String payloadClassFQN; // Record's key/partition-path fields private final String recordKeyField; // Partition name override + @Getter private final Option partitionNameOverrideOpt; // Ordering fields protected final String orderingFields; + @Getter(AccessLevel.PROTECTED) private final TypedProperties payloadProps; // Log File Paths protected final List logFiles; @@ -107,6 +111,7 @@ public abstract class BaseHoodieLogRecordReader { // optional instant range for incremental block filtering private final Option instantRange; // Read the operation metadata field from the avro record + @Getter private final boolean withOperationField; // FileSystem private final HoodieStorage storage; @@ -129,15 +134,18 @@ public abstract class BaseHoodieLogRecordReader { // Scan duration in milliseconds private AtomicLong blocksScanDuration = new AtomicLong(0); // Store the last instant log blocks (needed to implement rollback) + @Getter private Deque currentInstantLogBlocks = new ArrayDeque<>(); // Enables full scan of log records protected final boolean forceFullScan; // Progress + @Getter private float progress = 0.0f; - // Record type read from log block // Collect all the block instants after scanning all the log files. + @Getter private final List validBlockInstants = new ArrayList<>(); // Block-level scan stats for processed data blocks. + @Getter private List> blocksStats = new ArrayList<>(); protected HoodieFileGroupRecordBuffer recordBuffer; // Allows to consider inflight instants while merging log records @@ -271,7 +279,7 @@ protected final synchronized void scanInternal(Option keySpecOpt, boole */ while (logFormatReaderWrapper.hasNext()) { HoodieLogFile logFile = logFormatReaderWrapper.getLogFile(); - LOG.debug("Scanning log file {}", logFile); + log.debug("Scanning log file {}", logFile); scannedLogFiles.add(logFile); totalLogFiles.set(scannedLogFiles.size()); // Use the HoodieLogFileReader to iterate through the blocks in the log file @@ -279,11 +287,11 @@ protected final synchronized void scanInternal(Option keySpecOpt, boole logBlock.getBlockContentLocation() .map(contentLocation -> totalLogBlocksSize.addAndGet(contentLocation.getBlockSize())); final String instantTime = logBlock.getLogBlockHeader().get(INSTANT_TIME); - LOG.debug("Scanning log block with instant time {}", instantTime); + log.debug("Scanning log block with instant time {}", instantTime); totalLogBlocks.incrementAndGet(); // Ignore the corrupt blocks. No further handling is required for them. if (logBlock.getBlockType().equals(CORRUPT_BLOCK)) { - LOG.debug("Found a corrupt block in {}", logFile.getPath()); + log.debug("Found a corrupt block in {}", logFile.getPath()); totalCorruptBlocks.incrementAndGet(); continue; } @@ -314,7 +322,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA case AVRO_DATA_BLOCK: case PARQUET_DATA_BLOCK: case DELETE_BLOCK: - LOG.debug("Reading a {} block with instant time {}", + log.debug("Reading a {} block with instant time {}", logBlock.getBlockType() == HoodieLogBlock.HoodieLogBlockType.DELETE_BLOCK ? "delete" : "data", instantTime); List logBlocksList = instantToBlocksMap.getOrDefault(instantTime, new ArrayList<>()); @@ -326,7 +334,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA instantToBlocksMap.put(instantTime, logBlocksList); break; case COMMAND_BLOCK: - LOG.debug("Reading a command block from file {}", logFile.getPath()); + log.debug("Reading a command block from file {}", logFile.getPath()); // This is a command block - take appropriate action based on the command HoodieCommandBlock commandBlock = (HoodieCommandBlock) logBlock; @@ -341,10 +349,10 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA if (rolledBackBlocks != null) { numBlocksRolledBack += rolledBackBlocks.size(); } - LOG.debug("Reading a rollback block with instant {} and target instant {}", + log.debug("Reading a rollback block with instant {} and target instant {}", instantTime, targetInstantForCommandBlock); } else { - LOG.error("Reading a command block with instant {} whose operation is not supported", instantTime); + log.error("Reading a command block with instant {} whose operation is not supported", instantTime); throw new UnsupportedOperationException("Command type not yet supported."); } break; @@ -353,8 +361,8 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA } } - LOG.info("Ordered instant times seen {}", orderedInstantsList); - LOG.info("Targeted instants that are rolled back are {}", targetRollbackInstants); + log.info("Ordered instant times seen {}", orderedInstantsList); + log.info("Targeted instants that are rolled back are {}", targetRollbackInstants); // All the block's instants time that are added to the queue are collected in this set. Set instantTimesIncluded = new HashSet<>(); @@ -377,7 +385,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA // For compacted blocks COMPACTED_BLOCK_TIMES entry is present under its headers. if (firstBlock.getLogBlockHeader().containsKey(COMPACTED_BLOCK_TIMES)) { - LOG.debug("For instant time {}, compacted block instants are {}", + log.debug("For instant time {}, compacted block instants are {}", instantTime, firstBlock.getLogBlockHeader().get(COMPACTED_BLOCK_TIMES)); // When compacted blocks are seen update the blockTimeToCompactionBlockTimeMap. Arrays.stream(firstBlock.getLogBlockHeader().get(COMPACTED_BLOCK_TIMES).split(",")) @@ -410,20 +418,20 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA } } Collections.reverse(validBlockInstants); - LOG.debug("Number of applied rollback blocks {}", numBlocksRolledBack); - LOG.info("Total valid instants found are {}. Instants are {}", validBlockInstants.size(), validBlockInstants); + log.debug("Number of applied rollback blocks {}", numBlocksRolledBack); + log.info("Total valid instants found are {}. Instants are {}", validBlockInstants.size(), validBlockInstants); if (ignoredBlockCount > 0) { - LOG.info("Ignored {} log blocks from {} instants not in the range: {}", ignoredBlockCount, ignoredInstants.size(), ignoredInstants); + log.info("Ignored {} log blocks from {} instants not in the range: {}", ignoredBlockCount, ignoredInstants.size(), ignoredInstants); } - if (LOG.isDebugEnabled()) { - LOG.debug("Final view of the Block time to compactionBlockMap {}", blockTimeToCompactionBlockTimeMap); + if (log.isDebugEnabled()) { + log.debug("Final view of the Block time to compactionBlockMap {}", blockTimeToCompactionBlockTimeMap); } totalValidLogBlocks.set(currentInstantLogBlocks.size()); blocksScanDuration.set(scanTimer.endTimer()); // merge the last read block when all the blocks are done reading if (!currentInstantLogBlocks.isEmpty() && !skipProcessingBlocks) { - LOG.debug("Merging the final data blocks"); + log.debug("Merging the final data blocks"); processQueuedBlocksForInstant(currentInstantLogBlocks, scannedLogFiles.size(), keySpecOpt); } // Done @@ -432,10 +440,10 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA totalLogRecords.set(recordBuffer.getTotalLogRecords()); } } catch (IOException e) { - LOG.error("Got IOException when reading log file", e); + log.error("Got IOException when reading log file", e); throw new HoodieIOException("IOException when reading log file ", e); } catch (Exception e) { - LOG.error("Got exception when reading log file", e); + log.error("Got exception when reading log file", e); throw new HoodieException("Exception when reading log file ", e); } finally { try { @@ -444,18 +452,18 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA } } catch (IOException ioe) { // Eat exception as we do not want to mask the original exception that can happen - LOG.error("Unable to close log format reader", ioe); + log.error("Unable to close log format reader", ioe); } if (!logFiles.isEmpty()) { try { StoragePath path = logFiles.get(0).getPath(); - LOG.info("Finished scanning log files. FileId: {}, LogFileInstantTime: {}, " + log.info("Finished scanning log files. FileId: {}, LogFileInstantTime: {}, " + "Total log files: {}, Total log blocks: {}, Total rollbacks: {}, Total corrupt blocks: {}", FSUtils.getFileIdFromLogPath(path), FSUtils.getDeltaCommitTimeFromLogPath(path), totalLogFiles.get(), totalLogBlocks.get(), totalRollbacks.get(), totalCorruptBlocks.get()); } catch (Exception e) { - LOG.warn("Could not extract fileId from log path", e); - LOG.info("Finished scanning log files. " + log.warn("Could not extract fileId from log path", e); + log.info("Finished scanning log files. " + "Total log files: {}, Total log blocks: {}, Total rollbacks: {}, Total corrupt blocks: {}", totalLogFiles.get(), totalLogBlocks.get(), totalRollbacks.get(), totalCorruptBlocks.get()); } @@ -469,7 +477,7 @@ && compareTimestamps(logBlock.getLogBlockHeader().get(INSTANT_TIME), GREATER_THA private void processQueuedBlocksForInstant(Deque logBlocks, int numLogFilesSeen, Option keySpecOpt) throws Exception { while (!logBlocks.isEmpty()) { - LOG.debug("Number of remaining logblocks to merge {}", logBlocks.size()); + log.debug("Number of remaining logblocks to merge {}", logBlocks.size()); // poll the element at the bottom of the stack since that's the order it was inserted HoodieLogBlock lastBlock = logBlocks.pollLast(); switch (lastBlock.getBlockType()) { @@ -482,7 +490,7 @@ private void processQueuedBlocksForInstant(Deque logBlocks, int recordBuffer.processDeleteBlock((HoodieDeleteBlock) lastBlock); break; case CORRUPT_BLOCK: - LOG.warn("Found a corrupt block which was not rolled back"); + log.warn("Found a corrupt block which was not rolled back"); break; default: break; @@ -494,7 +502,7 @@ private void processQueuedBlocksForInstant(Deque logBlocks, int private void processDataBlock(HoodieDataBlock dataBlock, Option keySpecOpt) throws IOException { String blockInstantTime = dataBlock.getLogBlockHeader().get(INSTANT_TIME); - LOG.debug("Processing log block with instant time {}", blockInstantTime); + log.debug("Processing log block with instant time {}", blockInstantTime); long totalLogRecordsBefore = recordBuffer != null ? recordBuffer.getTotalLogRecords() : 0L; HoodieTimer blockReadTimer = HoodieTimer.start(); recordBuffer.processDataBlock(dataBlock, keySpecOpt); @@ -507,7 +515,7 @@ private void processDataBlock(HoodieDataBlock dataBlock, Option keySpec .map(contentLocation -> blockReadMetrics.put(BLOCK_SIZE_IN_BYTES, contentLocation.getBlockSize())); blockReadMetrics.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME.toString(), blockInstantTime); blocksStats.add(blockReadMetrics); - LOG.debug("For log block, scan metrics are {}", blockReadMetrics); + log.debug("For log block, scan metrics are {}", blockReadMetrics); } private boolean shouldLookupRecords() { @@ -516,13 +524,6 @@ private boolean shouldLookupRecords() { return !forceFullScan; } - /** - * Return progress of scanning as a float between 0.0 to 1.0. - */ - public float getProgress() { - return progress; - } - public long getTotalLogFiles() { return totalLogFiles.get(); } @@ -543,22 +544,10 @@ public long getTotalValidLogBlocks() { return totalValidLogBlocks.get(); } - public List> getBlocksStats() { - return blocksStats; - } - public long getBlocksScanDuration() { return blocksScanDuration.get(); } - protected String getPayloadClassFQN() { - return payloadClassFQN; - } - - public Option getPartitionNameOverride() { - return partitionNameOverrideOpt; - } - public long getTotalRollbacks() { return totalRollbacks.get(); } @@ -567,22 +556,6 @@ public long getTotalCorruptBlocks() { return totalCorruptBlocks.get(); } - public boolean isWithOperationField() { - return withOperationField; - } - - protected TypedProperties getPayloadProps() { - return payloadProps; - } - - public Deque getCurrentInstantLogBlocks() { - return currentInstantLogBlocks; - } - - public List getValidBlockInstants() { - return validBlockInstants; - } - /** * Builder used to build {@code AbstractHoodieLogRecordScanner}. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/FullKeySpec.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/FullKeySpec.java index ede7918649b4e..a186abd73e9a3 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/FullKeySpec.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/FullKeySpec.java @@ -19,6 +19,9 @@ package org.apache.hudi.common.table.log; +import lombok.AllArgsConstructor; +import lombok.Getter; + import java.util.List; /** @@ -26,17 +29,11 @@ * That is, the comparison between a record key and an element * of the set is {@link String#equals}. */ +@AllArgsConstructor +@Getter public class FullKeySpec implements KeySpec { - private final List keys; - - public FullKeySpec(List keys) { - this.keys = keys; - } - @Override - public List getKeys() { - return keys; - } + private final List keys; @Override public boolean isFullKey() { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFileReader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFileReader.java index 2c1d91e7b7c12..03ae4a2c4c07d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFileReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFileReader.java @@ -44,8 +44,8 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StorageSchemes; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; @@ -63,14 +63,15 @@ * Scans a log file and provides block level iterator on the log file Loads the entire block contents in memory Can emit * either a DataBlock, CommandBlock, DeleteBlock or CorruptBlock (if one is found). */ +@Slf4j public class HoodieLogFileReader implements HoodieLogFormat.Reader { public static final int DEFAULT_BUFFER_SIZE = 16 * 1024 * 1024; // 16 MB - private static final int BLOCK_SCAN_READ_BUFFER_SIZE = 1024 * 1024; // 1 MB - private static final Logger LOG = LoggerFactory.getLogger(HoodieLogFileReader.class); + private static final int BLOCK_SCAN_READ_BUFFER_SIZE = 1024 * 1024; private static final String REVERSE_LOG_READER_HAS_NOT_BEEN_ENABLED = "Reverse log reader has not been enabled"; private final HoodieStorage storage; + @Getter private final HoodieLogFile logFile; private final int bufferSize; private final byte[] magicBuffer = new byte[6]; @@ -118,11 +119,6 @@ public HoodieLogFileReader(HoodieStorage storage, HoodieLogFile logFile, HoodieS } } - @Override - public HoodieLogFile getLogFile() { - return logFile; - } - // TODO : convert content and block length to long by using ByteBuffer, raw byte [] allows // for max of Integer size private HoodieLogBlock readBlock() throws IOException { @@ -244,12 +240,12 @@ private HoodieLogBlockType tryReadBlockType(HoodieLogFormat.LogFormatVersion blo } private HoodieLogBlock createCorruptBlock(long blockStartPos) throws IOException { - LOG.info("Log {} has a corrupted block at {}", logFile, blockStartPos); + log.info("Log {} has a corrupted block at {}", logFile, blockStartPos); inputStream.seek(blockStartPos); long nextBlockOffset = scanForNextAvailableBlockOffset(); // Rewind to the initial start and read corrupted bytes till the nextBlockOffset inputStream.seek(blockStartPos); - LOG.info("Next available block in {} starts at {}", logFile, nextBlockOffset); + log.info("Next available block in {} starts at {}", logFile, nextBlockOffset); int corruptedBlockSize = (int) (nextBlockOffset - blockStartPos); long contentPosition = inputStream.getPos(); Option corruptedBytes = HoodieLogBlock.tryReadContent(inputStream, corruptedBlockSize, true); @@ -276,7 +272,7 @@ private boolean isBlockCorrupted(int blocksize) throws IOException { // So we have to shorten the footer block size by the size of magic hash blockSizeFromFooter = inputStream.readLong() - magicBuffer.length; } catch (EOFException e) { - LOG.info("Found corrupted block in file {} with block size({}) running past EOF", logFile, blocksize); + log.info("Found corrupted block in file {} with block size({}) running past EOF", logFile, blocksize); // this is corrupt // This seek is required because contract of seek() is different for naked DFSInputStream vs BufferedFSInputStream // release-3.1.0-RC1/DFSInputStream.java#L1455 @@ -286,7 +282,7 @@ private boolean isBlockCorrupted(int blocksize) throws IOException { } if (blocksize != blockSizeFromFooter) { - LOG.info("Found corrupted block in file {}. Header block size({}) did not match the footer block size({})", logFile, blocksize, blockSizeFromFooter); + log.info("Found corrupted block in file {}. Header block size({}) did not match the footer block size({})", logFile, blocksize, blockSizeFromFooter); inputStream.seek(currentPos); return true; } @@ -297,7 +293,7 @@ private boolean isBlockCorrupted(int blocksize) throws IOException { return false; } catch (CorruptedLogFileException e) { // This is a corrupted block - LOG.info("Found corrupted block in file {}. No magic hash found right after footer block size entry", logFile); + log.info("Found corrupted block in file {}. No magic hash found right after footer block size entry", logFile); return true; } finally { inputStream.seek(currentPos); @@ -330,7 +326,7 @@ private long scanForNextAvailableBlockOffset() throws IOException { @Override public void close() throws IOException { if (!closed) { - LOG.info("Closing Log file reader {}", logFile.getFileName()); + log.info("Closing Log file reader {}", logFile.getFileName()); if (null != this.inputStream) { this.inputStream.close(); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormat.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormat.java index a1103705c3c29..3877a682240dd 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormat.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormat.java @@ -24,13 +24,15 @@ import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.Closeable; import java.io.IOException; @@ -60,241 +62,177 @@ public interface HoodieLogFormat { String DEFAULT_WRITE_TOKEN = "0-0-0"; - String DEFAULT_LOG_FORMAT_WRITER = "org.apache.hudi.common.table.log.HoodieLogFormatWriter"; - - /** - * Writer interface to allow appending block to this file format. - */ - interface Writer extends Closeable { - - /** - * @return the path to the current {@link HoodieLogFile} being written to. - */ - HoodieLogFile getLogFile(); - - /** - * Append Block to a log file. - * @return {@link AppendResult} containing result of the append. - */ - AppendResult appendBlock(HoodieLogBlock block) throws IOException, InterruptedException; - - /** - * Appends the list of blocks to a logfile. - * @return {@link AppendResult} containing result of the append. - */ - AppendResult appendBlocks(List blocks) throws IOException, InterruptedException; - - long getCurrentSize() throws IOException; - } - - /** - * Reader interface which is an Iterator of HoodieLogBlock. - */ - interface Reader extends Closeable, Iterator { - - /** - * @return the path to this {@link HoodieLogFormat} - */ - HoodieLogFile getLogFile(); - - /** - * Read log file in reverse order and check if prev block is present. - * - * @return {@code true} if previous block is present, {@code false} otherwise. - */ - boolean hasPrev(); - - /** - * Read log file in reverse order and return prev block if present. - * - * @return {@link HoodieLogBlock} the previous block - * @throws IOException - */ - HoodieLogBlock prev() throws IOException; - } - /** - * Builder class to construct the default log format writer. + * Abstract base class for appending blocks to the Hoodie log format. + * Subclasses provide specific implementations for writing to different storage layers. */ - class WriterBuilder { + @Getter + @Slf4j + abstract class Writer implements Closeable { - private static final Logger LOG = LoggerFactory.getLogger(WriterBuilder.class); // Default max log file size 512 MB public static final long DEFAULT_SIZE_THRESHOLD = 512 * 1024 * 1024L; // Buffer size - private Integer bufferSize; + protected Integer bufferSize; // FileSystem - private HoodieStorage storage; + protected HoodieStorage storage; // Size threshold for the log file. Useful when used with a rolling log appender - private Long sizeThreshold; + protected Long sizeThreshold; // Log File extension. Could be .avro.delta or .avro.commits etc - private String fileExtension; + protected String fileExtension; // File Id - private String logFileId; + protected String logFileId; // File Commit Time stamp - private String instantTime; + protected String instantTime; // version number for this log file. If not specified, then the current version will be // computed by inspecting the file system - private Integer logVersion; - // file len of this log file - private Long fileLen = 0L; + protected Integer logVersion; + // file size of this log file + protected Long fileSize; // Location of the directory containing the log - private StoragePath parentPath; + protected StoragePath parentPath; // Log File Write Token - private String logWriteToken; + protected String logWriteToken; // optional file suffix - private String suffix; + protected String suffix; // file creation hook - private LogFileCreationCallback fileCreationCallback; - - private HoodieTableVersion tableVersion; - - public WriterBuilder withBufferSize(int bufferSize) { - this.bufferSize = bufferSize; - return this; - } - - public WriterBuilder withLogWriteToken(String logWriteToken) { - this.logWriteToken = logWriteToken; - return this; - } + protected LogFileCreationCallback fileCreationCallback; + protected HoodieLogFile logFile; - public WriterBuilder withSuffix(String suffix) { - this.suffix = suffix; - return this; - } + protected HoodieTableVersion tableVersion; - public WriterBuilder withStorage(HoodieStorage storage) { + /** + * Base constructor that performs the core Hudi Log logic. + */ + protected Writer( + Integer bufferSize, + HoodieStorage storage, + StoragePath parentPath, + String logFileId, + String fileExtension, + String instantTime, + Integer logVersion, + String logWriteToken, + String suffix, + Long fileSize, + Long sizeThreshold, + LogFileCreationCallback fileCreationCallback, + HoodieTableVersion tableVersion) throws IOException { + log.info("Building HoodieLogFormat.Writer"); + + // Validation + ValidationUtils.checkArgument(storage != null, "Storage is not specified"); + ValidationUtils.checkArgument(logFileId != null, "FileID is not specified"); + ValidationUtils.checkArgument(instantTime != null, "Instant time is not specified"); + ValidationUtils.checkArgument(fileExtension != null, "File extension is not specified"); + ValidationUtils.checkArgument(parentPath != null, "Log file parent location is not specified"); + + this.bufferSize = bufferSize != null ? bufferSize : storage.getDefaultBufferSize(); this.storage = storage; - return this; - } - - public WriterBuilder withSizeThreshold(long sizeThreshold) { - this.sizeThreshold = sizeThreshold; - return this; - } - - public WriterBuilder withFileExtension(String logFileExtension) { - this.fileExtension = logFileExtension; - return this; - } - - public WriterBuilder withFileId(String fileId) { - this.logFileId = fileId; - return this; - } - - public WriterBuilder withInstantTime(String instantTime) { - this.instantTime = instantTime; - return this; - } - - public WriterBuilder withLogVersion(int version) { - this.logVersion = version; - return this; - } - - public WriterBuilder withFileSize(long fileLen) { - this.fileLen = fileLen; - return this; - } - - public WriterBuilder onParentPath(StoragePath parentPath) { this.parentPath = parentPath; - return this; - } - - public WriterBuilder withFileCreationCallback(LogFileCreationCallback fileCreationCallback) { - this.fileCreationCallback = fileCreationCallback; - return this; - } - - public WriterBuilder withTableVersion(HoodieTableVersion writeTableVersion) { - this.tableVersion = writeTableVersion; - return this; - } - - public Writer build() throws IOException { - LOG.info("Building HoodieLogFormat Writer"); - if (storage == null) { - throw new IllegalArgumentException("fs is not specified"); - } - if (logFileId == null) { - throw new IllegalArgumentException("FileID is not specified"); - } - if (instantTime == null) { - throw new IllegalArgumentException("Instant time is not specified"); - } - if (fileExtension == null) { - throw new IllegalArgumentException("File extension is not specified"); - } - if (parentPath == null) { - throw new IllegalArgumentException("Log file parent location is not specified"); - } + this.logFileId = logFileId; + this.fileExtension = fileExtension; + this.instantTime = instantTime; + this.logVersion = logVersion; + this.logWriteToken = logWriteToken; + this.suffix = suffix; - if (fileCreationCallback == null) { - // by default does nothing. - fileCreationCallback = new LogFileCreationCallback() {}; - } + // Defaults and logic + this.fileSize = fileSize != null ? fileSize : 0L; + this.sizeThreshold = sizeThreshold != null ? sizeThreshold : DEFAULT_SIZE_THRESHOLD; + // Does nothing by default + this.fileCreationCallback = fileCreationCallback != null ? fileCreationCallback : new LogFileCreationCallback() {}; + this.tableVersion = tableVersion != null ? tableVersion : HoodieTableVersion.current(); - if (tableVersion == null) { - tableVersion = HoodieTableVersion.current(); - } + // Log version computation + if (this.logVersion == null) { + log.info("Computing next log version for {} in {}", logFileId, parentPath); + boolean useBaseVersion = this.tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) && this.logWriteToken != null; - if (logVersion == null) { - LOG.info("Computing the next log version for {} in {}", logFileId, parentPath); - boolean useBaseVersion = tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) - && logWriteToken != null; if (useBaseVersion) { - // the log format writer handles the existence check. - logVersion = HoodieLogFile.LOGFILE_BASE_VERSION; + this.logVersion = HoodieLogFile.LOGFILE_BASE_VERSION; } else { - // compute from storage (expensive) - Option> versionAndWriteToken = - FSUtils.getLatestLogVersion(storage, parentPath, logFileId, fileExtension, instantTime); - if (versionAndWriteToken.isPresent()) { - logVersion = versionAndWriteToken.get().getKey(); - logWriteToken = versionAndWriteToken.get().getValue(); + // Compute from storage (expensive) + Option> versionAndToken = FSUtils.getLatestLogVersion(this.storage, this.parentPath, this.logFileId, this.fileExtension, this.instantTime); + if (versionAndToken.isPresent()) { + this.logVersion = versionAndToken.get().getKey(); + this.logWriteToken = versionAndToken.get().getValue(); } else { - // this is the case where there is no existing log-file. - logVersion = HoodieLogFile.LOGFILE_BASE_VERSION; - logWriteToken = UNKNOWN_WRITE_TOKEN; + this.logVersion = HoodieLogFile.LOGFILE_BASE_VERSION; + this.logWriteToken = UNKNOWN_WRITE_TOKEN; } } - LOG.info("Computed the next log version for {} in {} as {} with write-token {}", logFileId, parentPath, logVersion, logWriteToken); } - if (logWriteToken == null) { - fileLen = 0L; - logWriteToken = UNKNOWN_WRITE_TOKEN; + if (this.logWriteToken == null) { + this.logWriteToken = UNKNOWN_WRITE_TOKEN; } - if (suffix != null) { + if (this.suffix != null) { // A little hacky to simplify the file name concatenation: // patch the write token with an optional suffix // instead of adding a new extension - logWriteToken = logWriteToken + suffix; + this.logWriteToken = this.logWriteToken + this.suffix; } + // Initialise logFile StoragePath logPath = new StoragePath(parentPath, - FSUtils.makeLogFileName(logFileId, fileExtension, instantTime, logVersion, logWriteToken)); - LOG.info("HoodieLogFile on path {}", logPath); - HoodieLogFile logFile = new HoodieLogFile(logPath, fileLen); - - if (sizeThreshold == null) { - sizeThreshold = DEFAULT_SIZE_THRESHOLD; - } - return (Writer) ReflectionUtils.loadClass( - DEFAULT_LOG_FORMAT_WRITER, - new Class[] {HoodieStorage.class, HoodieLogFile.class, Integer.class, Short.class, Long.class, String.class, LogFileCreationCallback.class}, - storage, logFile, bufferSize, null, sizeThreshold, logWriteToken, fileCreationCallback - ); + FSUtils.makeLogFileName(this.logFileId, this.fileExtension, this.instantTime, this.logVersion, this.logWriteToken)); + log.info("HoodieLogFile on path {}", logPath); + this.logFile = new HoodieLogFile(logPath, this.fileSize); } + + /** + * Append Block to a log file. + * @return {@link AppendResult} containing result of the append. + */ + public abstract AppendResult appendBlock(HoodieLogBlock block) throws IOException, InterruptedException; + + /** + * Appends the list of blocks to a logfile. + * @return {@link AppendResult} containing result of the append. + */ + public abstract AppendResult appendBlocks(List blocks) throws IOException, InterruptedException; + + public abstract long getCurrentSize() throws IOException; + + /** + * Force previously appended blocks to durable storage so that downstream + * readers can observe them before this writer is closed. + * + *

    Production code paths typically rely on {@link #close()} for + * commit-level visibility and do not need to call this. It is exposed + * mainly for tests that assert per-append visibility on the underlying + * file system. + */ + public abstract void sync() throws IOException; } - static WriterBuilder newWriterBuilder() { - return new WriterBuilder(); + /** + * Reader interface which is an Iterator of HoodieLogBlock. + */ + interface Reader extends Closeable, Iterator { + + /** + * @return the path to this {@link HoodieLogFormat} + */ + HoodieLogFile getLogFile(); + + /** + * Read log file in reverse order and check if prev block is present. + * + * @return {@code true} if previous block is present, {@code false} otherwise. + */ + boolean hasPrev(); + + /** + * Read log file in reverse order and return prev block if present. + * + * @return {@link HoodieLogBlock} the previous block + * @throws IOException + */ + HoodieLogBlock prev() throws IOException; } static HoodieLogFormat.Reader newReader(HoodieStorage storage, HoodieLogFile logFile, HoodieSchema readerSchema) @@ -310,18 +248,12 @@ static HoodieLogFormat.Reader newReader(HoodieStorage storage, HoodieLogFile log * A set of feature flags associated with a log format. Versions are changed when the log format changes. TODO(na) - * Implement policies around major/minor versions */ + @AllArgsConstructor(access = AccessLevel.PACKAGE) + @Getter abstract class LogFormatVersion { private final int version; - LogFormatVersion(int version) { - this.version = version; - } - - public int getVersion() { - return version; - } - public abstract boolean hasMagicHeader(); public abstract boolean hasContent(); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatReader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatReader.java index 77c3e78fcc328..1c342fecf39c5 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatReader.java @@ -25,8 +25,7 @@ import org.apache.hudi.internal.schema.InternalSchema; import org.apache.hudi.storage.HoodieStorage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.List; @@ -34,6 +33,7 @@ /** * Hoodie log format reader. */ +@Slf4j public class HoodieLogFormatReader implements HoodieLogFormat.Reader { private final List logFiles; @@ -45,8 +45,6 @@ public class HoodieLogFormatReader implements HoodieLogFormat.Reader { private final boolean enableInlineReading; private final int bufferSize; - private static final Logger LOG = LoggerFactory.getLogger(HoodieLogFormatReader.class); - HoodieLogFormatReader(HoodieStorage storage, List logFiles, HoodieSchema readerSchema, boolean reverseLogReader, int bufferSize, boolean enableRecordLookups, String recordKeyField, InternalSchema internalSchema) throws IOException { @@ -91,7 +89,7 @@ public boolean hasNext() { } catch (IOException io) { throw new HoodieIOException("unable to initialize read with log file ", io); } - LOG.debug("Moving to the next reader for logfile {}", currentReader.getLogFile()); + log.debug("Moving to the next reader for logfile {}", currentReader.getLogFile()); return hasNext(); } return false; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordReader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordReader.java index b3f1eeaa988ec..1ee8e7560d054 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordReader.java @@ -34,8 +34,8 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.Closeable; import java.io.Serializable; @@ -52,9 +52,11 @@ * * @param type of engine-specific record representation. */ +@Getter +@Slf4j public class HoodieMergedLogRecordReader extends BaseHoodieLogRecordReader implements Iterable>, Closeable { - private static final Logger LOG = LoggerFactory.getLogger(HoodieMergedLogRecordReader.class); + // A timer for calculating elapsed time in millis public final HoodieTimer timer = HoodieTimer.create(); // count of merged records in log @@ -102,8 +104,8 @@ private void performScan() { this.totalTimeTakenToReadAndMergeBlocks = timer.endTimer(); this.numMergedRecordsInLog = recordBuffer.size(); - LOG.info("Number of log files scanned => {}", logFiles.size()); - LOG.info("Number of entries in Map => {}", recordBuffer.size()); + log.info("Number of log files scanned => {}", logFiles.size()); + log.info("Number of entries in Map => {}", recordBuffer.size()); } static Option createKeySpec(Option filter) { @@ -134,10 +136,6 @@ public Map> getRecords() { return recordBuffer.getLogRecords(); } - public long getNumMergedRecordsInLog() { - return numMergedRecordsInLog; - } - /** * Returns the builder for {@code HoodieMergedLogRecordReader}. */ @@ -145,10 +143,6 @@ public static Builder newBuilder() { return new Builder<>(); } - public long getTotalTimeTakenToReadAndMergeBlocks() { - return totalTimeTakenToReadAndMergeBlocks; - } - @Override public void close() { // No op. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordScanner.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordScanner.java index 318533859746d..f7521fc207c5c 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordScanner.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/HoodieMergedLogRecordScanner.java @@ -47,8 +47,8 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import javax.annotation.concurrent.NotThreadSafe; @@ -81,10 +81,10 @@ * This results in two I/O passes over the log file. */ @NotThreadSafe +@Slf4j public class HoodieMergedLogRecordScanner extends AbstractHoodieLogRecordScanner implements Iterable, Closeable { - private static final Logger LOG = LoggerFactory.getLogger(HoodieMergedLogRecordScanner.class); // A timer for calculating elapsed time in millis public final HoodieTimer timer = HoodieTimer.create(); // Map of compacted/merged records @@ -92,9 +92,11 @@ public class HoodieMergedLogRecordScanner extends AbstractHoodieLogRecordScanner // Set of already scanned prefixes allowing us to avoid scanning same prefixes again private final Set scannedPrefixes; // count of merged records in log + @Getter private long numMergedRecordsInLog; private final long maxMemorySizeInBytes; // Stores the total time taken to perform reading and merging of log blocks + @Getter private long totalTimeTakenToReadAndMergeBlocks; private final String[] orderingFields; private final DeleteContext deleteContext; @@ -220,8 +222,8 @@ private void performScan() { this.totalTimeTakenToReadAndMergeBlocks = timer.endTimer(); this.numMergedRecordsInLog = records.size(); - if (LOG.isInfoEnabled()) { - LOG.info("Scanned {} log files with stats: MaxMemoryInBytes => {}, MemoryBasedMap => {} entries, {} total bytes, DiskBasedMap => {} entries, {} total bytes", + if (log.isInfoEnabled()) { + log.info("Scanned {} log files with stats: MaxMemoryInBytes => {}, MemoryBasedMap => {} entries, {} total bytes, DiskBasedMap => {} entries, {} total bytes", logFilePaths.size(), maxMemorySizeInBytes, records.getInMemoryMapNumEntries(), records.getCurrentInMemoryMapSize(), records.getDiskBasedMapNumEntries(), records.getSizeOfFileOnDiskInBytes()); } @@ -240,10 +242,6 @@ public HoodieRecord.HoodieRecordType getRecordType() { return recordMerger.getRecordType(); } - public long getNumMergedRecordsInLog() { - return numMergedRecordsInLog; - } - /** * Returns the builder for {@code HoodieMergedLogRecordScanner}. */ @@ -313,10 +311,6 @@ protected void processNextDeletedRecord(DeleteRecord deleteRecord) { } } - public long getTotalTimeTakenToReadAndMergeBlocks() { - return totalTimeTakenToReadAndMergeBlocks; - } - @Override public void close() { if (records != null) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/InstantRange.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/InstantRange.java index 9dd56cc66182c..eda285d478d87 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/InstantRange.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/InstantRange.java @@ -21,6 +21,10 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.io.Serializable; import java.util.Arrays; import java.util.Collections; @@ -37,6 +41,7 @@ /** * An instant range used for incremental reader filtering. */ +@Getter public abstract class InstantRange implements Serializable { private static final long serialVersionUID = 1L; @@ -55,14 +60,6 @@ public static Builder builder() { return new Builder(); } - public Option getStartInstant() { - return startInstantOpt; - } - - public Option getEndInstant() { - return endInstantOpt; - } - public abstract boolean isInRange(String instant); public abstract RangeType getRangeType(); @@ -248,6 +245,7 @@ public RangeType getRangeType() { /** * Builder for {@link InstantRange}. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Builder { private String startInstant; private String endInstant; @@ -256,9 +254,6 @@ public static class Builder { private Set explicitInstants; private List instantRanges; - private Builder() { - } - public Builder startInstant(String startInstant) { this.startInstant = startInstant; return this; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieAvroDataBlock.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieAvroDataBlock.java index 9f265d4f26878..764528bfc3f06 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieAvroDataBlock.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieAvroDataBlock.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieAvroIndexedRecord; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaCache; import org.apache.hudi.common.util.CollectionUtils; @@ -507,9 +508,11 @@ public byte[] getBytes(Schema schema) throws IOException { output.writeInt(records.size()); // 3. Write the records + // schema is loop-invariant; intern it once (shared, cached) instead of rebuilding the HoodieSchema per record + HoodieSchema hoodieSchema = HoodieAvroSchemaCache.intern(schema); Iterator> itr = records.iterator(); while (itr.hasNext()) { - IndexedRecord s = itr.next().toIndexedRecord(HoodieSchema.fromAvroSchema(schema), new Properties()).get().getData(); + IndexedRecord s = itr.next().toIndexedRecord(hoodieSchema, new Properties()).get().getData(); ByteArrayOutputStream temp = new ByteArrayOutputStream(); BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(temp, encoderCache.get()); encoderCache.set(encoder); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieCommandBlock.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieCommandBlock.java index 965c57309b652..ea4c25db661a8 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieCommandBlock.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieCommandBlock.java @@ -22,6 +22,8 @@ import org.apache.hudi.io.SeekableDataInputStream; import org.apache.hudi.storage.HoodieStorage; +import lombok.Getter; + import java.io.ByteArrayOutputStream; import java.util.HashMap; import java.util.Map; @@ -30,6 +32,7 @@ /** * Command block issues a specific command to the scanner. */ +@Getter public class HoodieCommandBlock extends HoodieLogBlock { private final HoodieCommandBlockTypeEnum type; @@ -53,10 +56,6 @@ public HoodieCommandBlock(Option content, Supplier compressionCodec; // This path is used for constructing HFile reader context, which should not be diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieLogBlock.java b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieLogBlock.java index 554b83b3455d7..0b703ed7ac910 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieLogBlock.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieLogBlock.java @@ -28,9 +28,11 @@ import org.apache.hudi.io.SeekableDataInputStream; import org.apache.hudi.storage.HoodieStorage; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.roaringbitmap.longlong.Roaring64NavigableMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -55,8 +57,11 @@ /** * Abstract class defining a block in HoodieLogFile. */ +@AllArgsConstructor +@Getter +@Slf4j public abstract class HoodieLogBlock { - private static final Logger LOG = LoggerFactory.getLogger(HoodieLogBlock.class); + /** * The current version of the log block. Anytime the logBlock format changes this version needs to be bumped and * corresponding changes need to be made to {@link HoodieLogBlockVersion} TODO : Change this to a class, something @@ -65,32 +70,24 @@ public abstract class HoodieLogBlock { */ public static int version = 3; // Header for each log block + @Nonnull private final Map logBlockHeader; // Footer for each log block + @Nonnull private final Map logBlockFooter; // Location of a log block on disk + @Nonnull private final Option blockContentLocation; // data for a specific block + @Nonnull private Option content; + @Getter(AccessLevel.PROTECTED) + @Nullable private final Supplier inputStreamSupplier; // Toggle flag, whether to read blocks lazily (I/O intensive) or not (Memory intensive) + @Getter(AccessLevel.NONE) protected boolean readBlockLazily; - public HoodieLogBlock( - @Nonnull Map logBlockHeader, - @Nonnull Map logBlockFooter, - @Nonnull Option blockContentLocation, - @Nonnull Option content, - @Nullable Supplier inputStreamSupplier, - boolean readBlockLazily) { - this.logBlockHeader = logBlockHeader; - this.logBlockFooter = logBlockFooter; - this.blockContentLocation = blockContentLocation; - this.content = content; - this.inputStreamSupplier = inputStreamSupplier; - this.readBlockLazily = readBlockLazily; - } - // Return the bytes representation of the data belonging to a LogBlock public ByteArrayOutputStream getContentBytes(HoodieStorage storage) throws IOException { throw new HoodieException("No implementation was provided"); @@ -110,22 +107,6 @@ public long getLogBlockLength() { throw new HoodieException("No implementation was provided"); } - public Option getBlockContentLocation() { - return this.blockContentLocation; - } - - public Map getLogBlockHeader() { - return logBlockHeader; - } - - public Map getLogBlockFooter() { - return logBlockFooter; - } - - public Option getContent() { - return content; - } - /** * Compacted blocks are created using log compaction which basically merges the consecutive blocks together and create * huge block with all the changes. @@ -161,10 +142,10 @@ protected void addRecordPositionsToHeader(Set positionSet, try { logBlockHeader.put(HeaderMetadataType.RECORD_POSITIONS, LogReaderUtils.encodePositions(positionSet)); } catch (IOException e) { - LOG.error("Cannot write record positions to the log block header.", e); + log.error("Cannot write record positions to the log block header.", e); } } else { - LOG.warn("There are duplicate keys in the records (number of unique positions: {}, " + log.warn("There are duplicate keys in the records (number of unique positions: {}, " + "number of records: {}). Skip writing record positions to the log block header.", positionSet.size(), numRecords); } @@ -176,7 +157,7 @@ protected boolean containsBaseFileInstantTimeOfPositions() { } protected void removeBaseFileInstantTimeOfPositions() { - LOG.info("There are records without valid positions. " + log.info("There are records without valid positions. " + "Skip writing record positions to the block header."); logBlockHeader.remove(HeaderMetadataType.BASE_FILE_INSTANT_TIME_OF_RECORD_POSITIONS); } @@ -252,7 +233,10 @@ public enum FooterMetadataType { * This class is used to store the Location of the Content of a Log Block. It's used when a client chooses for a IO * intensive CompactedScanner, the location helps to lazily read contents from the log file */ + @AllArgsConstructor + @Getter public static final class HoodieLogBlockContentLocation { + // Storage Config required to access the file private final HoodieStorage storage; // The logFile that contains this block @@ -263,38 +247,6 @@ public static final class HoodieLogBlockContentLocation { private final long blockSize; // The final position where the complete block ends private final long blockEndPos; - - public HoodieLogBlockContentLocation(HoodieStorage storage, - HoodieLogFile logFile, - long contentPositionInLogFile, - long blockSize, - long blockEndPos) { - this.storage = storage; - this.logFile = logFile; - this.contentPositionInLogFile = contentPositionInLogFile; - this.blockSize = blockSize; - this.blockEndPos = blockEndPos; - } - - public HoodieStorage getStorage() { - return storage; - } - - public HoodieLogFile getLogFile() { - return logFile; - } - - public long getContentPositionInLogFile() { - return contentPositionInLogFile; - } - - public long getBlockSize() { - return blockSize; - } - - public long getBlockEndPos() { - return blockEndPos; - } } /** @@ -359,10 +311,6 @@ protected Option getContentAsByteStream() throws IOExcept return Option.of(baos); } - protected Supplier getInputStreamSupplier() { - return inputStreamSupplier; - } - /** * Adds the record positions if the base file instant time of the positions exists * in the log header and the record positions are all valid. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecord.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecord.java index d68e608463df4..fb78adaa772c6 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecord.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecord.java @@ -23,6 +23,10 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.OrderingValues; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + import javax.annotation.Nullable; import java.io.Serializable; @@ -34,41 +38,22 @@ * * @param The type of the engine specific row. */ +@AllArgsConstructor +@Getter public class BufferedRecord implements Serializable { + private String recordKey; - private T record; private final Comparable orderingValue; + private T record; private final Integer schemaId; - @Nullable private HoodieOperation hoodieOperation; + @Nullable + @Setter + private HoodieOperation hoodieOperation; public BufferedRecord() { this(null, null, null, null, null); } - public BufferedRecord(String recordKey, Comparable orderingValue, T record, Integer schemaId, @Nullable HoodieOperation hoodieOperation) { - this.recordKey = recordKey; - this.orderingValue = orderingValue; - this.record = record; - this.schemaId = schemaId; - this.hoodieOperation = hoodieOperation; - } - - public String getRecordKey() { - return recordKey; - } - - public Comparable getOrderingValue() { - return orderingValue; - } - - public T getRecord() { - return record; - } - - public Integer getSchemaId() { - return schemaId; - } - public boolean isDelete() { return HoodieOperation.isDelete(hoodieOperation) || HoodieOperation.isUpdateBefore(hoodieOperation); } @@ -81,14 +66,6 @@ public boolean isCommitTimeOrderingDelete() { return isDelete() && OrderingValues.isDefault(orderingValue); } - public void setHoodieOperation(HoodieOperation hoodieOperation) { - this.hoodieOperation = hoodieOperation; - } - - public HoodieOperation getHoodieOperation() { - return this.hoodieOperation; - } - public BufferedRecord toBinary(RecordContext recordContext) { if (record != null) { HoodieSchema schema = recordContext.getSchemaFromBufferRecord(this); @@ -124,6 +101,8 @@ public BufferedRecord replaceRecordKey(String recordKey) { return this; } + // Intentionally not using @EqualsAndHashCode: Lombok generates instanceof/canEqual based equality, + // while this class requires exact runtime-class equality via getClass() @Override public boolean equals(Object o) { if (this == o) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecordMergerFactory.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecordMergerFactory.java index 81d2de0ac0a23..06e903dd1fa4f 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecordMergerFactory.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/BufferedRecordMergerFactory.java @@ -33,16 +33,17 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieIOException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + import java.io.IOException; /** * Factory to create a {@link BufferedRecordMerger}. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class BufferedRecordMergerFactory { - private BufferedRecordMergerFactory() { - } - public static BufferedRecordMerger create(HoodieReaderContext readerContext, RecordMergeMode recordMergeMode, boolean enablePartialMerging, diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/DeleteContext.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/DeleteContext.java index ce6857dc2aebb..f1bd8b0b0a8ac 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/DeleteContext.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/DeleteContext.java @@ -27,6 +27,9 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; +import lombok.Getter; +import lombok.experimental.Accessors; + import java.io.Serializable; import java.util.Properties; @@ -37,10 +40,13 @@ /** * Schema context for deletes. */ +@Getter public class DeleteContext implements Serializable { + private static final long serialVersionUID = 1L; private final Option> customDeleteMarkerKeyValue; + @Accessors(fluent = true) private final boolean hasBuiltInDeleteField; private int hoodieOperationPos; private HoodieSchema readerSchema; @@ -108,25 +114,9 @@ private static int getHoodieOperationPos(HoodieSchema schema) { .orElseGet(() -> -1); } - public Option> getCustomDeleteMarkerKeyValue() { - return customDeleteMarkerKeyValue; - } - - public boolean hasBuiltInDeleteField() { - return hasBuiltInDeleteField; - } - - public int getHoodieOperationPos() { - return hoodieOperationPos; - } - public DeleteContext withReaderSchema(HoodieSchema readerSchema) { this.readerSchema = readerSchema; this.hoodieOperationPos = getHoodieOperationPos(readerSchema); return this; } - - public HoodieSchema getReaderSchema() { - return readerSchema; - } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java index 2b35eaff4f30d..e4aae8f629401 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java @@ -23,7 +23,6 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.engine.HoodieReaderContext; import org.apache.hudi.common.model.BaseFile; -import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieBaseFile; import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.model.HoodieRecord; @@ -35,7 +34,6 @@ import org.apache.hudi.common.table.read.buffer.FileGroupRecordBufferLoader; import org.apache.hudi.common.table.read.buffer.HoodieFileGroupRecordBuffer; import org.apache.hudi.common.util.ConfigUtils; -import org.apache.hudi.common.util.Either; import org.apache.hudi.common.util.HoodieRecordUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; @@ -49,6 +47,10 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; @@ -67,7 +69,9 @@ * @param The type of engine-specific record representation, e.g.,{@code InternalRow} * in Spark and {@code RowData} in Flink. */ +@AllArgsConstructor public final class HoodieFileGroupReader implements Closeable { + private final HoodieReaderContext readerContext; private final HoodieTableMetaClient metaClient; private final InputSplit inputSplit; @@ -81,38 +85,127 @@ public final class HoodieFileGroupReader implements Closeable { private HoodieFileGroupRecordBuffer recordBuffer; private ClosableIterator baseFileIterator; private final Option> outputConverter; + @Getter private final HoodieReadStats readStats; // Callback to run custom logic on updates to the base files for the file group private final Option> fileGroupUpdateCallback; // The list of instant times read from the log blocks, this value is used by the log-compaction to allow optimized log-block scans + @Getter private List validBlockInstants = Collections.emptyList(); private BufferedRecordConverter bufferedRecordConverter; - private HoodieFileGroupReader(HoodieReaderContext readerContext, HoodieStorage storage, String tablePath, - String latestCommitTime, HoodieSchema dataSchema, HoodieSchema requestedSchema, - Option internalSchemaOpt, HoodieTableMetaClient hoodieTableMetaClient, TypedProperties props, - ReaderParameters readerParameters, InputSplit inputSplit, Option> updateCallback, - FileGroupRecordBufferLoader recordBufferLoader) { + @Builder(setterPrefix = "with") + private HoodieFileGroupReader( + HoodieReaderContext readerContext, + String latestCommitTime, + HoodieSchema dataSchema, + HoodieSchema requestedSchema, + Option internalSchemaOpt, + HoodieTableMetaClient hoodieTableMetaClient, + TypedProperties props, + Option baseFileOption, + Stream logFiles, + String partitionPath, + Long start, + Long length, + Iterator recordIterator, + Boolean shouldUseRecordPosition, + Boolean allowInflightInstants, + Boolean emitDelete, + Boolean sortOutput, + Option> fileGroupUpdateCallback, + FileGroupRecordBufferLoader recordBufferLoader) { + + // Validations + ValidationUtils.checkArgument(readerContext != null, "Reader context is required"); + ValidationUtils.checkArgument(hoodieTableMetaClient != null, "Hoodie table meta client is required"); + ValidationUtils.checkArgument(latestCommitTime != null, "Latest commit time is required"); + ValidationUtils.checkArgument(dataSchema != null, "Data schema is required"); + ValidationUtils.checkArgument(requestedSchema != null, "Requested schema is required"); + ValidationUtils.checkArgument(props != null, "Props is required"); + ValidationUtils.checkArgument(partitionPath != null, "Partition path is required"); + + // Handle defaults + if (internalSchemaOpt == null) { + internalSchemaOpt = Option.empty(); + } + if (baseFileOption == null) { + baseFileOption = Option.empty(); + } + if (start == null) { + start = 0L; + } + if (length == null) { + length = Long.MAX_VALUE; + } + if (shouldUseRecordPosition == null) { + shouldUseRecordPosition = false; + } + if (allowInflightInstants == null) { + allowInflightInstants = false; + } + if (emitDelete == null) { + emitDelete = false; + } + if (sortOutput == null) { + sortOutput = false; + } + if (fileGroupUpdateCallback == null) { + fileGroupUpdateCallback = Option.empty(); + } + + // Derive tablePath + String tablePath = hoodieTableMetaClient.getBasePath().toString(); + + // Set the storage with the readerContext's storage configuration + HoodieStorage storage = hoodieTableMetaClient.getStorage().newInstance(new StoragePath(tablePath), readerContext.getStorageConfiguration()); + + // Handle recordBufferLoader default + if (recordBufferLoader == null) { + if (recordIterator != null) { + recordBufferLoader = FileGroupRecordBufferLoader.createStreamingRecordsBufferLoader(); + } else { + recordBufferLoader = FileGroupRecordBufferLoader.createDefault(); + } + } + + // Build composite objects using static helpers + this.readerParameters = ReaderParameters.builder() + .shouldUseRecordPosition(shouldUseRecordPosition) + .emitDeletes(emitDelete) + .sortOutputs(sortOutput) + .inflightInstantsAllowed(allowInflightInstants) + .build(); + this.inputSplit = InputSplit.builder() + .baseFileOption(baseFileOption) + .logFileStream(logFiles) + .recordIterator((Iterator) recordIterator) + .partitionPath(partitionPath) + .start(start) + .length(length) + .build(); + + // Initialize fields this.readerContext = readerContext; this.recordBufferLoader = recordBufferLoader; - this.fileGroupUpdateCallback = updateCallback; + this.fileGroupUpdateCallback = fileGroupUpdateCallback; this.metaClient = hoodieTableMetaClient; this.storage = storage; - this.readerParameters = readerParameters; - this.inputSplit = inputSplit; + readerContext.setHasLogFiles(this.inputSplit.hasLogFiles()); readerContext.getRecordContext().setPartitionPath(inputSplit.getPartitionPath()); if (readerContext.getHasLogFiles() && inputSplit.getStart() != 0) { throw new IllegalArgumentException("Filegroup reader is doing log file merge but not reading from the start of the base file"); } HoodieTableConfig tableConfig = hoodieTableMetaClient.getTableConfig(); - this.props = ConfigUtils.getMergeProps(props, tableConfig); + props = ConfigUtils.getMergeProps(props, tableConfig); + this.props = props; this.partitionPathFields = tableConfig.getPartitionFields(); readerContext.initRecordMerger(props); readerContext.setTablePath(tablePath); readerContext.setLatestCommitTime(latestCommitTime); boolean isSkipMerge = ConfigUtils.getStringWithAltKeys(props, HoodieReaderConfig.MERGE_TYPE, true).equalsIgnoreCase(HoodieReaderConfig.REALTIME_SKIP_MERGE); - readerContext.setShouldMergeUseRecordPosition(readerParameters.useRecordPosition() && !isSkipMerge && readerContext.getHasLogFiles() && inputSplit.isParquetBaseFile()); + readerContext.setShouldMergeUseRecordPosition(readerParameters.shouldUseRecordPosition() && !isSkipMerge && readerContext.getHasLogFiles() && inputSplit.isParquetBaseFile()); readerContext.setHasBootstrapBaseFile(inputSplit.getBaseFileOption().flatMap(HoodieBaseFile::getBootstrapBaseFile).isPresent()); readerContext.setSchemaHandler(readerContext.getRecordContext().supportsParquetRowIndex() ? new ParquetRowIndexBasedSchemaHandler<>(readerContext, dataSchema, requestedSchema, internalSchemaOpt, props, metaClient) @@ -248,13 +341,6 @@ boolean hasNext() throws IOException { } } - /** - * @return statistics of reading a file group. - */ - public HoodieReadStats getStats() { - return readStats; - } - /** * @return The next record after calling {@link #hasNext}. */ @@ -266,10 +352,6 @@ BufferedRecord next() { return nextVal; } - public List getValidBlockInstants() { - return validBlockInstants; - } - /** * Notifies a write failure with the given record key. */ @@ -355,175 +437,4 @@ public void close() { } } } - - public static Builder newBuilder() { - return new Builder<>(); - } - - public static class Builder { - private HoodieReaderContext readerContext; - private HoodieStorage storage; - private String tablePath; - private String latestCommitTime; - private HoodieSchema dataSchema; - private HoodieSchema requestedSchema; - private Option internalSchemaOpt = Option.empty(); - private HoodieTableMetaClient hoodieTableMetaClient; - private TypedProperties props; - private Option baseFileOption; - private Stream logFiles; - private String partitionPath; - private long start = 0; - private long length = Long.MAX_VALUE; - private Iterator recordIterator; - private boolean shouldUseRecordPosition = false; - private boolean allowInflightInstants = false; - private boolean emitDelete; - private boolean sortOutput = false; - private Option> fileGroupUpdateCallback = Option.empty(); - private FileGroupRecordBufferLoader recordBufferLoader; - - public Builder withReaderContext(HoodieReaderContext readerContext) { - this.readerContext = readerContext; - return this; - } - - public Builder withLatestCommitTime(String latestCommitTime) { - this.latestCommitTime = latestCommitTime; - return this; - } - - public Builder withFileSlice(FileSlice fileSlice) { - this.baseFileOption = fileSlice.getBaseFile(); - this.logFiles = fileSlice.getLogFiles(); - this.partitionPath = fileSlice.getPartitionPath(); - return this; - } - - public Builder withBaseFileOption(Option baseFileOption) { - this.baseFileOption = baseFileOption; - return this; - } - - public Builder withLogFiles(Stream logFiles) { - this.logFiles = logFiles; - return this; - } - - public Builder withRecordIterator(Iterator recordIterator) { - this.recordIterator = (Iterator) recordIterator; - this.recordBufferLoader = FileGroupRecordBufferLoader.createStreamingRecordsBufferLoader(); - return this; - } - - public Builder withPartitionPath(String partitionPath) { - this.partitionPath = partitionPath; - return this; - } - - public Builder withDataSchema(HoodieSchema dataSchema) { - this.dataSchema = dataSchema; - return this; - } - - public Builder withRequestedSchema(HoodieSchema requestedSchema) { - this.requestedSchema = requestedSchema; - return this; - } - - public Builder withInternalSchema(Option internalSchemaOpt) { - this.internalSchemaOpt = internalSchemaOpt; - return this; - } - - public Builder withHoodieTableMetaClient(HoodieTableMetaClient hoodieTableMetaClient) { - this.hoodieTableMetaClient = hoodieTableMetaClient; - this.tablePath = hoodieTableMetaClient.getBasePath().toString(); - return this; - } - - public Builder withProps(TypedProperties props) { - this.props = props; - return this; - } - - public Builder withStart(long start) { - this.start = start; - return this; - } - - public Builder withLength(long length) { - this.length = length; - return this; - } - - public Builder withShouldUseRecordPosition(boolean shouldUseRecordPosition) { - this.shouldUseRecordPosition = shouldUseRecordPosition; - return this; - } - - public Builder withAllowInflightInstants(boolean allowInflightInstants) { - this.allowInflightInstants = allowInflightInstants; - return this; - } - - public Builder withEmitDelete(boolean emitDelete) { - this.emitDelete = emitDelete; - return this; - } - - public Builder withFileGroupUpdateCallback(Option> fileGroupUpdateCallback) { - this.fileGroupUpdateCallback = fileGroupUpdateCallback; - return this; - } - - /** - * If true, the output of the merge will be sorted instead of appending log records to end of the iterator if they do not have matching keys in the base file. - * This assumes that the base file is already sorted by key. - * @param sortOutput whether to sort the output iterator - * @return this builder instance - */ - public Builder withSortOutput(boolean sortOutput) { - this.sortOutput = sortOutput; - return this; - } - - public Builder withRecordBufferLoader(FileGroupRecordBufferLoader recordBufferLoader) { - this.recordBufferLoader = recordBufferLoader; - return this; - } - - public HoodieFileGroupReader build() { - ValidationUtils.checkArgument(readerContext != null, "Reader context is required"); - ValidationUtils.checkArgument(hoodieTableMetaClient != null, "Hoodie table meta client is required"); - ValidationUtils.checkArgument(tablePath != null, "Table path is required"); - // set the storage with the readerContext's storage configuration - this.storage = hoodieTableMetaClient.getStorage().newInstance(new StoragePath(tablePath), readerContext.getStorageConfiguration()); - - ValidationUtils.checkArgument(storage != null, "Storage is required"); - ValidationUtils.checkArgument(latestCommitTime != null, "Latest commit time is required"); - ValidationUtils.checkArgument(dataSchema != null, "Data schema is required"); - ValidationUtils.checkArgument(requestedSchema != null, "Requested schema is required"); - ValidationUtils.checkArgument(props != null, "Props is required"); - ValidationUtils.checkArgument(baseFileOption != null, "Base file option is required"); - ValidationUtils.checkArgument(partitionPath != null, "Partition path is required"); - - if (recordBufferLoader == null) { - recordBufferLoader = FileGroupRecordBufferLoader.createDefault(); - } - - ReaderParameters readerParameters = ReaderParameters.builder() - .shouldUseRecordPosition(shouldUseRecordPosition) - .emitDeletes(emitDelete) - .sortOutputs(sortOutput) - .allowInflightInstants(allowInflightInstants) - .build(); - InputSplit inputSplit = new InputSplit(baseFileOption, recordIterator != null ? Either.right(recordIterator) : Either.left(logFiles == null ? Stream.empty() : logFiles), - partitionPath, start, length); - return new HoodieFileGroupReader<>( - readerContext, storage, tablePath, latestCommitTime, dataSchema, requestedSchema, internalSchemaOpt, hoodieTableMetaClient, - props, readerParameters, inputSplit, fileGroupUpdateCallback, recordBufferLoader); - } - } - } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java index 01f3e513e3411..da0bfb567abe2 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java @@ -32,8 +32,11 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; @@ -108,11 +111,13 @@ * *

    IMPORTANT: the reader may optionally choose to fall back to reading the latest snapshot if there are files decoding the commit metadata are already cleaned. */ +@Slf4j public class IncrementalQueryAnalyzer { + public static final String START_COMMIT_EARLIEST = "earliest"; - private static final Logger LOG = LoggerFactory.getLogger(IncrementalQueryAnalyzer.class); private final HoodieTableMetaClient metaClient; + @Getter private final Option startCompletionTime; private final Option endCompletionTime; private final InstantRange.RangeType rangeType; @@ -143,10 +148,6 @@ private IncrementalQueryAnalyzer( this.limit = limit; } - public Option getStartCompletionTime() { - return startCompletionTime; - } - /** * Returns a builder. */ @@ -206,7 +207,7 @@ public QueryContext analyze() { String endInstant = endCompletionTime.isEmpty() ? null : lastInstant; return QueryContext.create(startInstant, endInstant, instants, archivedInstants, activeInstants, filteredTimeline, archivedReadTimeline); } catch (Exception ex) { - LOG.error("Got exception when generating incremental query info", ex); + log.error("Got exception when generating incremental query info", ex); throw new HoodieException(ex); } } @@ -284,6 +285,7 @@ private static HoodieTimeline filterInstantsAsPerUserConfigs( /** * Builder for {@link IncrementalQueryAnalyzer}. */ + @NoArgsConstructor public static class Builder { /** * Start completion time. @@ -304,9 +306,6 @@ public static class Builder { */ private int limit = -1; - public Builder() { - } - public Builder startCompletionTime(String startCompletionTime) { this.startCompletionTime = startCompletionTime; return this; @@ -361,9 +360,12 @@ public IncrementalQueryAnalyzer build() { /** * Represents the analyzed query context. */ + @AllArgsConstructor(access = AccessLevel.PRIVATE) + @Getter public static class QueryContext { + public static final QueryContext EMPTY = - new QueryContext(null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null, null); + new QueryContext(Option.empty(), Option.empty(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null, null); /** * An empty option indicates consumption from the earliest instant. @@ -373,6 +375,8 @@ public static class QueryContext { * An empty option indicates consumption to the latest instant. */ private final Option endInstant; + @Getter(AccessLevel.NONE) + private final List instants; private final List archivedInstants; private final List activeInstants; /** @@ -382,25 +386,9 @@ public static class QueryContext { /** * The archived timeline to read filtered by given configurations. */ + @Nullable + @Getter(AccessLevel.NONE) private final HoodieTimeline archivedTimeline; - private final List instants; - - private QueryContext( - @Nullable String startInstant, - @Nullable String endInstant, - List instants, - List archivedInstants, - List activeInstants, - HoodieTimeline activeTimeline, - @Nullable HoodieTimeline archivedTimeline) { - this.startInstant = Option.ofNullable(startInstant); - this.endInstant = Option.ofNullable(endInstant); - this.archivedInstants = archivedInstants; - this.activeInstants = activeInstants; - this.activeTimeline = activeTimeline; - this.archivedTimeline = archivedTimeline; - this.instants = instants; - } public static QueryContext create( @Nullable String startInstant, @@ -410,7 +398,7 @@ public static QueryContext create( List activeInstants, HoodieTimeline activeTimeline, @Nullable HoodieTimeline archivedTimeline) { - return new QueryContext(startInstant, endInstant, instants, archivedInstants, activeInstants, activeTimeline, archivedTimeline); + return new QueryContext(Option.ofNullable(startInstant), Option.ofNullable(endInstant), instants, archivedInstants, activeInstants, activeTimeline, archivedTimeline); } public boolean isEmpty() { @@ -421,14 +409,6 @@ public List getInstantTimeList() { return this.instants; } - public Option getStartInstant() { - return startInstant; - } - - public Option getEndInstant() { - return endInstant; - } - /** * Returns the latest instant time which should be included physically in reading. */ @@ -441,14 +421,6 @@ public List getInstants() { return Stream.concat(archivedInstants.stream(), activeInstants.stream()).collect(Collectors.toList()); } - public List getArchivedInstants() { - return archivedInstants; - } - - public List getActiveInstants() { - return activeInstants; - } - public boolean isConsumingFromEarliest() { return startInstant.isEmpty(); } @@ -489,10 +461,6 @@ public Option getInstantRange() { } } - public HoodieTimeline getActiveTimeline() { - return this.activeTimeline; - } - public @Nullable HoodieTimeline getArchivedTimeline() { return archivedTimeline; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/InputSplit.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/InputSplit.java index 6d7b578e15b3e..a0f1a3d04e905 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/InputSplit.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/InputSplit.java @@ -24,10 +24,13 @@ import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.table.cdc.HoodieCDCUtils; -import org.apache.hudi.common.util.Either; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -38,9 +41,13 @@ * Represents a split of input data for reading, which includes the partition path the data belongs to along with an optional base file and a list of log files. * If there is only a base file, it is possible for the reader to specify a particular range of the file with the start and length parameters. */ +@Getter public class InputSplit { + private final Option baseFileOption; + @Getter(AccessLevel.NONE) private final List logFiles; + @Getter(AccessLevel.NONE) private final Option> recordIterator; private final String partitionPath; // Byte offset to start reading from the base file @@ -48,26 +55,41 @@ public class InputSplit { // Length of bytes to read from the base file private final long length; - InputSplit(Option baseFileOption, - Either, Iterator> recordsToMerge, - String partitionPath, long start, long length) { - this.baseFileOption = baseFileOption; - if (recordsToMerge.isLeft()) { - this.logFiles = recordsToMerge.asLeft().sorted(HoodieLogFile.getLogFileComparator()) + @Builder + private InputSplit( + Option baseFileOption, + Stream logFileStream, + Iterator recordIterator, + String partitionPath, + long start, + long length) { + + // Ensure we do not have both sources of data to merge + // i.e. logFileStream and recordIterator cannot be both non-null + ValidationUtils.checkArgument(!(logFileStream != null && recordIterator != null), + "Cannot provide both logFileStream and recordIterator"); + + this.baseFileOption = Option.ofNullable(baseFileOption).orElse(Option.empty()); + this.partitionPath = partitionPath; + this.start = start; + this.length = length; + + if (logFileStream != null) { + // Process Log Files (if provided) + this.logFiles = logFileStream + .sorted(HoodieLogFile.getLogFileComparator()) .filter(logFile -> !logFile.getFileName().endsWith(HoodieCDCUtils.CDC_LOGFILE_SUFFIX)) .collect(Collectors.toList()); this.recordIterator = Option.empty(); + } else if (recordIterator != null) { + // Process Record Iterator (if provided) + this.logFiles = Collections.emptyList(); + this.recordIterator = Option.of(recordIterator); } else { + // Handle Case with neither (Base file only read) this.logFiles = Collections.emptyList(); - this.recordIterator = Option.of(recordsToMerge.asRight()); + this.recordIterator = Option.empty(); } - this.partitionPath = partitionPath; - this.start = start; - this.length = length; - } - - public Option getBaseFileOption() { - return baseFileOption; } public List getLogFiles() { @@ -79,18 +101,6 @@ public boolean hasLogFiles() { return !logFiles.isEmpty(); } - public String getPartitionPath() { - return partitionPath; - } - - public long getStart() { - return start; - } - - public long getLength() { - return length; - } - public boolean isParquetBaseFile() { return baseFileOption.map(baseFile -> HoodieFileFormat.fromFileExtension(baseFile.getStoragePath().getFileExtension()) == HoodieFileFormat.PARQUET).orElse(false); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/ReaderParameters.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/ReaderParameters.java index bfb791fe5fa78..d19ad39418b34 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/ReaderParameters.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/ReaderParameters.java @@ -19,78 +19,38 @@ package org.apache.hudi.common.table.read; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + /** * Parameters for how the reader should process the FileGroup while reading. */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder public class ReaderParameters { + // Rely on the position of the record in the file instead of the record keys while merging data between base and log files - private final boolean useRecordPosition; + @Getter(AccessLevel.NONE) + @Builder.Default + private final boolean shouldUseRecordPosition = false; // Whether to emit delete records while reading - private final boolean emitDelete; + @Builder.Default + private final boolean emitDeletes = false; // Whether to sort the output records while reading, this implicitly requires the base file to be sorted - private final boolean sortOutput; + @Builder.Default + private final boolean sortOutputs = false; // Allows to consider inflight instants while merging log records using HoodieMergedLogRecordReader // The inflight instants need to be considered while updating RLI records. RLI needs to fetch the revived // and deleted keys from the log files written as part of active data commit. During the RLI update, - // the allowInflightInstants flag would need to be set to true. This would ensure the HoodieMergedLogRecordReader + // the inflightInstantsAllowed flag would need to be set to true. This would ensure the HoodieMergedLogRecordReader // considers the log records which are inflight. - private final boolean allowInflightInstants; - - private ReaderParameters(boolean useRecordPosition, boolean emitDelete, boolean sortOutput, boolean allowInflightInstants) { - this.useRecordPosition = useRecordPosition; - this.emitDelete = emitDelete; - this.sortOutput = sortOutput; - this.allowInflightInstants = allowInflightInstants; - } - - public boolean useRecordPosition() { - return useRecordPosition; - } - - public boolean emitDeletes() { - return emitDelete; - } - - public boolean sortOutputs() { - return sortOutput; - } - - public boolean allowInflightInstants() { - return allowInflightInstants; - } - - static Builder builder() { - return new Builder(); - } - - static class Builder { - private boolean shouldUseRecordPosition = false; - private boolean emitDelete = false; - private boolean sortOutput = false; - private boolean allowInflightInstants = false; - - public Builder shouldUseRecordPosition(boolean shouldUseRecordPosition) { - this.shouldUseRecordPosition = shouldUseRecordPosition; - return this; - } - - public Builder emitDeletes(boolean emitDelete) { - this.emitDelete = emitDelete; - return this; - } - - public Builder sortOutputs(boolean sortOutput) { - this.sortOutput = sortOutput; - return this; - } - - public Builder allowInflightInstants(boolean allowInflightInstants) { - this.allowInflightInstants = allowInflightInstants; - return this; - } + @Builder.Default + private final boolean inflightInstantsAllowed = false; - public ReaderParameters build() { - return new ReaderParameters(shouldUseRecordPosition, emitDelete, sortOutput, allowInflightInstants); - } + public boolean shouldUseRecordPosition() { + return shouldUseRecordPosition; } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/DefaultFileGroupRecordBufferLoader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/DefaultFileGroupRecordBufferLoader.java index 3e0609003ad33..e8ff9add0ad85 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/DefaultFileGroupRecordBufferLoader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/DefaultFileGroupRecordBufferLoader.java @@ -33,6 +33,9 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.storage.HoodieStorage; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + import java.util.List; /** @@ -40,16 +43,15 @@ * * @param the engine specific record type */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) class DefaultFileGroupRecordBufferLoader extends LogScanningRecordBufferLoader implements FileGroupRecordBufferLoader { + private static final DefaultFileGroupRecordBufferLoader INSTANCE = new DefaultFileGroupRecordBufferLoader<>(); static DefaultFileGroupRecordBufferLoader getInstance() { return INSTANCE; } - private DefaultFileGroupRecordBufferLoader() { - } - @Override public Pair, List> getRecordBuffer(HoodieReaderContext readerContext, HoodieStorage storage, @@ -62,15 +64,15 @@ public Pair, List> getRecordBuffer(Hoodie Option> fileGroupUpdateCallback) { boolean isSkipMerge = ConfigUtils.getStringWithAltKeys(props, HoodieReaderConfig.MERGE_TYPE, true).equalsIgnoreCase(HoodieReaderConfig.REALTIME_SKIP_MERGE); Option partialUpdateModeOpt = hoodieTableMetaClient.getTableConfig().getPartialUpdateMode(); - UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.emitDeletes(), fileGroupUpdateCallback, props); + UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.isEmitDeletes(), fileGroupUpdateCallback, props); FileGroupRecordBuffer recordBuffer; if (isSkipMerge) { recordBuffer = new UnmergedFileGroupRecordBuffer<>( readerContext, hoodieTableMetaClient, readerContext.getMergeMode(), partialUpdateModeOpt, props, readStats); - } else if (readerParameters.sortOutputs()) { + } else if (readerParameters.isSortOutputs()) { recordBuffer = new SortedKeyBasedFileGroupRecordBuffer<>( readerContext, hoodieTableMetaClient, readerContext.getMergeMode(), partialUpdateModeOpt, props, orderingFieldNames, updateProcessor); - } else if (readerParameters.useRecordPosition() && inputSplit.getBaseFileOption().isPresent()) { + } else if (readerParameters.shouldUseRecordPosition() && inputSplit.getBaseFileOption().isPresent()) { recordBuffer = new PositionBasedFileGroupRecordBuffer<>( readerContext, hoodieTableMetaClient, readerContext.getMergeMode(), partialUpdateModeOpt, inputSplit.getBaseFileOption().get().getCommitTime(), props, orderingFieldNames, updateProcessor); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/FileGroupRecordBuffer.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/FileGroupRecordBuffer.java index 702ecf5041e09..8d5d967ec2567 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/FileGroupRecordBuffer.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/FileGroupRecordBuffer.java @@ -50,6 +50,9 @@ import org.apache.hudi.internal.schema.action.InternalSchemaMerger; import org.apache.hudi.internal.schema.convert.InternalSchemaConverter; +import lombok.Getter; +import lombok.Setter; + import java.io.IOException; import java.io.Serializable; import java.util.Iterator; @@ -77,8 +80,10 @@ abstract class FileGroupRecordBuffer implements HoodieFileGroupRecordBuffer> payloadClasses; protected final TypedProperties props; protected final ExternalSpillableMap> records; + @Getter protected final DeleteContext deleteContext; protected final BufferedRecordConverter bufferedRecordConverter; + @Setter protected ClosableIterator baseFileIterator; protected UpdateProcessor updateProcessor; protected Iterator> logRecordIterator; @@ -87,6 +92,7 @@ abstract class FileGroupRecordBuffer implements HoodieFileGroupRecordBuffer bufferedRecordMerger; + @Getter protected long totalLogRecords = 0; protected FileGroupRecordBuffer(HoodieReaderContext readerContext, @@ -131,15 +137,6 @@ protected ExternalSpillableMap> initializeRecord readerContext.getRecordSizeEstimator(), diskMapType, readerContext.getRecordSerializer(), isBitCaskDiskMapCompressionEnabled, getClass().getSimpleName()); } - @Override - public void setBaseFileIterator(ClosableIterator baseFileIterator) { - this.baseFileIterator = baseFileIterator; - } - - public DeleteContext getDeleteContext() { - return deleteContext; - } - /** * This allows hasNext() to be called multiple times without incrementing the iterator by more than 1 * record. It does come with the caveat that hasNext() must be called every time before next(). But @@ -169,10 +166,6 @@ public int size() { return records.size(); } - public long getTotalLogRecords() { - return totalLogRecords; - } - @Override public ClosableIterator> getLogRecordIterator() { return new LogRecordIterator<>(this); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/LogScanningRecordBufferLoader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/LogScanningRecordBufferLoader.java index b6638b1ecd043..1c4f6082e4f15 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/LogScanningRecordBufferLoader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/LogScanningRecordBufferLoader.java @@ -48,7 +48,7 @@ protected List scanLogFiles(HoodieReaderContext readerContext, Ho .withInstantRange(readerContext.getInstantRange()) .withPartition(inputSplit.getPartitionPath()) .withRecordBuffer(recordBuffer) - .withAllowInflightInstants(readerParameters.allowInflightInstants()) + .withAllowInflightInstants(readerParameters.isInflightInstantsAllowed()) .withMetaClient(hoodieTableMetaClient) .build()) { readStats.setTotalLogReadTimeMs(logRecordReader.getTotalTimeTakenToReadAndMergeBlocks()); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/PositionBasedFileGroupRecordBuffer.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/PositionBasedFileGroupRecordBuffer.java index 73dd5dda5e870..5eaa54e033c83 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/PositionBasedFileGroupRecordBuffer.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/PositionBasedFileGroupRecordBuffer.java @@ -41,9 +41,8 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieKeyException; +import lombok.extern.slf4j.Slf4j; import org.roaringbitmap.longlong.Roaring64NavigableMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -61,8 +60,8 @@ * Here the position means that record position in the base file. The records from the base file is accessed from an iterator object. These records are merged when the * {@link #hasNext} method is called. */ +@Slf4j public class PositionBasedFileGroupRecordBuffer extends KeyBasedFileGroupRecordBuffer { - private static final Logger LOG = LoggerFactory.getLogger(PositionBasedFileGroupRecordBuffer.class); private static final String ROW_INDEX_COLUMN_NAME = "row_index"; public static final String ROW_INDEX_TEMPORARY_COLUMN_NAME = "_tmp_metadata_" + ROW_INDEX_COLUMN_NAME; @@ -96,7 +95,7 @@ public void processDataBlock(HoodieDataBlock dataBlock, Option keySpecO // Extract positions from data block. List recordPositions = extractRecordPositions(dataBlock, baseFileInstantTime); if (recordPositions == null) { - LOG.debug("Falling back to key based merge for data block"); + log.debug("Falling back to key based merge for data block"); fallbackToKeyBasedBuffer(); super.processDataBlock(dataBlock, keySpecOpt); return; @@ -181,7 +180,7 @@ public void processDeleteBlock(HoodieDeleteBlock deleteBlock) throws IOException List recordPositions = extractRecordPositions(deleteBlock, baseFileInstantTime); if (recordPositions == null) { - LOG.debug("Falling back to key based merging for delete block"); + log.debug("Falling back to key based merging for delete block"); fallbackToKeyBasedBuffer(); super.processDeleteBlock(deleteBlock); return; @@ -291,7 +290,7 @@ protected static List extractRecordPositions(HoodieLogBlock logBlock, String blockBaseFileInstantTime = logBlock.getBaseFileInstantTimeOfPositions(); if (StringUtils.isNullOrEmpty(blockBaseFileInstantTime) || !baseFileInstantTime.equals(blockBaseFileInstantTime)) { - LOG.debug("The record positions cannot be used because the base file instant time " + log.debug("The record positions cannot be used because the base file instant time " + "is either missing or different from the base file to merge. " + "Instant time in the header: {}, base file instant time of the file group: {}.", blockBaseFileInstantTime, baseFileInstantTime); @@ -299,7 +298,7 @@ protected static List extractRecordPositions(HoodieLogBlock logBlock, } Roaring64NavigableMap positions = logBlock.getRecordPositions(); if (positions == null || positions.isEmpty()) { - LOG.info("No record position info is found when attempting to do position based merge."); + log.info("No record position info is found when attempting to do position based merge."); return null; } @@ -309,7 +308,7 @@ protected static List extractRecordPositions(HoodieLogBlock logBlock, } if (blockPositions.isEmpty()) { - LOG.info("No positions are extracted."); + log.info("No positions are extracted."); return null; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/ReusableFileGroupRecordBufferLoader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/ReusableFileGroupRecordBufferLoader.java index aa3fbd22cf94b..bd3b3ec55bb36 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/ReusableFileGroupRecordBufferLoader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/ReusableFileGroupRecordBufferLoader.java @@ -58,7 +58,7 @@ public synchronized Pair, List> getRecord ReaderParameters readerParameters, HoodieReadStats readStats, Option> fileGroupUpdateCallback) { - UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.emitDeletes(), fileGroupUpdateCallback, props); + UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.isEmitDeletes(), fileGroupUpdateCallback, props); Option partialUpdateModeOpt = hoodieTableMetaClient.getTableConfig().getPartialUpdateMode(); if (cachedResults == null) { // Create an initial buffer to process the log files diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java index 8fa9aa1dad912..083a7762bec27 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.table.read.BufferedRecord; import org.apache.hudi.common.table.read.UpdateProcessor; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.ValidationUtils; import java.io.IOException; @@ -58,14 +59,18 @@ class SortedKeyBasedFileGroupRecordBuffer extends KeyBasedFileGroupRecordBuff @Override protected void initializeLogRecordIterator() { - logRecordIterator = records.values().stream().sorted(Comparator.comparing(BufferedRecord::getRecordKey)).iterator(); + // This buffer is only used when the base file format is HFile (requireSortedRecords()), which orders + // keys by UTF-8 bytes, not String (UTF-16) order, so sort with the matching comparator. + logRecordIterator = records.values().stream() + .sorted(Comparator.comparing(BufferedRecord::getRecordKey, StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)) + .iterator(); } @Override protected boolean hasNextBaseRecord(T baseRecord) throws IOException { String recordKey = readerContext.getRecordContext().getRecordKey(baseRecord, readerSchema); int comparison = 0; - while (!getLogRecordKeysSorted().isEmpty() && (comparison = getLogRecordKeysSorted().peek().compareTo(recordKey)) <= 0) { + while (!getLogRecordKeysSorted().isEmpty() && (comparison = StringUtils.compareUtf8Bytes(getLogRecordKeysSorted().peek(), recordKey)) <= 0) { String nextLogRecordKey = getLogRecordKeysSorted().poll(); if (comparison == 0) { break; // Log record key matches the base record key, exit loop after removing the key from the queue of log record keys @@ -102,7 +107,8 @@ protected boolean doHasNext() throws IOException { private Queue getLogRecordKeysSorted() { if (logRecordKeysSorted == null) { - logRecordKeysSorted = records.keySet().stream().map(Object::toString).collect(Collectors.toCollection(PriorityQueue::new)); + logRecordKeysSorted = records.keySet().stream().map(Object::toString) + .collect(Collectors.toCollection(() -> new PriorityQueue<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR))); } return logRecordKeysSorted; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/StreamingFileGroupRecordBufferLoader.java b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/StreamingFileGroupRecordBufferLoader.java index 02c1d5b4cb9c1..2087b7dceaf8e 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/StreamingFileGroupRecordBufferLoader.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/StreamingFileGroupRecordBufferLoader.java @@ -67,9 +67,9 @@ public Pair, List> getRecordBuffer(Hoodie readerContext.getSchemaHandler().setSchemaForUpdates(recordSchema); HoodieTableConfig tableConfig = hoodieTableMetaClient.getTableConfig(); Option partialUpdateModeOpt = tableConfig.getPartialUpdateMode(); - UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.emitDeletes(), fileGroupUpdateCallback, props); + UpdateProcessor updateProcessor = UpdateProcessor.create(readStats, readerContext, readerParameters.isEmitDeletes(), fileGroupUpdateCallback, props); FileGroupRecordBuffer recordBuffer; - if (readerParameters.sortOutputs()) { + if (readerParameters.isSortOutputs()) { recordBuffer = new SortedKeyBasedFileGroupRecordBuffer<>( readerContext, hoodieTableMetaClient, readerContext.getMergeMode(), partialUpdateModeOpt, props, orderingFieldNames, updateProcessor); } else { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/BaseHoodieTimeline.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/BaseHoodieTimeline.java index f32cce52c4515..aaf24bf85feee 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/BaseHoodieTimeline.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/BaseHoodieTimeline.java @@ -27,6 +27,8 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; +import lombok.Getter; + import java.io.IOException; import java.io.InputStream; import java.io.Serializable; @@ -60,6 +62,7 @@ public abstract class BaseHoodieTimeline implements HoodieTimeline { private static final String HASHING_ALGORITHM = "SHA-256"; + @Getter protected transient HoodieInstantReader instantReader; private List instants; // for efficient #contains queries. @@ -70,6 +73,7 @@ public abstract class BaseHoodieTimeline implements HoodieTimeline { private transient volatile Option firstNonSavepointCommit; // for efficient #isBeforeTimelineStartsByCompletionTime private transient volatile Option firstNonSavepointCommitByCompletionTime; + @Getter private String timelineHash; protected TimelineFactory factory; @@ -495,11 +499,6 @@ public boolean containsOrBeforeTimelineStarts(String instant) { return containsInstant(instant) || isBeforeTimelineStarts(instant); } - @Override - public String getTimelineHash() { - return timelineHash; - } - @Override public Stream getInstantsAsStream() { return instants.stream(); @@ -665,10 +664,6 @@ private String computeTimelineHash(List instants) { return StringUtils.toHexString(md.digest()); } - public HoodieInstantReader getInstantReader() { - return instantReader; - } - /** * Merges the given instant list into one and keep the sequence. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieInstant.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieInstant.java index 8d874708cbcb6..70935d7e40ef4 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieInstant.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieInstant.java @@ -20,6 +20,10 @@ import org.apache.hudi.common.util.StringUtils; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + import java.io.Serializable; import java.util.Comparator; import java.util.Objects; @@ -28,6 +32,8 @@ * A Hoodie Instant represents a action done on a hoodie table. All actions start with a inflight instant and then * create a completed instant after done. */ +@AllArgsConstructor +@Getter public class HoodieInstant implements Serializable, Comparable { public static final String FILE_NAME_FORMAT_ERROR = "The provided file name %s does not conform to the required format"; @@ -36,10 +42,12 @@ public class HoodieInstant implements Serializable, Comparable { private final State state; private final String action; + @Getter(AccessLevel.NONE) private final String requestedTime; private final String completionTime; // Marker for older formats, we need the state transition time (pre table version 7) private boolean isLegacy = false; + @Getter(AccessLevel.NONE) private final Comparator comparator; public HoodieInstant(State state, String action, String requestTime, Comparator comparator) { @@ -50,15 +58,6 @@ public HoodieInstant(State state, String action, String requestTime, String comp this(state, action, requestTime, completionTime, false, comparator); } - public HoodieInstant(State state, String action, String requestedTime, String completionTime, boolean isLegacy, Comparator comparator) { - this.state = state; - this.action = action; - this.requestedTime = requestedTime; - this.completionTime = completionTime; - this.isLegacy = isLegacy; - this.comparator = comparator; - } - public boolean isCompleted() { return state == State.COMPLETED; } @@ -71,18 +70,10 @@ public boolean isRequested() { return state == State.REQUESTED; } - public String getAction() { - return action; - } - public String requestedTime() { return requestedTime; } - public boolean isLegacy() { - return isLegacy; - } - @Override public boolean equals(Object o) { if (this == o) { @@ -95,14 +86,6 @@ public boolean equals(Object o) { return state == that.state && Objects.equals(action, that.action) && Objects.equals(requestedTime, that.requestedTime); } - public State getState() { - return state; - } - - public String getCompletionTime() { - return completionTime; - } - @Override public int hashCode() { return Objects.hash(state, action, requestedTime); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java index f9a59c6ab6233..4ad8ec769ac32 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java @@ -30,6 +30,7 @@ import org.apache.hudi.avro.model.HoodieSavepointMetadata; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; +import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.storage.HoodieInstantWriter; @@ -77,6 +78,10 @@ public interface HoodieTimeline extends HoodieInstantReader, Serializable { CLEAN_ACTION, SAVEPOINT_ACTION, RESTORE_ACTION, ROLLBACK_ACTION, COMPACTION_ACTION, LOG_COMPACTION_ACTION, REPLACE_COMMIT_ACTION, CLUSTERING_ACTION, INDEXING_ACTION}; + Set VALID_ACTIONS_FOR_ROLLING_METADATA = CollectionUtils.createSet( + COMMIT_ACTION, DELTA_COMMIT_ACTION, CLEAN_ACTION, + COMPACTION_ACTION, LOG_COMPACTION_ACTION, REPLACE_COMMIT_ACTION, CLUSTERING_ACTION); + String COMMIT_EXTENSION = "." + COMMIT_ACTION; String DELTA_COMMIT_EXTENSION = "." + DELTA_COMMIT_ACTION; String CLEAN_EXTENSION = "." + CLEAN_ACTION; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java index 4be5f941b9d86..2824866706265 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java @@ -37,4 +37,20 @@ public interface InstantComparator extends Serializable { * @return {@link Comparator} that orders primarily based on completion time and secondary ordering based on {@link #requestedTimeOrderedComparator()}. */ Comparator completionTimeOrderedComparator(); + + /** + * Returns the comparator implementing the instant ordering of this timeline version: + * completion-time based for v2, requested-time based for v1. + * + *

    Implementations must keep this consistent with {@link #getOrderingTime(HoodieInstant)}, + * which returns the primary timestamp this comparator orders by: a timeline walk that sorts by + * this comparator and then bounds instants by {@code getOrderingTime} relies on the two agreeing. + */ + Comparator orderingComparator(); + + /** + * Returns the timestamp ordering the given instant in this timeline version: completion time + * for v2 (null if the instant is not completed yet), requested time for v1. + */ + String getOrderingTime(HoodieInstant instant); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/LSMTimeline.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/LSMTimeline.java index 19938f7cd7cc9..267e3f1585748 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/LSMTimeline.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/LSMTimeline.java @@ -29,9 +29,8 @@ import org.apache.hudi.storage.StoragePathFilter; import org.apache.hudi.storage.StoragePathInfo; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.FileNotFoundException; import java.io.IOException; @@ -103,8 +102,8 @@ *

    Instants TTL

    * The timeline reader only reads instants of last limited days. We will by default skip the instants from LSM timeline that are generated long time ago. */ +@Slf4j public class LSMTimeline { - private static final Logger LOG = LoggerFactory.getLogger(LSMTimeline.class); public static final int LSM_TIMELINE_INSTANT_VERSION_1 = 1; @@ -158,7 +157,7 @@ public static int latestSnapshotVersion(HoodieTableMetaClient metaClient, Storag } } catch (Exception e) { // fallback to manifest file listing. - LOG.warn("Error reading version file {}", versionFilePath, e); + log.warn("Error reading version file {}", versionFilePath, e); } return allSnapshotVersions(metaClient, archivePath).stream().max(Integer::compareTo).orElse(-1); @@ -176,7 +175,7 @@ public static List allSnapshotVersions(HoodieTableMetaClient metaClient .map(LSMTimeline::getManifestVersion) .collect(Collectors.toList()); } catch (FileNotFoundException ex) { - LOG.debug("Archive path {} does not exist", archivePath); + log.debug("Archive path {} does not exist", archivePath); return Collections.emptyList(); } } @@ -256,7 +255,7 @@ public static int getFileLayer(String fileName) { } } catch (NumberFormatException e) { // log and ignore any format warnings - LOG.warn("error getting file layout for archived file: {}", fileName, e); + log.warn("error getting file layout for archived file: {}", fileName, e); } // return default value in case of any errors diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java index e28af75a1ef75..b7f4019dfacd4 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java @@ -40,10 +40,9 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.avro.specific.SpecificRecordBase; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -52,8 +51,8 @@ /** * Helper class to convert between different action related payloads and {@link HoodieArchivedMetaEntry}. */ +@Slf4j public class MetadataConversionUtils { - private static final Logger LOG = LoggerFactory.getLogger(MetadataConversionUtils.class); public static HoodieArchivedMetaEntry createMetaWrapper(HoodieInstant hoodieInstant, HoodieTableMetaClient metaClient) { try { @@ -172,7 +171,7 @@ public static HoodieArchivedMetaEntry createMetaWrapper( Option planBytes = planBuffer != null ? Option.of(planBuffer.array()) : Option.empty(); String instantTime = lsmTimelineRecord.get(ArchivedTimelineV2.INSTANT_TIME_ARCHIVED_META_FIELD).toString(); - String completionTime = lsmTimelineRecord.get(ArchivedTimelineV2.COMPLETION_TIME_ARCHIVED_META_FIELD).toString(); + String completionTime = ArchivedTimelineV2.completionTimeOrInstantTime(lsmTimelineRecord, instantTime); HoodieArchivedMetaEntry archivedMetaWrapper = new HoodieArchivedMetaEntry(); archivedMetaWrapper.setCommitTime(instantTime); @@ -387,17 +386,17 @@ public static void removeNullKeyFromMapMembersForCommitMetadata(T metadata) if (metadata instanceof HoodieReplaceCommitMetadata) { HoodieReplaceCommitMetadata hoodieCommitMetadata = (HoodieReplaceCommitMetadata) metadata; if (hoodieCommitMetadata.getPartitionToWriteStats().containsKey(null)) { - LOG.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToWriteStats().get(null)); + log.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToWriteStats().get(null)); hoodieCommitMetadata.getPartitionToWriteStats().remove(null); } if (hoodieCommitMetadata.getPartitionToReplaceFileIds().containsKey(null)) { - LOG.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToReplaceFileIds().get(null)); + log.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToReplaceFileIds().get(null)); hoodieCommitMetadata.getPartitionToReplaceFileIds().remove(null); } } else if (metadata instanceof HoodieCommitMetadata) { HoodieCommitMetadata hoodieCommitMetadata = (HoodieCommitMetadata) metadata; if (hoodieCommitMetadata.getPartitionToWriteStats().containsKey(null)) { - LOG.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToWriteStats().get(null)); + log.info("partition path is null for {}", hoodieCommitMetadata.getPartitionToWriteStats().get(null)); hoodieCommitMetadata.getPartitionToWriteStats().remove(null); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimeGeneratorBase.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimeGeneratorBase.java index b616640a90290..18d187e2fdf12 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimeGeneratorBase.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimeGeneratorBase.java @@ -26,8 +26,7 @@ import org.apache.hudi.exception.HoodieLockException; import org.apache.hudi.storage.StorageConfiguration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.util.Arrays; @@ -42,10 +41,9 @@ /** * Base time generator facility that maintains lock-related utilities. */ +@Slf4j public abstract class TimeGeneratorBase implements TimeGenerator, Serializable { - private static final Logger LOG = LoggerFactory.getLogger(TimeGeneratorBase.class); - /** * The lock provider. */ @@ -87,7 +85,7 @@ protected LockProvider getLockProvider() { synchronized (this) { if (lockProvider == null) { String lockProviderClass = lockConfiguration.getConfig().getString("hoodie.write.lock.provider"); - LOG.info("LockProvider for TimeGenerator: {}", lockProviderClass); + log.info("LockProvider for TimeGenerator: {}", lockProviderClass); lockProvider = (LockProvider) ReflectionUtils.loadClass(lockProviderClass, new Class[] {LockConfiguration.class, StorageConfiguration.class}, lockConfiguration, storageConf); @@ -121,10 +119,10 @@ private synchronized void closeQuietly() { if (lockProvider != null) { lockProvider.close(); lockProvider = null; - LOG.info("Released the connection of the timeGenerator lock"); + log.info("Released the connection of the timeGenerator lock"); } } catch (Exception e) { - LOG.info("Unable to release the connection of the timeGenerator lock"); + log.info("Unable to release the connection of the timeGenerator lock"); } } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineDiffHelper.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineDiffHelper.java index 502565dd0221a..a0159dd2bc3ec 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineDiffHelper.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineDiffHelper.java @@ -23,8 +23,12 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; @@ -37,13 +41,10 @@ /** * A helper class used to diff timeline. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Slf4j public class TimelineDiffHelper { - private static final Logger LOG = LoggerFactory.getLogger(TimelineDiffHelper.class); - - private TimelineDiffHelper() { - } - public static TimelineDiffResult getNewInstantsForIncrementalSync(HoodieTableMetaClient metaClient, HoodieTimeline oldTimeline, HoodieTimeline newTimeline) { @@ -72,7 +73,7 @@ public static TimelineDiffResult getNewInstantsForIncrementalSync(HoodieTableMet if (!lostPendingCompactions.isEmpty()) { // If a compaction is unscheduled, fall back to complete refresh of fs view since some log files could have been // moved. Its unsafe to incrementally sync in that case. - LOG.warn("Some pending compactions are no longer in new timeline (unscheduled ?). They are: {}", lostPendingCompactions); + log.warn("Some pending compactions are no longer in new timeline (unscheduled ?). They are: {}", lostPendingCompactions); return TimelineDiffResult.UNSAFE_SYNC_RESULT; } List finishedCompactionInstants = compactionInstants.stream() @@ -91,7 +92,7 @@ public static TimelineDiffResult getNewInstantsForIncrementalSync(HoodieTableMet return new TimelineDiffResult(newInstants, finishedCompactionInstants, finishedOrRemovedLogCompactionInstants, true); } else { // One or more timelines is empty - LOG.warn("One or more timelines is empty"); + log.warn("One or more timelines is empty"); return TimelineDiffResult.UNSAFE_SYNC_RESULT; } } @@ -125,40 +126,19 @@ private static List> getPendingActionTransiti /** * A diff result of timeline. */ + @AllArgsConstructor + @Getter public static class TimelineDiffResult { private final List newlySeenInstants; private final List finishedCompactionInstants; private final List finishedOrRemovedLogCompactionInstants; + @Accessors(fluent = true) private final boolean canSyncIncrementally; public static final TimelineDiffResult UNSAFE_SYNC_RESULT = new TimelineDiffResult(null, null, null, false); - public TimelineDiffResult(List newlySeenInstants, List finishedCompactionInstants, - List finishedOrRemovedLogCompactionInstants, boolean canSyncIncrementally) { - this.newlySeenInstants = newlySeenInstants; - this.finishedCompactionInstants = finishedCompactionInstants; - this.finishedOrRemovedLogCompactionInstants = finishedOrRemovedLogCompactionInstants; - this.canSyncIncrementally = canSyncIncrementally; - } - - public List getNewlySeenInstants() { - return newlySeenInstants; - } - - public List getFinishedCompactionInstants() { - return finishedCompactionInstants; - } - - public List getFinishedOrRemovedLogCompactionInstants() { - return finishedOrRemovedLogCompactionInstants; - } - - public boolean canSyncIncrementally() { - return canSyncIncrementally; - } - @Override public String toString() { return "TimelineDiffResult{" diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineLayout.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineLayout.java index d4ed4b53c916c..83ce1f20d2c25 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineLayout.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineLayout.java @@ -34,6 +34,8 @@ import org.apache.hudi.common.table.timeline.versioning.v2.TimelineV2Factory; import org.apache.hudi.common.util.collection.Pair; +import lombok.Getter; + import java.io.Serializable; import java.util.HashMap; import java.util.Map; @@ -81,44 +83,20 @@ public static TimelineLayout fromVersion(TimelineLayoutVersion version) { /** * Table Layout where state transitions are managed by renaming files. */ + @Getter private static class TimelineLayoutV0 extends TimelineLayout { private final InstantGenerator instantGenerator = new InstantGeneratorV1(); private final InstantFileNameGenerator instantFileNameGenerator = new InstantFileNameGeneratorV1(); private final TimelineFactory timelineFactory = new TimelineV1Factory(this); private final InstantComparator instantComparator = new InstantComparatorV1(); - private final InstantFileNameParser fileNameParser = new InstantFileNameParserV2(); + private final InstantFileNameParser instantFileNameParser = new InstantFileNameParserV2(); @Override public Stream filterHoodieInstants(Stream instantStream) { return instantStream; } - @Override - public InstantGenerator getInstantGenerator() { - return instantGenerator; - } - - @Override - public InstantFileNameGenerator getInstantFileNameGenerator() { - return instantFileNameGenerator; - } - - @Override - public TimelineFactory getTimelineFactory() { - return timelineFactory; - } - - @Override - public InstantComparator getInstantComparator() { - return instantComparator; - } - - @Override - public InstantFileNameParser getInstantFileNameParser() { - return fileNameParser; - } - @Override public CommitMetadataSerDe getCommitMetadataSerDe() { return new CommitMetadataSerDeV1(); @@ -157,44 +135,20 @@ public Stream filterHoodieInstants(Stream instantS /** * Timeline corresponding to Hudi 1.x */ + @Getter private static class TimelineLayoutV2 extends TimelineLayout { private final InstantGenerator instantGenerator = new InstantGeneratorV2(); private final InstantFileNameGenerator instantFileNameGenerator = new InstantFileNameGeneratorV2(); private final TimelineFactory timelineFactory = new TimelineV2Factory(this); private final InstantComparator instantComparator = new InstantComparatorV2(); - private final InstantFileNameParser fileNameParser = new InstantFileNameParserV2(); + private final InstantFileNameParser instantFileNameParser = new InstantFileNameParserV2(); @Override public Stream filterHoodieInstants(Stream instantStream) { return TimelineLayout.filterHoodieInstantsByLatestState(instantStream, InstantComparatorV2::getComparableAction); } - @Override - public InstantGenerator getInstantGenerator() { - return instantGenerator; - } - - @Override - public InstantFileNameGenerator getInstantFileNameGenerator() { - return instantFileNameGenerator; - } - - @Override - public TimelineFactory getTimelineFactory() { - return timelineFactory; - } - - @Override - public InstantComparator getInstantComparator() { - return instantComparator; - } - - @Override - public InstantFileNameParser getInstantFileNameParser() { - return fileNameParser; - } - @Override public CommitMetadataSerDe getCommitMetadataSerDe() { return new CommitMetadataSerDeV2(); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineUtils.java index cfc1520b23933..647cab77123d6 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineUtils.java @@ -40,8 +40,7 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; @@ -49,6 +48,7 @@ import java.io.InputStream; import java.text.ParseException; import java.util.AbstractMap; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; @@ -80,6 +80,7 @@ * 1) HiveSync - this can be used to query partitions that changed since previous sync. * 2) Incremental reads - InputFormats can use this API to query */ +@Slf4j public class TimelineUtils { public static final Set NOT_PARSABLE_TIMESTAMPS = new HashSet(3) { { @@ -88,7 +89,6 @@ public class TimelineUtils { add(HoodieTimeline.FULL_BOOTSTRAP_INSTANT_TS); } }; - private static final Logger LOG = LoggerFactory.getLogger(TimelineUtils.class); /** * Returns partitions that have new data strictly after commitTime. @@ -136,7 +136,7 @@ public static List getDroppedPartitions(HoodieTableMetaClient metaClient }); } catch (HoodieIOException e) { if (e.getCause() instanceof FileNotFoundException) { - LOG.warn("Instant {} not found in storage and has been archived", instant, e); + log.warn("Instant {} not found in storage and has been archived", instant, e); } else { throw e; } @@ -263,7 +263,7 @@ public static Map> getAllExtraMetadataForKey(HoodieTableM private static Option getMetadataValue(HoodieTableMetaClient metaClient, String extraMetadataKey, HoodieInstant instant) { try { - LOG.info("reading checkpoint info for:" + instant + " key: " + extraMetadataKey); + log.info("reading checkpoint info for:{} key: {}", instant, extraMetadataKey); byte[] contents = metaClient.getCommitsTimeline().getInstantDetails(instant).get(); if (instant.isCompleted()) { if (contents == null || contents.length == 0) { @@ -475,7 +475,7 @@ public static HoodieTimeline handleHollowCommitIfNeeded(HoodieTimeline completed "Found hollow commit: '%s'. Adjust config `%s` accordingly if to avoid throwing this exception.", hollowCommitTimestamp, INCREMENTAL_READ_HANDLE_HOLLOW_COMMIT.key())); case BLOCK: - LOG.warn("Found hollow commit '{}'. Config `{}` was set to `{}`: no data will be returned beyond '{}' until it's completed.", + log.warn("Found hollow commit '{}'. Config `{}` was set to `{}`: no data will be returned beyond '{}' until it's completed.", hollowCommitTimestamp, INCREMENTAL_READ_HANDLE_HOLLOW_COMMIT.key(), handlingMode, hollowCommitTimestamp); return completedCommitTimeline.findInstantsBefore(hollowCommitTimestamp); default: @@ -517,7 +517,7 @@ public static Option parseDateFromInstantTimeSafely(String timestamp) { if (NOT_PARSABLE_TIMESTAMPS.contains(timestamp)) { parsedDate = Option.of(new Date(Integer.parseInt(timestamp))); } else { - LOG.warn("Failed to parse timestamp {}: {}", timestamp, e.getMessage()); + log.warn("Failed to parse timestamp {}: {}", timestamp, e.getMessage()); parsedDate = Option.empty(); } } @@ -647,4 +647,30 @@ public static Option getHoodieInstantWriterOption(Hoodi } return writerOption; } + + /** + * Returns the latest reverse-ordered instant whose commit metadata contains at least one of the provided + * checkpoint metadata keys. + * + * @param timeline timeline to scan; expected to contain completed instants + * @param checkpointKeys checkpoint metadata keys to look for in commit metadata + * @return an {@link Option} containing a {@link Pair} of the matching instant's + * {@link HoodieInstant#toString()} value and its + * {@link HoodieCommitMetadata}; {@link Option#empty()} if no matching instant is found + * @throws IOException if reading commit metadata fails + */ + @SuppressWarnings("unchecked") + public static Option> getLatestInstantAndCommitMetadataWithValidCheckpointInfo( + HoodieTimeline timeline, String... checkpointKeys) throws IOException { + return (Option>) timeline.getReverseOrderedInstants().map(instant -> { + try { + HoodieCommitMetadata commitMetadata = timeline.readCommitMetadata(instant); + boolean hasCheckpointMetadata = Arrays.stream(checkpointKeys) + .anyMatch(key -> !StringUtils.isNullOrEmpty(commitMetadata.getMetadata(key))); + return hasCheckpointMetadata ? Option.of(Pair.of(instant.toString(), commitMetadata)) : Option.empty(); + } catch (IOException e) { + throw new HoodieIOException("Failed to parse HoodieCommitMetadata for " + instant.toString(), e); + } + }).filter(Option::isPresent).findFirst().orElse(Option.empty()); + } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/TimelineLayoutVersion.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/TimelineLayoutVersion.java index feceb81e33b62..cb2cee9f30ac0 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/TimelineLayoutVersion.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/TimelineLayoutVersion.java @@ -20,12 +20,15 @@ import org.apache.hudi.common.util.ValidationUtils; +import lombok.Getter; + import java.io.Serializable; import java.util.Objects; /** * Metadata Layout Version. Add new version when timeline format changes */ +@Getter public class TimelineLayoutVersion implements Serializable, Comparable { public static final Integer VERSION_0 = 0; // pre 0.5.1 version format @@ -38,7 +41,6 @@ public class TimelineLayoutVersion implements Serializable, Comparable VALID_EXTENSIONS_IN_ACTIVE_TIMELINE = new HashSet<>(Arrays.asList( @@ -77,7 +80,6 @@ public class ActiveTimelineV1 extends BaseTimelineV1 implements HoodieActiveTime REQUESTED_INDEX_COMMIT_EXTENSION, INFLIGHT_INDEX_COMMIT_EXTENSION, INDEX_COMMIT_EXTENSION, REQUESTED_SAVE_SCHEMA_ACTION_EXTENSION, INFLIGHT_SAVE_SCHEMA_ACTION_EXTENSION, SAVE_SCHEMA_ACTION_EXTENSION)); - private static final Logger LOG = LoggerFactory.getLogger(ActiveTimelineV1.class); protected HoodieTableMetaClient metaClient; private final InstantFileNameGenerator instantFileNameGenerator = new InstantFileNameGeneratorV1(); @@ -89,7 +91,7 @@ protected ActiveTimelineV1(HoodieTableMetaClient metaClient, Set include this.metaClient = metaClient; // multiple casts will make this lambda serializable - // http://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.16 - LOG.debug("Loaded instants upto : " + lastInstant()); + log.debug("Loaded instants upto : {}", lastInstant()); } public ActiveTimelineV1(HoodieTableMetaClient metaClient) { @@ -100,15 +102,6 @@ public ActiveTimelineV1(HoodieTableMetaClient metaClient, boolean applyLayoutFil this(metaClient, Collections.unmodifiableSet(VALID_EXTENSIONS_IN_ACTIVE_TIMELINE), applyLayoutFilter); } - /** - * For serialization and de-serialization only. - * - * @deprecated - */ - @Deprecated - public ActiveTimelineV1() { - } - /** * This method is only used when this object is deserialized in a spark executor. * @@ -126,13 +119,13 @@ public Set getValidExtensionsInActiveTimeline() { @Override public void createCompleteInstant(HoodieInstant instant) { - LOG.info("Creating a new complete instant {}", instant); + log.info("Creating a new complete instant {}", instant); createFileInMetaPath(instantFileNameGenerator.getFileName(instant), Option.empty(), false); } @Override public void createNewInstant(HoodieInstant instant) { - LOG.info("Creating a new instant {}", instant); + log.info("Creating a new instant {}", instant); // Create the in-flight file createFileInMetaPath(instantFileNameGenerator.getFileName(instant), Option.empty(), false); } @@ -140,7 +133,7 @@ public void createNewInstant(HoodieInstant instant) { @Override public HoodieInstant createRequestedCommitWithReplaceMetadata(String instantTime, String actionType) { HoodieInstant instant = instantGenerator.createNewInstant(HoodieInstant.State.REQUESTED, actionType, instantTime); - LOG.info("Creating a new instant {}", instant); + log.info("Creating a new instant {}", instant); // Create the request replace file createFileInMetaPath(instantFileNameGenerator.getFileName(instant), Option.of(new HoodieRequestedReplaceMetadata()), false); return instant; @@ -148,12 +141,12 @@ public HoodieInstant createRequestedCommitWithReplaceMetadata(String instantTime @Override public HoodieInstant saveAsComplete(HoodieInstant instant, Option metadata) { - LOG.info("Marking instant complete " + instant); + log.info("Marking instant complete {}", instant); ValidationUtils.checkArgument(instant.isInflight(), "Could not mark an already completed instant as complete again " + instant); HoodieInstant completedInstant = instantGenerator.createNewInstant(HoodieInstant.State.COMPLETED, instant.getAction(), instant.requestedTime()); transitionState(instant, completedInstant, metadata); - LOG.info("Completed {}", instant); + log.info("Completed {}", instant); return completedInstant; } @@ -176,10 +169,10 @@ public HoodieInstant saveAsComplete(boolean shouldLock, HoodieInstant instan @Override public HoodieInstant revertToInflight(HoodieInstant instant) { - LOG.info("Reverting instant to inflight {}", instant); + log.info("Reverting instant to inflight {}", instant); HoodieInstant inflight = TimelineUtils.getInflightInstant(instant, metaClient); revertCompleteToInflight(instant, inflight); - LOG.info("Reverted {} to inflight {}", instant, inflight); + log.info("Reverted {} to inflight {}", instant, inflight); return inflight; } @@ -216,18 +209,18 @@ public void deleteCompactionRequested(HoodieInstant instant) { @Override public void deleteInstantFileIfExists(HoodieInstant instant) { - LOG.info("Deleting instant {}", instant); + log.info("Deleting instant {}", instant); StoragePath commitFilePath = getInstantFileNamePath(instantFileNameGenerator.getFileName(instant)); try { if (metaClient.getStorage().exists(commitFilePath)) { boolean result = metaClient.getStorage().deleteFile(commitFilePath); if (result) { - LOG.info("Removed instant {}", instant); + log.info("Removed instant {}", instant); } else { throw new HoodieIOException("Could not delete instant " + instant + " with path " + commitFilePath); } } else { - LOG.info("The commit {} to remove does not exist", commitFilePath); + log.info("The commit {} to remove does not exist", commitFilePath); } } catch (IOException e) { throw new HoodieIOException("Could not remove commit " + commitFilePath, e); @@ -235,12 +228,12 @@ public void deleteInstantFileIfExists(HoodieInstant instant) { } private void deleteInstantFile(HoodieInstant instant) { - LOG.info("Deleting instant {}", instant); + log.info("Deleting instant {}", instant); StoragePath inFlightCommitFilePath = getInstantFileNamePath(instantFileNameGenerator.getFileName(instant)); try { boolean result = metaClient.getStorage().deleteFile(inFlightCommitFilePath); if (result) { - LOG.info("Removed instant {}", instant); + log.info("Removed instant {}", instant); } else { throw new HoodieIOException("Could not delete instant " + instant + " with path " + inFlightCommitFilePath); } @@ -541,7 +534,7 @@ protected void transitionState(HoodieInstant fromInstant, HoodieInstant toIn } else { storage.createImmutableFileInPath(getInstantFileNamePath(instantFileNameGenerator.getFileName(toInstant)), getHoodieInstantWriterOption(this, metadata)); } - LOG.info("Create new file for toInstant ?{}", getInstantFileNamePath(instantFileNameGenerator.getFileName(toInstant))); + log.info("Create new file for toInstant? {}", getInstantFileNamePath(instantFileNameGenerator.getFileName(toInstant))); } } catch (IOException e) { throw new HoodieIOException("Could not complete " + fromInstant, e); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineLoaderV1.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineLoaderV1.java index 8485dc7405d92..d95a8aab0c686 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineLoaderV1.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineLoaderV1.java @@ -39,10 +39,9 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import javax.annotation.Nullable; @@ -61,13 +60,14 @@ import java.util.regex.Pattern; import java.util.stream.StreamSupport; +@Slf4j public class ArchivedTimelineLoaderV1 implements ArchivedTimelineLoader { + private static final String MERGE_ARCHIVE_PLAN_NAME = "mergeArchivePlan"; private static final Pattern ARCHIVE_FILE_PATTERN = Pattern.compile("^\\.commits_\\.archive\\.([0-9]+).*"); private static final String STATE_TRANSITION_TIME = "stateTransitionTime"; private static final String ACTION_TYPE_KEY = "actionType"; - private static final Logger LOG = LoggerFactory.getLogger(ArchivedTimelineLoaderV1.class); @Override public void loadInstants(HoodieTableMetaClient metaClient, @@ -172,7 +172,7 @@ public void loadInstants(HoodieTableMetaClient metaClient, HoodieMergeArchiveFilePlan plan = TimelineMetadataUtils.deserializeAvroMetadataLegacy(FileIOUtils.readDataFromPath(storage, planPath).get(), HoodieMergeArchiveFilePlan.class); String mergedArchiveFileName = plan.getMergedArchiveFileName(); if (!StringUtils.isNullOrEmpty(mergedArchiveFileName) && fs.getPath().getName().equalsIgnoreCase(mergedArchiveFileName)) { - LOG.debug("Catch exception because of reading uncompleted merging archive file {}. Ignore it here.", mergedArchiveFileName); + log.debug("Catch exception because of reading uncompleted merging archive file {}. Ignore it here.", mergedArchiveFileName); continue; } } @@ -207,7 +207,7 @@ private int getArchivedFileSuffix(StoragePathInfo f) { } } catch (NumberFormatException e) { // log and ignore any format warnings - LOG.warn("error getting suffix for archived file: {}", f.getPath()); + log.warn("error getting suffix for archived file: {}", f.getPath()); } // return default value in case of any errors diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineV1.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineV1.java index 6fa23ec95c46f..aeea6e20c8686 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineV1.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/ArchivedTimelineV1.java @@ -28,6 +28,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import lombok.NoArgsConstructor; import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; import org.slf4j.Logger; @@ -53,6 +54,8 @@ import static org.apache.hudi.common.table.timeline.TimelineUtils.getInputStreamOptionLegacy; +// no-arg constructor is for serialization and de-serialization only +@NoArgsConstructor(onConstructor_ = @Deprecated) public class ArchivedTimelineV1 extends BaseTimelineV1 implements HoodieArchivedTimeline, HoodieInstantReader { private static final String HOODIE_COMMIT_ARCHIVE_LOG_FILE_PREFIX = "commits"; private static final String ACTION_TYPE_KEY = "actionType"; @@ -155,14 +158,6 @@ public ArchivedTimelineV1(HoodieTableMetaClient metaClient, Set logFiles this(metaClient, null, new LogFileFilter(logFiles), state); } - /** - * For serialization and de-serialization only. - * - * @deprecated - */ - public ArchivedTimelineV1() { - } - @Override public HoodieInstantReader getInstantReader() { return this; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CommitMetadataSerDeV1.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CommitMetadataSerDeV1.java index 24be7d83aedc3..52a24dcc9d0bc 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CommitMetadataSerDeV1.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CommitMetadataSerDeV1.java @@ -26,9 +26,8 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.storage.HoodieInstantWriter; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.specific.SpecificRecordBase; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; @@ -37,8 +36,8 @@ import static org.apache.hudi.common.table.timeline.MetadataConversionUtils.removeNullKeyFromMapMembersForCommitMetadata; import static org.apache.hudi.common.table.timeline.TimelineMetadataUtils.deserializeAvroMetadata; +@Slf4j public class CommitMetadataSerDeV1 implements CommitMetadataSerDe { - private static final Logger LOG = LoggerFactory.getLogger(CommitMetadataSerDeV1.class); @Override public T deserialize(HoodieInstant instant, InputStream inputStream, BooleanSupplier isEmptyInstant, Class clazz) throws IOException { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CompletionTimeQueryViewV1.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CompletionTimeQueryViewV1.java index f996627801766..18aeae515ec3f 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CompletionTimeQueryViewV1.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/CompletionTimeQueryViewV1.java @@ -28,6 +28,8 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.VisibleForTesting; +import lombok.Getter; + import java.io.Serializable; import java.time.Instant; import java.util.Date; @@ -40,6 +42,7 @@ import static org.apache.hudi.common.table.timeline.InstantComparison.LESSER_THAN; public class CompletionTimeQueryViewV1 implements CompletionTimeQueryView, Serializable { + private static final long serialVersionUID = 1L; private static final long MILLI_SECONDS_IN_THREE_DAYS = 3 * 24 * 3600 * 1000; @@ -60,6 +63,7 @@ public class CompletionTimeQueryViewV1 implements CompletionTimeQueryView, Seria * a completion query for t5 would trigger lazy loading with this cursor instant updated to t5. * This sliding window model amortizes redundant loading from different queries. */ + @Getter private final String cursorInstant; /** @@ -229,11 +233,6 @@ private void setCompletionTime(String beginInstantTime, String completionTime) { this.beginToCompletionInstantTimeMap.putIfAbsent(beginInstantTime, completionTime); } - @Override - public String getCursorInstant() { - return cursorInstant; - } - @Override public boolean isEmptyTable() { return this.beginToCompletionInstantTimeMap.isEmpty(); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java index 8b21e672b7974..2c4c150a1416e 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java @@ -74,4 +74,14 @@ public Comparator requestedTimeOrderedComparator() { public Comparator completionTimeOrderedComparator() { return COMPLETION_TIME_BASED_COMPARATOR; } + + @Override + public Comparator orderingComparator() { + return REQUESTED_TIME_BASED_COMPARATOR; + } + + @Override + public String getOrderingTime(HoodieInstant instant) { + return instant.requestedTime(); + } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ActiveTimelineV2.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ActiveTimelineV2.java index 69e36596e48da..7a45582bad603 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ActiveTimelineV2.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ActiveTimelineV2.java @@ -50,8 +50,8 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.io.InputStream; @@ -67,6 +67,9 @@ import static org.apache.hudi.common.table.timeline.TimelineUtils.getHoodieInstantWriterOption; +// no-arg constructor is for serialization and de-serialization only; @Deprecated marks it as such +@NoArgsConstructor(onConstructor_ = @Deprecated) +@Slf4j public class ActiveTimelineV2 extends BaseTimelineV2 implements HoodieActiveTimeline { public static final Set VALID_EXTENSIONS_IN_ACTIVE_TIMELINE = new HashSet<>(Arrays.asList( @@ -82,8 +85,6 @@ public class ActiveTimelineV2 extends BaseTimelineV2 implements HoodieActiveTime REQUESTED_INDEX_COMMIT_EXTENSION, INFLIGHT_INDEX_COMMIT_EXTENSION, INDEX_COMMIT_EXTENSION, REQUESTED_SAVE_SCHEMA_ACTION_EXTENSION, INFLIGHT_SAVE_SCHEMA_ACTION_EXTENSION, SAVE_SCHEMA_ACTION_EXTENSION, REQUESTED_CLUSTERING_COMMIT_EXTENSION, INFLIGHT_CLUSTERING_COMMIT_EXTENSION)); - - private static final Logger LOG = LoggerFactory.getLogger(ActiveTimelineV2.class); protected HoodieTableMetaClient metaClient; private final InstantFileNameGenerator instantFileNameGenerator = new InstantFileNameGeneratorV2(); @@ -95,7 +96,7 @@ private ActiveTimelineV2(HoodieTableMetaClient metaClient, Set includedE this.metaClient = metaClient; // multiple casts will make this lambda serializable - // http://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.16 - LOG.debug("Loaded instants upto : {}", lastInstant()); + log.debug("Loaded instants upto: {}", lastInstant()); } public ActiveTimelineV2(HoodieTableMetaClient metaClient) { @@ -106,15 +107,6 @@ public ActiveTimelineV2(HoodieTableMetaClient metaClient, boolean applyLayoutFil this(metaClient, Collections.unmodifiableSet(VALID_EXTENSIONS_IN_ACTIVE_TIMELINE), applyLayoutFilter); } - /** - * For serialization and de-serialization only. - * - * @deprecated - */ - @Deprecated - public ActiveTimelineV2() { - } - /** * This method is only used when this object is deserialized in a spark executor. * @@ -132,13 +124,13 @@ public Set getValidExtensionsInActiveTimeline() { @Override public void createCompleteInstant(HoodieInstant instant) { - LOG.info("Creating a new complete instant " + instant); + log.info("Creating a new complete instant {}", instant); createCompleteFileInMetaPath(true, instant, Option.empty()); } @Override public void createNewInstant(HoodieInstant instant) { - LOG.info("Creating a new instant " + instant); + log.info("Creating a new instant: {}", instant); ValidationUtils.checkArgument(!instant.isCompleted()); createFileInMetaPath(instantFileNameGenerator.getFileName(instant), Option.empty(), false); } @@ -146,7 +138,7 @@ public void createNewInstant(HoodieInstant instant) { @Override public HoodieInstant createRequestedCommitWithReplaceMetadata(String instantTime, String actionType) { HoodieInstant instant = instantGenerator.createNewInstant(HoodieInstant.State.REQUESTED, actionType, instantTime); - LOG.info("Creating a new instant " + instant); + log.info("Creating a new instant: {}", instant); // Create the request replace file createFileInMetaPath(instantFileNameGenerator.getFileName(instant), Option.of(new HoodieRequestedReplaceMetadata()), false); return instant; @@ -164,12 +156,12 @@ public HoodieInstant saveAsComplete(boolean shouldLock, HoodieInstant instan @Override public HoodieInstant saveAsComplete(boolean shouldLock, HoodieInstant instant, Option metadata, Option completionTimeOpt) { - LOG.info("Marking instant complete {}", instant); + log.info("Marking instant complete {}", instant); ValidationUtils.checkArgument(instant.isInflight(), "Could not mark an already completed instant as complete again " + instant); HoodieInstant commitInstant = instantGenerator.createNewInstant(HoodieInstant.State.COMPLETED, instant.getAction(), instant.requestedTime()); HoodieInstant completedInstant = transitionStateToComplete(shouldLock, instant, commitInstant, metadata, completionTimeOpt); - LOG.info("Completed " + instant); + log.info("Completed {}", instant); return completedInstant; } @@ -182,10 +174,10 @@ public HoodieInstant saveAsComplete(boolean shouldLock, HoodieInstant instan @Override public HoodieInstant revertToInflight(HoodieInstant instant) { - LOG.info("Reverting instant to inflight {}", instant); + log.info("Reverting instant to inflight {}", instant); HoodieInstant inflight = TimelineUtils.getInflightInstant(instant, metaClient); revertCompleteToInflight(instant, inflight); - LOG.info("Reverted {} to inflight {}", instant, inflight); + log.info("Reverted {} to inflight {}", instant, inflight); return inflight; } @@ -223,18 +215,18 @@ public void deleteCompactionRequested(HoodieInstant instant) { @Override public void deleteInstantFileIfExists(HoodieInstant instant) { - LOG.info("Deleting instant {}", instant); + log.info("Deleting instant {}", instant); StoragePath commitFilePath = getInstantFileNamePath(instantFileNameGenerator.getFileName(instant)); try { if (metaClient.getStorage().exists(commitFilePath)) { boolean result = metaClient.getStorage().deleteFile(commitFilePath); if (result) { - LOG.info("Removed instant {}", instant); + log.info("Removed instant {}", instant); } else { throw new HoodieIOException("Could not delete instant " + instant + " with path " + commitFilePath); } } else { - LOG.info("The commit {} to remove does not exist", commitFilePath); + log.info("The commit {} to remove does not exist", commitFilePath); } } catch (IOException e) { throw new HoodieIOException("Could not remove commit " + commitFilePath, e); @@ -242,12 +234,12 @@ public void deleteInstantFileIfExists(HoodieInstant instant) { } protected void deleteInstantFile(HoodieInstant instant) { - LOG.info("Deleting instant {}", instant); + log.info("Deleting instant {}", instant); StoragePath filePath = getInstantFileNamePath(instantFileNameGenerator.getFileName(instant)); try { boolean result = metaClient.getStorage().deleteFile(filePath); if (result) { - LOG.info("Removed instant {}", instant); + log.info("Removed instant {}", instant); } else { throw new HoodieIOException("Could not delete instant " + instant + " with path " + filePath); } @@ -602,7 +594,7 @@ protected void transitionPendingState( } else { storage.createImmutableFileInPath(getInstantFileNamePath(toInstantFileName), getInstantWriter(metadata)); } - LOG.info("Create new file for toInstant ?{}", getInstantFileNamePath(toInstantFileName)); + log.info("Create new file for toInstant ?{}", getInstantFileNamePath(toInstantFileName)); } } catch (IOException e) { throw new HoodieIOException("Could not complete " + fromInstant, e); @@ -763,7 +755,7 @@ protected String createCompleteFileInMetaPath(boolean shouldLock, HoodieInst metaClient.getStorage().createImmutableFileInPath(fullPath, writerOption); } completionTimeRef.set(completionTime); - LOG.info("Created new file for toInstant: {}", fullPath); + log.info("Created new file for toInstant: {}", fullPath); }); return completionTimeRef.get(); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java index 9a181cf9bcbec..2f8cdfe7d4e93 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.table.timeline.InstantComparison; import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.avro.generic.GenericRecord; import org.slf4j.Logger; @@ -147,7 +148,7 @@ public void loadCompactionDetailsInMemory(String compactionInstantTime) { public void loadCompactionDetailsInMemory(String startTs, String endTs) { // load compactionPlan - List loadedInstants = loadInstants(new HoodieArchivedTimeline.TimeRangeFilter(startTs, endTs), HoodieArchivedTimeline.LoadMode.PLAN, + List loadedInstants = loadInstants(new HoodieArchivedTimeline.ClosedClosedTimeRangeFilter(startTs, endTs), HoodieArchivedTimeline.LoadMode.PLAN, record -> record.get(ACTION_ARCHIVED_META_FIELD).toString().equals(COMMIT_ACTION) && record.get(PLAN_ARCHIVED_META_FIELD) != null ); @@ -164,7 +165,7 @@ record -> record.get(ACTION_ARCHIVED_META_FIELD).toString().equals(COMMIT_ACTION @Override public void loadCompletedInstantDetailsInMemory(String startTs, String endTs) { - List loadedInstants = loadInstants(new HoodieArchivedTimeline.TimeRangeFilter(startTs, endTs), HoodieArchivedTimeline.LoadMode.METADATA); + List loadedInstants = loadInstants(new HoodieArchivedTimeline.ClosedClosedTimeRangeFilter(startTs, endTs), HoodieArchivedTimeline.LoadMode.METADATA); appendLoadedInstants(loadedInstants); } @@ -217,9 +218,27 @@ public HoodieArchivedTimeline reload(String startTs) { } } + /** + * The completion time of an archived instant, falling back to its instant time when the record carries + * none. + * + *

    {@code completionTime} is declared {@code ["null","string"]} with a null default in + * {@code HoodieLSMTimelineInstant} and has no value for instants archived before the field existed, so it + * must not be dereferenced. Defaulting to the instant time is the fallback + * {@code CompletionTimeQueryViewV2#setCompletionTime} already documents for the same records. + * + * @param record an LSM timeline record. + * @param instantTime the instant time to fall back to. + * @return the completion time, never null as long as {@code instantTime} is not. + */ + public static String completionTimeOrInstantTime(GenericRecord record, String instantTime) { + String completionTime = StringUtils.objToString(record.get(COMPLETION_TIME_ARCHIVED_META_FIELD)); + return completionTime != null ? completionTime : instantTime; + } + private HoodieInstant readCommit(String instantTime, GenericRecord record, Option> instantDetailsConsumer) { final String action = record.get(ACTION_ARCHIVED_META_FIELD).toString(); - final String completionTime = record.get(COMPLETION_TIME_ARCHIVED_META_FIELD).toString(); + final String completionTime = completionTimeOrInstantTime(record, instantTime); instantDetailsConsumer.ifPresent(consumer -> consumer.accept(instantTime, record)); return instantGenerator.createNewInstant(HoodieInstant.State.COMPLETED, action, instantTime, completionTime); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java index e773c40692ee5..be8dbd0462b15 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java @@ -27,8 +27,10 @@ import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.InstantComparison; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.VisibleForTesting; +import lombok.Getter; import org.apache.avro.generic.GenericRecord; import java.io.Serializable; @@ -73,6 +75,7 @@ public class CompletionTimeQueryViewV2 implements CompletionTimeQueryView, Seria * a completion query for t5 would trigger lazy loading with this cursor instant updated to t5. * This sliding window model amortizes redundant loading from different queries. */ + @Getter private volatile String cursorInstant; /** @@ -301,8 +304,9 @@ private void load() { } private void readCompletionTime(String instantTime, GenericRecord record) { - final String completionTime = record.get(COMPLETION_TIME_ARCHIVED_META_FIELD).toString(); - setCompletionTime(instantTime, completionTime); + // The field is nullable in HoodieLSMTimelineInstant and is absent for instants archived before it + // existed, so leave the fallback to setCompletionTime rather than dereferencing here. + setCompletionTime(instantTime, StringUtils.objToString(record.get(COMPLETION_TIME_ARCHIVED_META_FIELD))); } private void setCompletionTime(String beginInstantTime, String completionTime) { @@ -313,11 +317,6 @@ private void setCompletionTime(String beginInstantTime, String completionTime) { this.instantTimeToCompletionTimeMap.putIfAbsent(beginInstantTime, completionTime); } - @Override - public String getCursorInstant() { - return cursorInstant; - } - @Override public boolean isEmptyTable() { return this.instantTimeToCompletionTimeMap.isEmpty(); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java index 4b20708789bef..f6b51292009cf 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java @@ -70,4 +70,18 @@ public Comparator requestedTimeOrderedComparator() { public Comparator completionTimeOrderedComparator() { return COMPLETION_TIME_BASED_COMPARATOR; } + + @Override + public Comparator orderingComparator() { + return COMPLETION_TIME_BASED_COMPARATOR; + } + + // On tables upgraded from version 6, completion times of instants before the upgrade boundary are + // backfilled from the meta file modification time and are not guaranteed durable ordering keys. + // This is safe: the upgrade runs a full compaction with no concurrent writers, so those pre-upgrade + // completion times no longer affect concurrency or file-slicing decisions. + @Override + public String getOrderingTime(HoodieInstant instant) { + return instant.getCompletionTime(); + } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/AbstractTableFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/AbstractTableFileSystemView.java index 9c4debe193659..c9468f152fbd6 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/AbstractTableFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/AbstractTableFileSystemView.java @@ -50,8 +50,8 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.FileNotFoundException; import java.io.IOException; @@ -93,9 +93,9 @@ * * The actual mechanism of fetching file slices from different view storages is delegated to sub-classes. */ +@Slf4j public abstract class AbstractTableFileSystemView implements SyncableFileSystemView, Serializable { - private static final Logger LOG = LoggerFactory.getLogger(AbstractTableFileSystemView.class); protected final HoodieTableMetadata tableMetadata; protected HoodieTableMetaClient metaClient; @@ -104,13 +104,14 @@ public abstract class AbstractTableFileSystemView implements SyncableFileSystemV // This is the commits timeline that will be visible for all views extending this view // This is nothing but the write timeline, which contains both ingestion and compaction(major and minor) writers. + @Getter private HoodieTimeline visibleCommitsAndCompactionTimeline; // Used to concurrently load and populate partition views private final ConcurrentHashMap addedPartitions = new ConcurrentHashMap<>(4096); // Sampling logger for replaced file groups read logs (log at INFO once every 5 times) - private final SamplingLogger replacedFileGroupsReadSamplingLogger = new SamplingLogger(LOG, 5); + private final SamplingLogger replacedFileGroupsReadSamplingLogger = new SamplingLogger(log, 5); // Locks to control concurrency. Sync operations use write-lock blocking all fetch operations. // For the common-case, we allow concurrent read of single or multiple partitions @@ -199,7 +200,7 @@ public List addFilesToView(String partitionPath, List sourceFileMappings = reader.getSourceFileMappingForPartition(partition); addBootstrapBaseFileMapping(sourceFileMappings.stream() @@ -211,7 +212,7 @@ public List addFilesToView(String partitionPath, List partitionList) { try { // For metadata table, log at DEBUG. For data table, log at INFO. if (metaClient.isMetadataTable()) { - LOG.debug("Building file system view for {} partition(s)", partitionSet.size()); + log.debug("Building file system view for {} partition(s)", partitionSet.size()); } else { - LOG.info("Building file system view for {} partition(s)", partitionSet.size()); + log.info("Building file system view for {} partition(s)", partitionSet.size()); } // Pairs of relative partition path and absolute partition path @@ -419,20 +420,20 @@ private void ensurePartitionsLoadedCorrectly(List partitionList) { Map, List> pathInfoMap = tableMetadata.listPartitions(absolutePartitionPathList); long endLsTs = System.currentTimeMillis(); - LOG.debug("Time taken to list partitions {} ={}", partitionSet, (endLsTs - beginLsTs)); + log.debug("Time taken to list partitions {} ={}", partitionSet, (endLsTs - beginLsTs)); pathInfoMap.forEach((partitionPair, statuses) -> { String relativePartitionStr = partitionPair.getLeft(); List groups = addFilesToView(relativePartitionStr, statuses); if (groups.isEmpty()) { storePartitionView(relativePartitionStr, Collections.emptyList()); } - LOG.debug("#files found in partition ({}) ={}", relativePartitionStr, statuses.size()); + log.debug("#files found in partition ({}) ={}", relativePartitionStr, statuses.size()); }); } catch (IOException e) { throw new HoodieIOException("Failed to list base files in partitions " + partitionSet, e); } long endTs = System.currentTimeMillis(); - LOG.debug("Time to load partition {} ={}", partitionSet, (endTs - beginTs)); + log.debug("Time to load partition {} ={}", partitionSet, (endTs - beginTs)); } partitionSet.forEach(partition -> @@ -451,7 +452,7 @@ private List getAllFilesInPartition(String relativePartitionPat long beginLsTs = System.currentTimeMillis(); List pathInfoList = tableMetadata.getAllFilesInPartition(partitionPath); long endLsTs = System.currentTimeMillis(); - LOG.debug( + log.debug( "#files found in partition ({}}) = {}, Time taken ={}", relativePartitionPath, pathInfoList.size(), (endLsTs - beginLsTs)); return pathInfoList; } @@ -473,9 +474,9 @@ protected void ensurePartitionLoadedCorrectly(String partition) { try { // For metadata table, log at DEBUG. For data table, log at INFO. if (metaClient.isMetadataTable()) { - LOG.debug("Building file system view for partition ({})", partitionPathStr); + log.debug("Building file system view for partition ({})", partitionPathStr); } else { - LOG.info("Building file system view for partition ({})", partitionPathStr); + log.info("Building file system view for partition ({})", partitionPathStr); } List groups = addFilesToView(partitionPathStr, getAllFilesInPartition(partitionPathStr)); if (groups.isEmpty()) { @@ -485,10 +486,10 @@ protected void ensurePartitionLoadedCorrectly(String partition) { throw new HoodieIOException("Failed to list base files in partition " + partitionPathStr, e); } } else { - LOG.debug("View already built for Partition :{}", partitionPathStr); + log.debug("View already built for Partition :{}", partitionPathStr); } long endTs = System.currentTimeMillis(); - LOG.debug("Time to load partition ({}) ={}", partitionPathStr, (endTs - beginTs)); + log.debug("Time to load partition ({}) ={}", partitionPathStr, (endTs - beginTs)); return true; }); } @@ -580,7 +581,7 @@ private boolean isFileSliceAfterPendingCompaction(FileSlice fileSlice) { */ protected Stream filterBaseFileAfterPendingCompaction(FileSlice fileSlice, boolean includeEmptyFileSlice) { if (isFileSliceAfterPendingCompaction(fileSlice)) { - LOG.debug("File Slice ({}) is in pending compaction", fileSlice); + log.debug("File Slice ({}) is in pending compaction", fileSlice); // Base file is filtered out of the file-slice as the corresponding compaction // instant not completed yet. FileSlice transformed = new FileSlice(fileSlice.getPartitionPath(), fileSlice.getBaseInstantTime(), fileSlice.getFileId()); @@ -606,7 +607,7 @@ private Stream filterUncommittedFiles(FileSlice fileSlice, boolean in .collect(Collectors.toList()); if ((fileSlice.getBaseFile().isPresent() && !committedBaseFile.isPresent()) || committedLogFiles.size() != fileSlice.getLogFileCnt()) { - LOG.debug("File Slice ({}) has uncommitted files.", fileSlice); + log.debug("File Slice ({}) has uncommitted files.", fileSlice); // A file is filtered out of the file-slice if the corresponding // instant has not completed yet. FileSlice transformed = new FileSlice(fileSlice.getPartitionPath(), fileSlice.getBaseInstantTime(), fileSlice.getFileId()); @@ -630,7 +631,7 @@ private FileSlice filterUncommittedLogs(FileSlice fileSlice) { .filter(logFile -> completionTimeQueryView.isCompleted(logFile.getDeltaCommitTime())) .collect(Collectors.toList()); if (committedLogFiles.size() != fileSlice.getLogFileCnt()) { - LOG.debug("File Slice ({}) has uncommitted log files.", fileSlice); + log.debug("File Slice ({}) has uncommitted log files.", fileSlice); // A file is filtered out of the file-slice if the corresponding // instant has not completed yet. FileSlice transformed = new FileSlice(fileSlice.getPartitionPath(), fileSlice.getBaseInstantTime(), fileSlice.getFileId()); @@ -1206,7 +1207,7 @@ public final Stream getAllFileGroupsStateless(String partitionS private Map getBootstrapBaseFileMappings(String partition) { try (BootstrapIndex.IndexReader reader = bootstrapIndex.createReader()) { - LOG.info("Bootstrap Index available for partition {}", partition); + log.info("Bootstrap Index available for partition {}", partition); List sourceFileMappings = reader.getSourceFileMappingForPartition(partition); return sourceFileMappings.stream() @@ -1742,13 +1743,4 @@ public void sync() { writeLock.unlock(); } } - - /** - * Return Only Commits and Compaction timeline for building file-groups. - * - * @return {@code HoodieTimeline} - */ - public HoodieTimeline getVisibleCommitsAndCompactionTimeline() { - return visibleCommitsAndCompactionTimeline; - } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java index d1baad8495165..c77e3b99152a7 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java @@ -32,8 +32,7 @@ import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.storage.StorageConfiguration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.util.concurrent.ConcurrentHashMap; @@ -58,8 +57,8 @@ * view. FileSystemViewManager uses a factory to construct specific implementation of file-system view and passes it to * clients for querying. */ +@Slf4j public class FileSystemViewManager { - private static final Logger LOG = LoggerFactory.getLogger(FileSystemViewManager.class); private static final String HOODIE_METASERVER_FILE_SYSTEM_VIEW_CLASS = "org.apache.hudi.common.table.view.HoodieMetaserverFileSystemView"; @@ -142,7 +141,7 @@ public void close() { private static RocksDbBasedFileSystemView createRocksDBBasedFileSystemView(HoodieEngineContext engineContext, FileSystemViewStorageConfig viewConf, HoodieTableMetaClient metaClient, boolean metadataTableEnabled, SerializableFunctionUnchecked metadataCreator) { - LOG.info("Creating RocksDB based view for basePath {}.", metaClient.getBasePath()); + log.info("Creating RocksDB based view for basePath {}.", metaClient.getBasePath()); HoodieTimeline timeline = metaClient.getActiveTimeline().filterCompletedAndCompactionInstants(); HoodieTableMetadata tableMetadata = getTableMetadata(engineContext, metaClient, metadataTableEnabled, metadataCreator); return new RocksDbBasedFileSystemView(tableMetadata, metaClient, timeline, viewConf); @@ -159,7 +158,7 @@ private static SpillableMapBasedFileSystemView createSpillableMapBasedFileSystem HoodieTableMetaClient metaClient, HoodieCommonConfig commonConfig, boolean metadataTableEnabled, SerializableFunctionUnchecked metadataCreator) { - LOG.info("Creating SpillableMap based view for basePath {}.", metaClient.getBasePath()); + log.info("Creating SpillableMap based view for basePath {}.", metaClient.getBasePath()); HoodieTimeline timeline = metaClient.getActiveTimeline().filterCompletedAndCompactionInstants(); HoodieTableMetadata tableMetadata = getTableMetadata(engineContext, metaClient, metadataTableEnabled, metadataCreator); return new SpillableMapBasedFileSystemView(tableMetadata, metaClient, timeline, viewConf, commonConfig); @@ -171,7 +170,7 @@ private static SpillableMapBasedFileSystemView createSpillableMapBasedFileSystem private static HoodieTableFileSystemView createInMemoryFileSystemView(HoodieEngineContext engineContext, FileSystemViewStorageConfig viewConf, HoodieTableMetaClient metaClient, boolean metadataTableEnabled, SerializableFunctionUnchecked metadataCreator) { - LOG.info("Creating InMemory based view for basePath {}.", metaClient.getBasePath()); + log.info("Creating InMemory based view for basePath {}.", metaClient.getBasePath()); HoodieTimeline timeline = metaClient.getActiveTimeline().filterCompletedAndCompactionInstants(); HoodieTableMetadata tableMetadata = getTableMetadata(engineContext, metaClient, metadataTableEnabled, metadataCreator); if (metaClient.getMetaserverConfig().isMetaserverEnabled()) { @@ -204,7 +203,7 @@ public static HoodieTableFileSystemView createInMemoryFileSystemViewWithTimeline HoodieTableMetaClient metaClient, HoodieMetadataConfig metadataConfig, HoodieTimeline timeline) { - LOG.info("Creating InMemory based view for basePath {}.", metaClient.getBasePath()); + log.info("Creating InMemory based view for basePath {}.", metaClient.getBasePath()); HoodieTableMetadata tableMetadata = getTableMetadata(engineContext, metaClient, metadataConfig.isEnabled(), unused -> metaClient.getTableFormat().getMetadataFactory().create(engineContext, metaClient.getStorage(), metadataConfig, metaClient.getBasePath().toString())); @@ -225,7 +224,7 @@ public static HoodieTableFileSystemView createInMemoryFileSystemViewWithTimeline */ private static RemoteHoodieTableFileSystemView createRemoteFileSystemView(FileSystemViewStorageConfig viewConf, HoodieTableMetaClient metaClient) { - LOG.info("Creating remote view for basePath {}. Server={}:{}, Timeout={}", metaClient.getBasePath(), + log.info("Creating remote view for basePath {}. Server={}:{}, Timeout={}", metaClient.getBasePath(), viewConf.getRemoteViewServerHost(), viewConf.getRemoteViewServerPort(), viewConf.getRemoteTimelineClientTimeoutSecs()); return new RemoteHoodieTableFileSystemView(metaClient, viewConf); } @@ -254,27 +253,27 @@ public static FileSystemViewManager createViewManager(final HoodieEngineContext final FileSystemViewStorageConfig config, final HoodieCommonConfig commonConfig, final SerializableFunctionUnchecked metadataCreator) { - LOG.info("Creating View Manager with storage type {}.", config.getStorageType()); + log.info("Creating View Manager with storage type {}.", config.getStorageType()); boolean metadataTableEnabled = metadataConfig.isEnabled(); switch (config.getStorageType()) { case EMBEDDED_KV_STORE: - LOG.debug("Creating embedded rocks-db based Table View"); + log.debug("Creating embedded rocks-db based Table View"); return new FileSystemViewManager(context, config, (metaClient, viewConf) -> createRocksDBBasedFileSystemView(context, viewConf, metaClient, metadataTableEnabled, metadataCreator)); case SPILLABLE_DISK: - LOG.debug("Creating Spillable Disk based Table View"); + log.debug("Creating Spillable Disk based Table View"); return new FileSystemViewManager(context, config, (metaClient, viewConf) -> createSpillableMapBasedFileSystemView(context, viewConf, metaClient, commonConfig, metadataTableEnabled, metadataCreator)); case MEMORY: - LOG.debug("Creating in-memory based Table View"); + log.debug("Creating in-memory based Table View"); return new FileSystemViewManager(context, config, (metaClient, viewConfig) -> createInMemoryFileSystemView(context, viewConfig, metaClient, metadataTableEnabled, metadataCreator)); case REMOTE_ONLY: - LOG.debug("Creating remote only table view"); + log.debug("Creating remote only table view"); return new FileSystemViewManager(context, config, (metaClient, viewConfig) -> createRemoteFileSystemView(viewConfig, metaClient)); case REMOTE_FIRST: - LOG.debug("Creating remote first table view"); + log.debug("Creating remote first table view"); return new FileSystemViewManager(context, config, (metaClient, viewConfig) -> { RemoteHoodieTableFileSystemView remoteFileSystemView = createRemoteFileSystemView(viewConfig, metaClient); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/HoodieTableFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/HoodieTableFileSystemView.java index 70f81ffa4cee7..cb1aa5723a0af 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/HoodieTableFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/HoodieTableFileSystemView.java @@ -33,8 +33,8 @@ import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.ArrayList; @@ -52,10 +52,9 @@ * @see TableFileSystemView * @since 0.3.0 */ +@Slf4j public class HoodieTableFileSystemView extends IncrementalTimelineSyncFileSystemView { - private static final Logger LOG = LoggerFactory.getLogger(HoodieTableFileSystemView.class); - //TODO: [HUDI-6249] change the maps below to implement ConcurrentMap // mapping from partition paths to file groups contained within them @@ -89,6 +88,7 @@ public class HoodieTableFileSystemView extends IncrementalTimelineSyncFileSystem /** * Flag to determine if closed. */ + @Getter private boolean closed = false; HoodieTableFileSystemView(HoodieTableMetadata tableMetadata, boolean enableIncrementalTimelineSync) { @@ -402,7 +402,7 @@ protected boolean isPartitionAvailableInStore(String partitionPath) { @Override protected void storePartitionView(String partitionPath, List fileGroups) { - LOG.debug("Adding file-groups for partition :{}, #FileGroups={}", partitionPath, fileGroups.size()); + log.debug("Adding file-groups for partition :{}, #FileGroups={}", partitionPath, fileGroups.size()); List newList = new ArrayList<>(fileGroups); partitionToFileGroupsMap.put(partitionPath, newList); } @@ -438,8 +438,8 @@ protected Option getReplaceInstant(final HoodieFileGroupId fileGr } @Override - public void close() { - super.close(); + protected void closeResources() throws Exception { + super.closeResources(); this.fgIdToPendingCompaction = null; this.fgIdToPendingLogCompaction = null; this.partitionToFileGroupsMap = null; @@ -450,7 +450,7 @@ public void close() { } @Override - public boolean isClosed() { - return closed; + public void close() { + super.close(); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/IncrementalTimelineSyncFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/IncrementalTimelineSyncFileSystemView.java index c56ccbe97eb6c..997fbdea224bb 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/IncrementalTimelineSyncFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/IncrementalTimelineSyncFileSystemView.java @@ -47,8 +47,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.List; @@ -59,10 +58,9 @@ /** * Adds the capability to incrementally sync the changes to file-system view as and when new instants gets completed. */ +@Slf4j public abstract class IncrementalTimelineSyncFileSystemView extends AbstractTableFileSystemView { - private static final Logger LOG = LoggerFactory.getLogger(IncrementalTimelineSyncFileSystemView.class); - // Allows incremental Timeline syncing private final boolean incrementalTimelineSyncEnabled; @@ -98,19 +96,19 @@ protected void maySyncIncrementally() { if (incrementalTimelineSyncEnabled) { TimelineDiffResult diffResult = TimelineDiffHelper.getNewInstantsForIncrementalSync(metaClient, oldTimeline, newTimeline); if (diffResult.canSyncIncrementally()) { - LOG.info("Doing incremental sync"); + log.info("Doing incremental sync"); // need to refresh the completion time query view // before amending existing file groups. refreshCompletionTimeQueryView(); runIncrementalSync(newTimeline, diffResult); - LOG.info("Finished incremental sync"); + log.info("Finished incremental sync"); // Reset timeline to latest refreshTimeline(newTimeline); return; } } } catch (Exception ioe) { - LOG.error("Got exception trying to perform incremental sync. Reverting to complete sync", ioe); + log.error("Got exception trying to perform incremental sync. Reverting to complete sync", ioe); } clear(); // Initialize with new Hoodie timeline. @@ -125,7 +123,7 @@ protected void maySyncIncrementally() { */ private void runIncrementalSync(HoodieTimeline timeline, TimelineDiffResult diffResult) { - LOG.info("Timeline Diff Result is :{}", diffResult); + log.info("Timeline Diff Result is :{}", diffResult); // First remove pending compaction instants which were completed diffResult.getFinishedCompactionInstants().stream().forEach(instant -> { @@ -180,7 +178,7 @@ private void runIncrementalSync(HoodieTimeline timeline, TimelineDiffResult diff * @param instant Compaction Instant to be removed */ private void removePendingCompactionInstant(HoodieInstant instant) throws IOException { - LOG.info("Removing completed compaction instant ({})", instant); + log.info("Removing completed compaction instant ({})", instant); HoodieCompactionPlan plan = CompactionUtils.getCompactionPlan(metaClient, instant.requestedTime()); removePendingCompactionOperations(CompactionUtils.getPendingCompactionOperations(instant, plan) .map(instantPair -> Pair.of(instantPair.getValue().getKey(), @@ -194,7 +192,7 @@ private void removePendingCompactionInstant(HoodieInstant instant) throws IOExce * @param instant Log Compaction Instant to be removed */ private void removePendingLogCompactionInstant(HoodieInstant instant) throws IOException { - LOG.info("Removing completed log compaction instant ({})", instant); + log.info("Removing completed log compaction instant ({})", instant); HoodieCompactionPlan plan = CompactionUtils.getLogCompactionPlan(metaClient, instant.requestedTime()); removePendingLogCompactionOperations(CompactionUtils.getPendingCompactionOperations(instant, plan) .map(instantPair -> Pair.of(instantPair.getValue().getKey(), @@ -208,7 +206,7 @@ private void removePendingLogCompactionInstant(HoodieInstant instant) throws IOE * @param instant Compaction Instant */ private void addPendingCompactionInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing pending compaction instant ({})", instant); + log.info("Syncing pending compaction instant ({})", instant); HoodieCompactionPlan compactionPlan = CompactionUtils.getCompactionPlan(metaClient, instant.requestedTime()); List> pendingOps = CompactionUtils.getPendingCompactionOperations(instant, compactionPlan) @@ -238,7 +236,7 @@ private void addPendingCompactionInstant(HoodieTimeline timeline, HoodieInstant * @param instant Compaction Instant */ private void addPendingLogCompactionInstant(HoodieInstant instant) throws IOException { - LOG.info("Syncing pending log compaction instant ({})", instant); + log.info("Syncing pending log compaction instant ({})", instant); HoodieCompactionPlan compactionPlan = CompactionUtils.getLogCompactionPlan(metaClient, instant.requestedTime()); List> pendingOps = CompactionUtils.getPendingCompactionOperations(instant, compactionPlan) @@ -257,10 +255,10 @@ private void addPendingLogCompactionInstant(HoodieInstant instant) throws IOExce * @param instant Instant */ private void addCommitInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing committed instant ({})", instant); + log.info("Syncing committed instant ({})", instant); HoodieCommitMetadata commitMetadata = timeline.readCommitMetadata(instant); updatePartitionWriteFileGroups(commitMetadata.getPartitionToWriteStats(), timeline, instant); - LOG.info("Done Syncing committed instant ({})", instant); + log.info("Done Syncing committed instant ({})", instant); } private void updatePartitionWriteFileGroups(Map> partitionToWriteStats, @@ -269,7 +267,7 @@ private void updatePartitionWriteFileGroups(Map> p partitionToWriteStats.entrySet().stream().forEach(entry -> { String partition = entry.getKey(); if (isPartitionAvailableInStore(partition)) { - LOG.info("Syncing partition ({}) of instant ({})", partition, instant); + log.info("Syncing partition ({}) of instant ({})", partition, instant); List pathInfoList = entry.getValue().stream() .map(p -> new StoragePathInfo( new StoragePath(String.format("%s/%s", metaClient.getBasePath(), p.getPath())), @@ -279,10 +277,10 @@ private void updatePartitionWriteFileGroups(Map> p buildFileGroups(partition, pathInfoList, timeline.filterCompletedAndCompactionInstants(), false); applyDeltaFileSlicesToPartitionView(partition, fileGroups, DeltaApplyMode.ADD); } else { - LOG.warn("Skipping partition ({}) when syncing instant ({}) as it is not loaded", partition, instant); + log.warn("Skipping partition ({}) when syncing instant ({}) as it is not loaded", partition, instant); } }); - LOG.info("Done Syncing committed instant ({})", instant); + log.info("Done Syncing committed instant ({})", instant); } /** @@ -292,7 +290,7 @@ private void updatePartitionWriteFileGroups(Map> p * @param instant Restore Instant */ private void addRestoreInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing restore instant ({})", instant); + log.info("Syncing restore instant ({})", instant); HoodieRestoreMetadata metadata = timeline.readRestoreMetadata(instant); Map>> partitionFiles = @@ -312,7 +310,7 @@ private void addRestoreInstant(HoodieTimeline timeline, HoodieInstant instant) t .map(HoodieInstantInfo::getCommitTime).collect(Collectors.toSet()); removeReplacedFileIdsAtInstants(rolledbackInstants); } - LOG.info("Done Syncing restore instant ({})", instant); + log.info("Done Syncing restore instant ({})", instant); } /** @@ -322,13 +320,13 @@ private void addRestoreInstant(HoodieTimeline timeline, HoodieInstant instant) t * @param instant Rollback Instant */ private void addRollbackInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing rollback instant ({})", instant); + log.info("Syncing rollback instant ({})", instant); HoodieRollbackMetadata metadata = timeline.readRollbackMetadata(instant); metadata.getPartitionMetadata().entrySet().stream().forEach(e -> { removeFileSlicesForPartition(timeline, instant, e.getKey(), e.getValue().getSuccessDeleteFiles()); }); - LOG.info("Done Syncing rollback instant ({})", instant); + log.info("Done Syncing rollback instant ({})", instant); } /** @@ -338,7 +336,7 @@ private void addRollbackInstant(HoodieTimeline timeline, HoodieInstant instant) * @param instant REPLACE Instant */ private void addReplaceInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing replace instant ({})", instant); + log.info("Syncing replace instant ({})", instant); HoodieReplaceCommitMetadata replaceMetadata = timeline.readReplaceCommitMetadata(instant); updatePartitionWriteFileGroups(replaceMetadata.getPartitionToWriteStats(), timeline, instant); replaceMetadata.getPartitionToReplaceFileIds().entrySet().stream().forEach(entry -> { @@ -346,10 +344,10 @@ private void addReplaceInstant(HoodieTimeline timeline, HoodieInstant instant) t Map replacedFileIds = entry.getValue().stream() .collect(Collectors.toMap(replaceStat -> new HoodieFileGroupId(partition, replaceStat), replaceStat -> instant)); - LOG.info("For partition ({}) of instant ({}), excluding {} file groups", partition, instant, replacedFileIds.size()); + log.info("For partition ({}) of instant ({}), excluding {} file groups", partition, instant, replacedFileIds.size()); addReplacedFileGroups(replacedFileIds); }); - LOG.info("Done Syncing REPLACE instant ({})", instant); + log.info("Done Syncing REPLACE instant ({})", instant); } /** @@ -360,7 +358,7 @@ private void addReplaceInstant(HoodieTimeline timeline, HoodieInstant instant) t * @param instant Clean instant */ private void addCleanInstant(HoodieTimeline timeline, HoodieInstant instant) throws IOException { - LOG.info("Syncing cleaner instant ({})", instant); + log.info("Syncing cleaner instant ({})", instant); HoodieCleanMetadata cleanMetadata = CleanerUtils.getCleanerMetadata(metaClient, instant); cleanMetadata.getPartitionMetadata().entrySet().stream().forEach(entry -> { final StoragePath basePath = metaClient.getBasePath(); @@ -371,13 +369,13 @@ private void addCleanInstant(HoodieTimeline timeline, HoodieInstant instant) thr .collect(Collectors.toList()); removeFileSlicesForPartition(timeline, instant, entry.getKey(), fullPathList); }); - LOG.info("Done Syncing cleaner instant ({})", instant); + log.info("Done Syncing cleaner instant ({})", instant); } private void removeFileSlicesForPartition(HoodieTimeline timeline, HoodieInstant instant, String partition, List paths) { if (isPartitionAvailableInStore(partition)) { - LOG.info("Removing file slices for partition ({}) for instant ({})", partition, instant); + log.info("Removing file slices for partition ({}) for instant ({})", partition, instant); List pathInfoList = paths.stream() .map(p -> new StoragePathInfo(new StoragePath(p), 0, false, (short) 0, 0, 0)) .collect(Collectors.toList()); @@ -385,7 +383,7 @@ private void removeFileSlicesForPartition(HoodieTimeline timeline, HoodieInstant buildFileGroups(partition, pathInfoList, timeline.filterCompletedAndCompactionInstants(), false); applyDeltaFileSlicesToPartitionView(partition, fileGroups, DeltaApplyMode.REMOVE); } else { - LOG.warn("Skipping partition ({}) when syncing instant ({}) as it is not loaded", partition, instant); + log.warn("Skipping partition ({}) when syncing instant ({}) as it is not loaded", partition, instant); } } @@ -408,7 +406,7 @@ enum DeltaApplyMode { protected void applyDeltaFileSlicesToPartitionView(String partition, List deltaFileGroups, DeltaApplyMode mode) { if (deltaFileGroups.isEmpty()) { - LOG.info("No delta file groups for partition :{}", partition); + log.info("No delta file groups for partition :{}", partition); return; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/PriorityBasedFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/PriorityBasedFileSystemView.java index e6d60c13b548c..c54c0edcafc32 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/PriorityBasedFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/PriorityBasedFileSystemView.java @@ -34,10 +34,11 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.http.HttpStatus; import org.apache.http.client.HttpResponseException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.List; @@ -48,11 +49,11 @@ * A file system view which proxies request to a preferred File System View implementation. In case of error, flip all * subsequent calls to a backup file-system view implementation. */ +@Slf4j public class PriorityBasedFileSystemView implements SyncableFileSystemView, Serializable { - private static final Logger LOG = LoggerFactory.getLogger(PriorityBasedFileSystemView.class); - private final transient HoodieEngineContext engineContext; + @Getter(AccessLevel.PACKAGE) private final SyncableFileSystemView preferredView; private final SerializableFunctionUnchecked secondaryViewCreator; private SyncableFileSystemView secondaryView; @@ -69,7 +70,7 @@ public PriorityBasedFileSystemView(SyncableFileSystemView preferredView, Seriali private R execute(Function0 preferredFunction, Function0 secondaryFunction) { if (errorOnPreferredView) { - LOG.warn("Routing request to secondary file-system view"); + log.warn("Routing request to secondary file-system view"); return secondaryFunction.apply(); } else { try { @@ -84,7 +85,7 @@ private R execute(Function0 preferredFunction, Function0 secondaryFunc private R execute(T1 val, Function1 preferredFunction, Function1 secondaryFunction) { if (errorOnPreferredView) { - LOG.warn("Routing request to secondary file-system view"); + log.warn("Routing request to secondary file-system view"); return secondaryFunction.apply(val); } else { try { @@ -100,7 +101,7 @@ private R execute(T1 val, Function1 preferredFunction, Function1< private R execute(T1 val, T2 val2, Function2 preferredFunction, Function2 secondaryFunction) { if (errorOnPreferredView) { - LOG.warn("Routing request to secondary file-system view"); + log.warn("Routing request to secondary file-system view"); return secondaryFunction.apply(val, val2); } else { try { @@ -116,7 +117,7 @@ private R execute(T1 val, T2 val2, Function2 preferredFun private R execute(T1 val, T2 val2, T3 val3, Function3 preferredFunction, Function3 secondaryFunction) { if (errorOnPreferredView) { - LOG.warn("Routing request to secondary file-system view"); + log.warn("Routing request to secondary file-system view"); return secondaryFunction.apply(val, val2, val3); } else { try { @@ -131,9 +132,9 @@ private R execute(T1 val, T2 val2, T3 val3, Function3 getLatestFileSlice(String partitionPath, String fileId) return execute(partitionPath, fileId, preferredView::getLatestFileSlice, (path, fgId) -> getSecondaryView().getLatestFileSlice(path, fgId)); } - SyncableFileSystemView getPreferredView() { - return preferredView; - } - synchronized SyncableFileSystemView getSecondaryView() { if (secondaryView == null) { secondaryView = secondaryViewCreator.apply(engineContext); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/RemoteHoodieTableFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/RemoteHoodieTableFileSystemView.java index 4cdcb09681227..431292e88aa22 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/RemoteHoodieTableFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/RemoteHoodieTableFileSystemView.java @@ -46,8 +46,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.module.afterburner.AfterburnerModule; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.io.Serializable; @@ -62,6 +61,7 @@ /** * A proxy for table file-system view which translates local View API calls to REST calls to remote timeline service. */ +@Slf4j public class RemoteHoodieTableFileSystemView implements SyncableFileSystemView, Serializable { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new AfterburnerModule()); @@ -127,8 +127,6 @@ public class RemoteHoodieTableFileSystemView implements SyncableFileSystemView, public static final String INCLUDE_FILES_IN_PENDING_COMPACTION_PARAM = "includependingcompaction"; public static final String MULTI_VALUE_SEPARATOR = ","; - - private static final Logger LOG = LoggerFactory.getLogger(RemoteHoodieTableFileSystemView.class); private static final TypeReference> FILE_SLICE_DTOS_REFERENCE = new TypeReference>() {}; private static final TypeReference> FILE_GROUP_DTOS_REFERENCE = new TypeReference>() {}; private static final TypeReference BOOLEAN_TYPE_REFERENCE = new TypeReference() {}; diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/RocksDbBasedFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/RocksDbBasedFileSystemView.java index c2cac1420ff34..c8df4063abf0a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/RocksDbBasedFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/RocksDbBasedFileSystemView.java @@ -38,8 +38,9 @@ import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.util.HashMap; @@ -65,16 +66,16 @@ * support view-state preservation across restarts, Hoodie timeline also needs to be stored inorder to detect changes to * timeline across restarts. */ +@Slf4j public class RocksDbBasedFileSystemView extends IncrementalTimelineSyncFileSystemView { - private static final Logger LOG = LoggerFactory.getLogger(RocksDbBasedFileSystemView.class); - private final FileSystemViewStorageConfig config; private final RocksDBSchemaHelper schemaHelper; private RocksDBDAO rocksDB; + @Getter(AccessLevel.PACKAGE) private boolean closed = false; public RocksDbBasedFileSystemView(HoodieTableMetadata tableMetadata, HoodieTableMetaClient metaClient, HoodieTimeline visibleActiveTimeline, @@ -96,7 +97,7 @@ public RocksDbBasedFileSystemView(HoodieTableMetadata tableMetadata, HoodieTable protected void init(HoodieTableMetaClient metaClient, HoodieTimeline visibleActiveTimeline) { schemaHelper.getAllColumnFamilies().forEach(rocksDB::addColumnFamily); super.init(metaClient, visibleActiveTimeline); - LOG.info("Created ROCKSDB based file-system view at {}", config.getRocksdbBasePath()); + log.info("Created ROCKSDB based file-system view at {}", config.getRocksdbBasePath()); } @Override @@ -111,7 +112,7 @@ protected void resetPendingCompactionOperations(Stream> fetchFileGroupsInPendingCl @Override void resetFileGroupsInPendingClustering(Map fgIdToInstantMap) { - LOG.info("Resetting file groups in pending clustering to ROCKSDB based file-system view at " - + config.getRocksdbBasePath() + ", Total file-groups=" + fgIdToInstantMap.size()); + log.info("Resetting file groups in pending clustering to ROCKSDB based file-system view at {}, Total file-groups={}", config.getRocksdbBasePath(), fgIdToInstantMap.size()); // Delete all replaced file groups rocksDB.prefixDelete(schemaHelper.getColFamilyForFileGroupsInPendingClustering(), "part="); // Now add new entries addFileGroupsInPendingClustering(fgIdToInstantMap.entrySet().stream().map(entry -> Pair.of(entry.getKey(), entry.getValue()))); - LOG.info("Resetting replacedFileGroups to ROCKSDB based file-system view complete"); + log.info("Resetting replacedFileGroups to ROCKSDB based file-system view complete"); } @Override @@ -246,7 +246,7 @@ void removeFileGroupsInPendingClustering(Stream fileGroups) { - LOG.info("Resetting and adding new partition ({}) to ROCKSDB based file-system view at {}, Total file-groups={}", + log.info("Resetting and adding new partition ({}) to ROCKSDB based file-system view at {}, Total file-groups={}", partitionPath, config.getRocksdbBasePath(), fileGroups.size()); String lookupKey = schemaHelper.getKeyForPartitionLookup(partitionPath); @@ -303,7 +303,7 @@ protected void storePartitionView(String partitionPath, List fi // record that partition is loaded. rocksDB.put(schemaHelper.getColFamilyForStoredPartitions(), lookupKey, Boolean.TRUE); - LOG.info("Finished adding new partition ({}}) to ROCKSDB based file-system view at {}, Total file-groups={}", + log.info("Finished adding new partition ({}}) to ROCKSDB based file-system view at {}, Total file-groups={}", partitionPath, config.getRocksdbBasePath(), fileGroups.size()); } @@ -322,7 +322,7 @@ protected void applyDeltaFileSlicesToPartitionView(String partition, List !logFiles.containsKey(e.getKey())) .forEach(p -> newLogFiles.put(p.getKey(), p.getValue())); newLogFiles.values().forEach(newFileSlice::addLogFile); - LOG.info("Adding back new File Slice after add FS={}", newFileSlice); + log.info("Adding back new File Slice after add FS={}", newFileSlice); return newFileSlice; } case REMOVE: { - LOG.info("Removing old File Slice ={}", fs); + log.info("Removing old File Slice ={}", fs); FileSlice newFileSlice = new FileSlice(oldSlice.getFileGroupId(), oldSlice.getBaseInstantTime()); fs.getBaseFile().orElseGet(() -> { oldSlice.getBaseFile().ifPresent(newFileSlice::setBaseFile); @@ -357,7 +357,7 @@ protected void applyDeltaFileSlicesToPartitionView(String partition, List 0)) { - LOG.info("Adding back new file-slice after remove FS={}", newFileSlice); + log.info("Adding back new file-slice after remove FS={}", newFileSlice); return newFileSlice; } return null; @@ -406,7 +406,7 @@ void resetBootstrapBaseFileMapping(Stream bootstrapBas rocksDB.putInBatch(batch, schemaHelper.getColFamilyForBootstrapBaseFile(), schemaHelper.getKeyForBootstrapBaseFile(externalBaseFile.getFileGroupId()), externalBaseFile); }); - LOG.info("Initializing external data file mapping. Count={}", batch.count()); + log.info("Initializing external data file mapping. Count={}", batch.count()); }); } @@ -516,14 +516,13 @@ Option fetchHoodieFileGroup(String partitionPath, String fileId @Override protected void resetReplacedFileGroups(final Map replacedFileGroups) { - LOG.info("Resetting replacedFileGroups to ROCKSDB based file-system view at " - + config.getRocksdbBasePath() + ", Total file-groups=" + replacedFileGroups.size()); + log.info("Resetting replacedFileGroups to ROCKSDB based file-system view at {}, Total file-groups={}", config.getRocksdbBasePath(), replacedFileGroups.size()); // Delete all replaced file groups rocksDB.prefixDelete(schemaHelper.getColFamilyForReplacedFileGroups(), "part="); // Now add new entries addReplacedFileGroups(replacedFileGroups); - LOG.info("Resetting replacedFileGroups to ROCKSDB based file-system view complete"); + log.info("Resetting replacedFileGroups to ROCKSDB based file-system view complete"); } @Override @@ -542,8 +541,8 @@ protected void addReplacedFileGroups(final Map }) ); - LOG.info("Finished adding replaced file groups to partition (" + partitionPath + ") to ROCKSDB based view at " - + config.getRocksdbBasePath() + ", Total file-groups=" + partitionToReplacedFileGroupsEntry.getValue().size()); + log.info("Finished adding replaced file groups to partition ({}) to ROCKSDB based view at {}, Total file-groups={}", + partitionPath, config.getRocksdbBasePath(), partitionToReplacedFileGroupsEntry.getValue().size()); }); } @@ -598,18 +597,16 @@ private static boolean isFileSliceWithoutCompactionBarrier(FileSlice fileSlice) @Override public void close() { try { - LOG.info("Closing Rocksdb !!"); + writeLock.lock(); + log.info("Closing Rocksdb !!"); closed = true; closeResources(); rocksDB.close(); - LOG.info("Closed Rocksdb !!"); + log.info("Closed Rocksdb !!"); } catch (Exception e) { throw new HoodieException("Unable to close file system view", e); + } finally { + writeLock.unlock(); } } - - @Override - boolean isClosed() { - return closed; - } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/SpillableMapBasedFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/SpillableMapBasedFileSystemView.java index 89687ef070579..37bf6b4b05a20 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/SpillableMapBasedFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/SpillableMapBasedFileSystemView.java @@ -35,8 +35,7 @@ import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.storage.StoragePathInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.IOException; @@ -48,10 +47,9 @@ /** * Table FileSystemView implementation where view is stored in spillable disk using fixed memory. */ +@Slf4j public class SpillableMapBasedFileSystemView extends HoodieTableFileSystemView { - private static final Logger LOG = LoggerFactory.getLogger(SpillableMapBasedFileSystemView.class); - private final long maxMemoryForFileGroupMap; private final long maxMemoryForPendingCompaction; private final long maxMemoryForPendingLogCompaction; @@ -75,7 +73,7 @@ public SpillableMapBasedFileSystemView(HoodieTableMetadata tableMetadata, Hoodie new File(baseStoreDir).mkdirs(); diskMapType = commonConfig.getSpillableDiskMapType(); isBitCaskDiskMapCompressionEnabled = commonConfig.isBitCaskDiskMapCompressionEnabled(); - LOG.info("Initializing SpillableMapBasedFileSystemView with memory configs: " + log.info("Initializing SpillableMapBasedFileSystemView with memory configs: " + "maxMemoryForFileGroupMap={}, maxMemoryForPendingCompaction={}, maxMemoryForPendingLogCompaction={}, " + "maxMemoryForBootstrapBaseFile={}, maxMemoryForReplaceFileGroups={}, maxMemoryForClusteringFileGroups={}, baseStoreDir={}", maxMemoryForFileGroupMap, maxMemoryForPendingCompaction, maxMemoryForPendingLogCompaction, @@ -225,14 +223,18 @@ protected void removeReplacedFileIdsAtInstants(Set instants) { } @Override - public void close() { + protected void closeResources() throws Exception { + // Close ExternalSpillableMaps (which hold RocksDB handles) while the writeLock is held + // by AbstractTableFileSystemView.close(). This prevents a race where a concurrent reader + // holding readLock could be mid-call in RocksDBDAO.put() when the handles are cleared, + // causing a NullPointerException at RocksDB.put(null_handle, ...). + super.closeResources(); closeFileGroupsMapIfPresent(); closePendingClusteringMapIfPresent(); closePendingCompactionMapIfPresent(); closePendingLogCompactionMapIfPresent(); closeBootstrapFileMapIfPresent(); closeReplaceInstantsMapIfPresent(); - super.close(); } private void closeReplaceInstantsMapIfPresent() { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/CloseableUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/util/CloseableUtils.java new file mode 100644 index 0000000000000..50b6e2e6ee80b --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/CloseableUtils.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.hudi.common.util; + +/** Utility methods for closing resources. */ +public final class CloseableUtils { + + private CloseableUtils() { + } + + /** Closes {@code closeable}, attaching any failure to {@code primary} as a suppressed exception. */ + public static void closeSuppressing(AutoCloseable closeable, Throwable primary) { + try { + closeable.close(); + } catch (Throwable closeError) { + primary.addSuppressed(closeError); + } + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/ClusteringUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/util/ClusteringUtils.java index 013d568f27fb6..293e581ee51ac 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/ClusteringUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/ClusteringUtils.java @@ -122,9 +122,9 @@ public static Option getRequestedClusteringInstant(String timesta * Transitions the provided clustering instant fron inflight to complete based on the clustering * action type. After HUDI-7905, the new clustering commits are written with clustering action. */ - public static void transitionClusteringOrReplaceInflightToComplete(boolean shouldLock, HoodieInstant clusteringInstant, - HoodieReplaceCommitMetadata metadata, HoodieActiveTimeline activeTimeline, - TableFormatCompletionAction tableFormatCompletionAction) { + public static void transitionClusteringOrReplaceInflightToComplete(boolean shouldLock, HoodieInstant clusteringInstant, + HoodieReplaceCommitMetadata metadata, HoodieActiveTimeline activeTimeline, + TableFormatCompletionAction tableFormatCompletionAction) { if (clusteringInstant.getAction().equals(HoodieTimeline.CLUSTERING_ACTION)) { activeTimeline.transitionClusterInflightToComplete(shouldLock, clusteringInstant, metadata, tableFormatCompletionAction); } else { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/ConfigUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/util/ConfigUtils.java index 5f0ce3bbb9a06..463842096d43f 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/ConfigUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/ConfigUtils.java @@ -151,7 +151,13 @@ public static TypedProperties supplementOrderingFields(TypedProperties props, Li * Ensures that the prefixed merge properties are populated for mergers. */ public static TypedProperties getMergeProps(TypedProperties props, HoodieTableConfig tableConfig) { - Map mergeProps = tableConfig.getTableMergeProperties(); + // Prefer the payload class persisted in the table config, falling back to the reader/write props + // (e.g. hoodie.datasource.write.payload.class) when the table never persisted it. This keeps the + // persisted payload authoritative (no query-side shadowing) while pre-v9 delete markers still derive. + String payloadClass = tableConfig.getPayloadClassIfPresent() + .orElseGet(() -> HoodieRecordPayload.getPayloadClassNameIfPresent(props) + .orElseGet(tableConfig::getPayloadClass)); + Map mergeProps = tableConfig.getTableMergeProperties(payloadClass); if (mergeProps.isEmpty()) { return props; } @@ -682,7 +688,7 @@ public static TypedProperties fetchConfigs( return props; } catch (IOException e) { if (HoodieExceptionUtil.isPermissionDeniedException(e)) { - log.error("Permission denied for " + path.toString() + " file.", e); + log.error("Permission denied for {} file.", path, e); throw new HoodieIOException("Permission denied for " + path + " file path. User does not have read access on the dataset.", e); } else { log.warn("Could not read properties from {}: {}", path, e); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/TimestampLogicalTypeClassifier.java b/hudi-common/src/main/java/org/apache/hudi/common/util/TimestampLogicalTypeClassifier.java new file mode 100644 index 0000000000000..efb2b6e5be2c4 --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/TimestampLogicalTypeClassifier.java @@ -0,0 +1,218 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.common.util; + +import org.apache.avro.LogicalType; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; + +/** + * Shared classifier for the timestamp logical-type drift introduced by Hudi 0.14.1 / 0.15.0 / 1.0.x. + * It decides, from three signals about a long-backed column, what the correct target timestamp + * logical type is, so a caller can suggest a value for + * {@code hoodie.write.timestamp.logical.type.overrides}. + * + *

    The three signals are: (1) the table schema logical type, (2) the base-file schema logical + * type, and (3) the shape of the raw stored {@code long} values. Signal (1) must be resolved as of + * the file's own commit instant, not the latest table schema, so that a file is classified within + * its own era. The classification here is pure; the I/O that gathers the signals is caller-specific + * (the OSS scanner reads through the storage abstraction, the data-plane tool reads parquet + * directly), but both must share this logic so their verdicts cannot drift apart. + * + *

    Status: this is the verdict logic only. The inspection scanner that samples base files + * and emits a ready-to-paste config value is not part of this repo yet, so an in-repo grep finds + * only tests today. It lives here rather than in the tool so that every consumer shares one + * definition of the verdict and they cannot drift apart. + * + *

    Do not rely on the enums or method signatures here as stable public API: this is internal to + * the timestamp-repair workflow. + */ +public class TimestampLogicalTypeClassifier { + + private TimestampLogicalTypeClassifier() { + } + + // Plausibility windows: an epoch instant in 1990-01-01 .. 2100-01-01, interpreted as millis vs + // micros. The two windows are ~1000x apart and do not overlap, so a single value fits at most one. + private static final long PLAUSIBLE_MILLIS_MIN = 631152000000L; // 1990-01-01 + private static final long PLAUSIBLE_MILLIS_MAX = 4102444800000L; // 2100-01-01 + private static final long PLAUSIBLE_MICROS_MIN = 631152000000000L; // 1990-01-01 + private static final long PLAUSIBLE_MICROS_MAX = 4102444800000000L; // 2100-01-01 + + /** The timestamp logical type of a long-backed column, or NONE / UNKNOWN. */ + public enum LogicalTimestampType { + NONE, + TIMESTAMP_MICROS, + TIMESTAMP_MILLIS, + LOCAL_TIMESTAMP_MICROS, + LOCAL_TIMESTAMP_MILLIS, + UNKNOWN + } + + /** The shape of a stored long value, judged against the plausibility windows. */ + public enum DataShape { + MICROS, + MILLIS, + AMBIGUOUS, + UNKNOWN + } + + /** The per-column verdict. */ + public enum Bucket { + /** No timestamp logical type and no timestamp-shaped data. Nothing to do, safe to upgrade. */ + UNAFFECTED, + /** Table, file, and values agree at the same precision. Correct, though it may still need a + * defensive pin if the ingestion source declares a different precision. */ + CORRECT, + /** + * Label says micros but the values are millis, or the symmetric inverse (label says millis but + * values are micros): the 0.14.1 drift. Both directions map to the same repair action — pin the + * field to whatever the values actually are — so they share this bucket. The observed + * production case is label_micros/values_millis; the symmetric case is included to be safe. + */ + LEGACY_0X_BUG, + /** Bare long, but the values are timestamp-shaped: the 0.x local-timestamp logical-type loss. */ + DROPPED_LOGICAL_TYPE, + /** The three signals disagree in some other way. */ + DIVERGENT, + /** The value shape cannot be judged confidently (near-epoch, sentinels, zeros, negatives). */ + AMBIGUOUS + } + + /** Classifies the logical type of a long-backed Avro field (the union is expected to be unwrapped). */ + public static LogicalTimestampType classifyAvroLogicalType(Schema longSchema) { + LogicalType lt = longSchema.getLogicalType(); + if (lt == null) { + return LogicalTimestampType.NONE; + } + if (lt instanceof LogicalTypes.TimestampMillis) { + return LogicalTimestampType.TIMESTAMP_MILLIS; + } + if (lt instanceof LogicalTypes.TimestampMicros) { + return LogicalTimestampType.TIMESTAMP_MICROS; + } + if (lt instanceof LogicalTypes.LocalTimestampMillis) { + return LogicalTimestampType.LOCAL_TIMESTAMP_MILLIS; + } + if (lt instanceof LogicalTypes.LocalTimestampMicros) { + return LogicalTimestampType.LOCAL_TIMESTAMP_MICROS; + } + return LogicalTimestampType.UNKNOWN; + } + + /** + * Judges a single raw long. Zeros, negatives, and sentinels (for example the year-9999 markers) + * fall outside both plausibility windows and are reported UNKNOWN so they never drive a verdict. + */ + public static DataShape classifyValueShape(long value) { + if (value <= 0) { + return DataShape.UNKNOWN; + } + boolean millisPlausible = value >= PLAUSIBLE_MILLIS_MIN && value < PLAUSIBLE_MILLIS_MAX; + boolean microsPlausible = value >= PLAUSIBLE_MICROS_MIN && value < PLAUSIBLE_MICROS_MAX; + if (millisPlausible && !microsPlausible) { + return DataShape.MILLIS; + } + if (microsPlausible && !millisPlausible) { + return DataShape.MICROS; + } + if (millisPlausible) { + // The windows do not overlap, so this is unreachable for a single value; kept for safety. + return DataShape.AMBIGUOUS; + } + return DataShape.UNKNOWN; + } + + /** Folds one sampled value's shape into the running per-column shape across many samples/files. */ + public static DataShape reduceShape(DataShape acc, DataShape sample) { + if (acc == null || acc == DataShape.UNKNOWN) { + return sample; + } + if (sample == DataShape.UNKNOWN || acc == sample) { + return acc; + } + return DataShape.AMBIGUOUS; + } + + /** + * Reconciles the three signals into a verdict. {@code tableType} must be the table logical type as + * of the inspected file's commit instant. + */ + public static Bucket classifyBucket(LogicalTimestampType tableType, LogicalTimestampType fileType, DataShape shape) { + boolean noLogicalType = tableType == LogicalTimestampType.NONE && fileType == LogicalTimestampType.NONE; + if (shape == DataShape.UNKNOWN) { + // Nothing timestamp-shaped was seen. Bare-long-everywhere is a plain non-timestamp column; + // anything else cannot be judged without a clearer value signal. + return noLogicalType ? Bucket.UNAFFECTED : Bucket.AMBIGUOUS; + } + if (shape == DataShape.AMBIGUOUS) { + return Bucket.AMBIGUOUS; + } + if (noLogicalType) { + // Bare long with timestamp-shaped data: 0.x dropped the local-timestamp logical type. + return Bucket.DROPPED_LOGICAL_TYPE; + } + boolean tableMicros = tableType == LogicalTimestampType.TIMESTAMP_MICROS || tableType == LogicalTimestampType.LOCAL_TIMESTAMP_MICROS; + boolean tableMillis = tableType == LogicalTimestampType.TIMESTAMP_MILLIS || tableType == LogicalTimestampType.LOCAL_TIMESTAMP_MILLIS; + boolean fileMicros = fileType == LogicalTimestampType.TIMESTAMP_MICROS || fileType == LogicalTimestampType.LOCAL_TIMESTAMP_MICROS; + boolean fileMillis = fileType == LogicalTimestampType.TIMESTAMP_MILLIS || fileType == LogicalTimestampType.LOCAL_TIMESTAMP_MILLIS; + if (tableMicros && fileMicros && shape == DataShape.MILLIS) { + return Bucket.LEGACY_0X_BUG; + } + if (tableMillis && fileMillis && shape == DataShape.MICROS) { + return Bucket.LEGACY_0X_BUG; + } + if (tableMicros && fileMicros && shape == DataShape.MICROS) { + // All three agree on micros. Genuinely correct; a source-declared millis is indistinguishable + // here and maps to the same action (keep micros), so it is not a separate bucket. + return Bucket.CORRECT; + } + if (tableMillis && fileMillis && shape == DataShape.MILLIS) { + return Bucket.CORRECT; + } + // Reached when the table and file logical types disagree (for example table_micros + + // file_millis) or the surviving cases where the three signals do not line up to CORRECT + // or LEGACY_0X_BUG. The operator must decide the correct override; no auto-suggestion. + return Bucket.DIVERGENT; + } + + /** + * The suggested {@code hoodie.write.timestamp.logical.type.overrides} token for a column, or empty + * when the operator must decide (ambiguous / divergent) or nothing is needed (unaffected). + * {@code local} selects the local-timestamp variant, carried from the table/file logical type. + */ + public static Option suggestedOverrideToken(Bucket bucket, DataShape shape, boolean local) { + switch (bucket) { + case CORRECT: + // Pin to the current precision so a differently-declared source cannot flip it. + return Option.of(token(shape, local)); + case LEGACY_0X_BUG: + case DROPPED_LOGICAL_TYPE: + // Repair to what the values actually are. + return Option.of(token(shape, local)); + default: + return Option.empty(); + } + } + + private static String token(DataShape shape, boolean local) { + String precision = shape == DataShape.MILLIS ? "millis" : "micros"; + return (local ? "local-timestamp-" : "timestamp-") + precision; + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/collection/BitCaskDiskMap.java b/hudi-common/src/main/java/org/apache/hudi/common/util/collection/BitCaskDiskMap.java index e7b298dc2b8d4..fb22c9869ae26 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/collection/BitCaskDiskMap.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/collection/BitCaskDiskMap.java @@ -58,6 +58,7 @@ import java.util.stream.Stream; import java.util.zip.Deflater; import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import static org.apache.hudi.common.util.BinaryUtil.generateChecksum; @@ -408,6 +409,11 @@ private static class CompressionHandler implements Serializable { private final ByteArrayOutputStream compressBaos; private final ByteArrayOutputStream decompressBaos; private final byte[] decompressIntermediateBuffer; + // Each CompressionHandler is held in a ThreadLocal, + // so a single Deflater/Inflater pair per worker thread is sufficient and + // avoids per-call construction. + private transient Deflater deflater; + private transient Inflater inflater; CompressionHandler() { compressBaos = new ByteArrayOutputStream(DISK_COMPRESSION_INITIAL_BUFFER_SIZE); @@ -415,22 +421,35 @@ private static class CompressionHandler implements Serializable { decompressIntermediateBuffer = new byte[DECOMPRESS_INTERMEDIATE_BUFFER_SIZE]; } + private Deflater getDeflater() { + if (deflater == null) { + deflater = new Deflater(Deflater.BEST_COMPRESSION); + } + return deflater; + } + + private Inflater getInflater() { + if (inflater == null) { + inflater = new Inflater(); + } + return inflater; + } + private byte[] compressBytes(final byte[] value) throws IOException { compressBaos.reset(); - Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION); - DeflaterOutputStream dos = new DeflaterOutputStream(compressBaos, deflater); - try { + Deflater deflater = getDeflater(); + deflater.reset(); + try (DeflaterOutputStream dos = new DeflaterOutputStream(compressBaos, deflater)) { dos.write(value); - } finally { - dos.close(); - deflater.end(); } return compressBaos.toByteArray(); } private byte[] decompressBytes(final byte[] bytes) throws IOException { decompressBaos.reset(); - try (InputStream in = new InflaterInputStream(new ByteArrayInputStream(bytes))) { + Inflater inflater = getInflater(); + inflater.reset(); + try (InputStream in = new InflaterInputStream(new ByteArrayInputStream(bytes), inflater)) { int len; while ((len = in.read(decompressIntermediateBuffer)) > 0) { decompressBaos.write(decompressIntermediateBuffer, 0, len); diff --git a/hudi-common/src/main/java/org/apache/hudi/exception/ExceptionUtil.java b/hudi-common/src/main/java/org/apache/hudi/exception/ExceptionUtil.java new file mode 100644 index 0000000000000..40a39542c7a71 --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/exception/ExceptionUtil.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.hudi.exception; + +import org.apache.hudi.common.util.StringUtils; + +import javax.annotation.Nonnull; + +import java.io.IOException; + +/** + * Util class for exception analysis. + */ +public final class ExceptionUtil { + private ExceptionUtil() { + } + + /** + * Returns true if error message is contained in any nested exception to provided {@link Throwable}. + */ + public static boolean validateErrorMsg(@Nonnull Throwable t, String errorMsg) { + if (StringUtils.isNullOrEmpty(errorMsg)) { + return false; + } + + Throwable cause = t; + while (cause != null) { + if (cause.getMessage() != null && cause.getMessage().contains(errorMsg)) { + return true; + } + cause = cause.getCause(); + } + + return false; + } + + /** + * Throws the provided exception as-is when it is an {@link IOException} or + * {@link RuntimeException}, otherwise wraps it in an {@link IOException}. + */ + public static void throwAsIOExceptionOrRuntimeException(Throwable exception) throws IOException { + if (exception instanceof IOException) { + throw (IOException) exception; + } + if (exception instanceof RuntimeException) { + throw (RuntimeException) exception; + } + throw new IOException(exception); + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/expression/BindVisitor.java b/hudi-common/src/main/java/org/apache/hudi/expression/BindVisitor.java index 2b7e589af2138..5d4ea2fd52357 100644 --- a/hudi-common/src/main/java/org/apache/hudi/expression/BindVisitor.java +++ b/hudi-common/src/main/java/org/apache/hudi/expression/BindVisitor.java @@ -182,6 +182,6 @@ public Expression visitPredicate(Predicate predicate) { return Predicates.contains(left, right); } - throw new IllegalArgumentException("The expression " + this + "cannot be visited as predicate"); + throw new IllegalArgumentException("The expression " + predicate + " cannot be visited as predicate"); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/expression/Predicates.java b/hudi-common/src/main/java/org/apache/hudi/expression/Predicates.java index e5dd07e2bfe6b..e03522a910a38 100644 --- a/hudi-common/src/main/java/org/apache/hudi/expression/Predicates.java +++ b/hudi-common/src/main/java/org/apache/hudi/expression/Predicates.java @@ -233,7 +233,7 @@ public static class StringStartsWith extends BinaryExpression implements Predica @Override public String toString() { - return getLeft().toString() + ".startWith(" + getRight().toString() + ")"; + return getLeft().toString() + ".startsWith(" + getRight().toString() + ")"; } @Override @@ -449,6 +449,14 @@ public Object eval(StructLike data) { return false; } + @Override + public String toString() { + // Only the left expression is absent: HoodieBackedTableMetadata passes null for the metadata table + // key filters, so it must not be dereferenced. The right operands are always non-null literals. + return left + ".startsWithAny(" + + right.stream().map(Expression::toString).collect(Collectors.joining(",")) + ")"; + } + public List getRightChildren() { return right; } diff --git a/hudi-common/src/main/java/org/apache/hudi/internal/schema/action/TableChanges.java b/hudi-common/src/main/java/org/apache/hudi/internal/schema/action/TableChanges.java index 05d9e0bfa3f34..17e4517b5fbd5 100644 --- a/hudi-common/src/main/java/org/apache/hudi/internal/schema/action/TableChanges.java +++ b/hudi-common/src/main/java/org/apache/hudi/internal/schema/action/TableChanges.java @@ -47,13 +47,11 @@ public static class ColumnUpdateChange extends TableChange.BaseColumnChange { @Getter private final Map updates = new HashMap<>(); + private final boolean allowTimestampPrecisionEvolution; - private ColumnUpdateChange(InternalSchema schema) { - super(schema, false); - } - - private ColumnUpdateChange(InternalSchema schema, boolean caseSensitive) { + private ColumnUpdateChange(InternalSchema schema, boolean caseSensitive, boolean allowTimestampPrecisionEvolution) { super(schema, caseSensitive); + this.allowTimestampPrecisionEvolution = allowTimestampPrecisionEvolution; } @Override @@ -96,7 +94,7 @@ public ColumnUpdateChange updateColumnType(String name, Type newType) { throw new SchemaCompatibilityException(String.format("Cannot update type for column '%s' because it does not exist in the schema", name)); } - if (!SchemaChangeUtils.isTypeUpdateAllow(field.type(), newType)) { + if (!SchemaChangeUtils.isTypeUpdateAllow(field.type(), newType, allowTimestampPrecisionEvolution)) { throw new SchemaCompatibilityException(String.format( "Cannot update column '%s' from type '%s' to incompatible type '%s'.", name, field.type(), newType)); } @@ -232,11 +230,11 @@ protected Integer findIdByFullName(String fullName) { } public static ColumnUpdateChange get(InternalSchema schema) { - return new ColumnUpdateChange(schema); + return new ColumnUpdateChange(schema, false, false); } - public static ColumnUpdateChange get(InternalSchema schema, boolean caseSensitive) { - return new ColumnUpdateChange(schema, caseSensitive); + public static ColumnUpdateChange get(InternalSchema schema, boolean caseSensitive, boolean allowTimestampPrecisionEvolution) { + return new ColumnUpdateChange(schema, caseSensitive, allowTimestampPrecisionEvolution); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/AvroSchemaEvolutionUtils.java b/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/AvroSchemaEvolutionUtils.java index 5ae6f203f0f3b..19bb29742edc7 100644 --- a/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/AvroSchemaEvolutionUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/AvroSchemaEvolutionUtils.java @@ -18,19 +18,22 @@ package org.apache.hudi.internal.schema.utils; +import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaType; +import org.apache.hudi.exception.SchemaCompatibilityException; import org.apache.hudi.internal.schema.InternalSchema; +import org.apache.hudi.internal.schema.Type; import org.apache.hudi.internal.schema.action.TableChanges; import org.apache.hudi.internal.schema.action.TableChangesHelper; -import org.apache.avro.Schema; - import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.stream.Collectors; @@ -64,12 +67,13 @@ public class AvroSchemaEvolutionUtils { * nullable in the result. Otherwise, no updates will be made to those fields. * @return reconcile Schema */ - public static InternalSchema reconcileSchema(Schema incomingSchema, InternalSchema oldTableSchema, boolean makeMissingFieldsNullable) { + public static InternalSchema reconcileSchema(HoodieSchema incomingSchema, InternalSchema oldTableSchema, + boolean makeMissingFieldsNullable, Map timestampLogicalTypeOverrides) { /* If incoming schema is null, we fall back on table schema. */ - if (incomingSchema.getType() == Schema.Type.NULL) { + if (incomingSchema.isSchemaNull()) { return oldTableSchema; } - InternalSchema inComingInternalSchema = convert(HoodieSchema.fromAvroSchema(incomingSchema), oldTableSchema.getNameToPosition()); + InternalSchema inComingInternalSchema = convert(incomingSchema, oldTableSchema.getNameToPosition()); // check column add/missing List colNamesFromIncoming = inComingInternalSchema.getAllColsFullName(); List colNamesFromOldSchema = oldTableSchema.getAllColsFullName(); @@ -80,7 +84,19 @@ public static InternalSchema reconcileSchema(Schema incomingSchema, InternalSche .stream() .filter(f -> colNamesFromOldSchema.contains(f) && !inComingInternalSchema.findType(f).equals(oldTableSchema.findType(f))) .collect(Collectors.toList()); - if (colNamesFromIncoming.size() == colNamesFromOldSchema.size() && diffFromOldSchema.size() == 0 && typeChangeColumns.isEmpty()) { + // check columns the incoming schema relaxed from required to nullable. Since the result is built from + // oldTableSchema (to preserve column order/ids and to null-fill missing columns), an existing column + // whose incoming counterpart became nullable would otherwise silently keep the table's REQUIRED + // nullability, blocking a valid required -> nullable evolution. We only ever relax (never tighten). + List nullabilityRelaxColumns = colNamesFromIncoming + .stream() + .filter(f -> colNamesFromOldSchema.contains(f) + && !META_FIELD_NAMES.contains(f) + && inComingInternalSchema.findField(f).isOptional() + && !oldTableSchema.findField(f).isOptional()) + .collect(Collectors.toList()); + if (colNamesFromIncoming.size() == colNamesFromOldSchema.size() && diffFromOldSchema.size() == 0 + && typeChangeColumns.isEmpty() && nullabilityRelaxColumns.isEmpty()) { return oldTableSchema; } @@ -119,11 +135,27 @@ public static InternalSchema reconcileSchema(Schema incomingSchema, InternalSche // do type evolution. InternalSchema internalSchemaAfterAddColumns = SchemaChangeUtils.applyTableChanges2Schema(oldTableSchema, addChange); - TableChanges.ColumnUpdateChange typeChange = TableChanges.ColumnUpdateChange.get(internalSchemaAfterAddColumns); + // The reconcile pre-validates timestamp precision changes per field below (against the explicit + // overrides), so the update change is constructed permissively; non-overridden precision changes + // are rejected here with an actionable error rather than deferred to the gate. + TableChanges.ColumnUpdateChange typeChange = TableChanges.ColumnUpdateChange.get( + internalSchemaAfterAddColumns, false, true); typeChangeColumns.stream().filter(f -> !inComingInternalSchema.findType(f).isNestedType()).forEach(col -> { - typeChange.updateColumnType(col, inComingInternalSchema.findType(col)); + Type tableType = oldTableSchema.findType(col); + Type incomingType = inComingInternalSchema.findType(col); + if (SchemaChangeUtils.isGatedTimestampChange(tableType, incomingType)) { + // Skip-if equals the *table* type: the reconcile is producing the new table schema starting + // from oldTableSchema, so a coerce-to-table-precision override needs no schema update — the + // writer coerces incoming values via rewriteRecordWithNewSchema. + applyTimestampOverrideOrThrow(col, tableType, incomingType, timestampLogicalTypeOverrides, tableType, typeChange); + } else { + typeChange.updateColumnType(col, incomingType); + } }); + // relax existing columns to nullable when the incoming schema made them nullable (valid widening) + nullabilityRelaxColumns.forEach(col -> typeChange.updateColumnNullability(col, true)); + if (makeMissingFieldsNullable) { // mark columns missing from incoming schema as nullable Set visited = new HashSet<>(); @@ -149,8 +181,119 @@ public static InternalSchema reconcileSchema(Schema incomingSchema, InternalSche return evolvedSchema; } - public static Schema reconcileSchema(Schema incomingSchema, Schema oldTableSchema, boolean makeMissingFieldsNullable) { - return convert(reconcileSchema(incomingSchema, convert(HoodieSchema.fromAvroSchema(oldTableSchema)), makeMissingFieldsNullable), oldTableSchema.getFullName()).toAvroSchema(); + public static HoodieSchema reconcileSchema(HoodieSchema incomingSchema, HoodieSchema oldTableSchema, boolean makeMissingFieldsNullable, + Map timestampLogicalTypeOverrides) { + return convert(reconcileSchema(incomingSchema, convert(oldTableSchema), makeMissingFieldsNullable, timestampLogicalTypeOverrides), oldTableSchema.getFullName()); + } + + /** + * Reconciles only the timestamp logical-type precision of {@code writerSchema} against + * {@code tableSchema}, independent of column add/drop/nullability reconciliation. This is the + * single guard that every writer-schema deduction path must apply, including the non-reconcile + * paths that otherwise validate via the logical-type-blind Avro reader/writer compatibility check + * and would let an unverified micros/millis flip through silently. + * + *

    For each field whose precision differs from the table: an override pins it (equal to the + * table type coerces the incoming values, a different type applies the authorized evolution), and + * a change with no override throws. A UTC/local zone change throws unconditionally, since no + * override authorizes one. Non-timestamp changes are left untouched here. + */ + public static HoodieSchema reconcileTimestampLogicalType(HoodieSchema writerSchema, HoodieSchema tableSchema, + Map timestampLogicalTypeOverrides) { + if (writerSchema == null || writerSchema.getType() != HoodieSchemaType.RECORD + || tableSchema == null || tableSchema.getType() != HoodieSchemaType.RECORD) { + return writerSchema; + } + InternalSchema writerInternal = convert(writerSchema); + InternalSchema tableInternal = convert(tableSchema); + List tableCols = tableInternal.getAllColsFullName(); + TableChanges.ColumnUpdateChange typeChange = TableChanges.ColumnUpdateChange.get(writerInternal, false, true); + boolean changed = false; + for (String col : writerInternal.getAllColsFullName()) { + if (!tableCols.contains(col)) { + continue; + } + Type writerType = writerInternal.findType(col); + Type tableType = tableInternal.findType(col); + if (writerType.isNestedType()) { + continue; + } + // A zone change is never authorizable, and this is the only guard on the default + // non-reconcile path -- the Avro reader/writer check that follows is logical-type-blind for + // two long-backed fields, so skipping here would let the flip through silently. + if (SchemaChangeUtils.isCrossZoneTimestampChange(tableType, writerType)) { + throw crossZoneTimestampChangeError(col, tableType, writerType); + } + if (!SchemaChangeUtils.isGatedTimestampChange(tableType, writerType)) { + continue; + } + // Skip-if equals the *writer* type: this method returns a modified writerSchema. When the + // override already matches the writer field, the writer schema is what we want; no update. + if (applyTimestampOverrideOrThrow(col, tableType, writerType, timestampLogicalTypeOverrides, writerType, typeChange)) { + changed = true; + } + } + if (!changed) { + return writerSchema; + } + return convert(SchemaChangeUtils.applyTableChanges2Schema(writerInternal, typeChange), writerSchema.getFullName()); + } + + /** + * Shared override-apply for a single field whose type is a gated timestamp precision change. + * Called from both {@link #reconcileSchema} and {@link #reconcileTimestampLogicalType} — those + * two paths differ only in which "current" schema they compare the override against (the table + * type vs. the writer type), so the caller passes that in as {@code skipIfEquals}. + * + * @param col the fully-qualified column name (for the error message) + * @param tableType the table's current type (for the error message) + * @param incomingType the writer/incoming type (for the error message) + * @param overrides the parsed per-field overrides map + * @param skipIfEquals compare the override against this; no schema update when equal + * @param typeChange the accumulator for schema updates + * @return {@code true} if the override was applied (schema will change), {@code false} otherwise + * @throws SchemaCompatibilityException when no override is present for this gated change + */ + private static boolean applyTimestampOverrideOrThrow(String col, Type tableType, Type incomingType, + Map overrides, Type skipIfEquals, + TableChanges.ColumnUpdateChange typeChange) { + Type overrideType = overrides.get(col); + if (overrideType == null) { + throw timestampPrecisionChangeError(col, tableType, incomingType); + } + if (overrideType.equals(skipIfEquals)) { + return false; + } + typeChange.updateColumnType(col, overrideType); + return true; + } + + private static SchemaCompatibilityException crossZoneTimestampChangeError(String col, Type from, Type to) { + return new SchemaCompatibilityException(String.format( + "Refusing to change the timestamp logical type of column '%s' from '%s' to '%s': this crosses the " + + "UTC/local boundary, which changes the instant the stored value denotes and cannot be repaired by " + + "rescaling. '%s' authorizes precision changes only, never a zone change. Keep writing the column " + + "with its existing zone, or add a new column and backfill it with an explicit conversion.", + col, from, to, HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key())); + } + + /** + * Builds the actionable error for a gated timestamp logical-type change with no per-field override + * in {@code hoodie.write.timestamp.logical.type.overrides}. Public so tests can assert the exact + * message without duplicating its format. + */ + public static SchemaCompatibilityException timestampPrecisionChangeError(String col, Type from, Type to) { + return new SchemaCompatibilityException(String.format( + "Refusing to change the timestamp logical type of column '%s' from '%s' to '%s' without an explicit " + + "verdict. This precision change is not applied automatically because the correct target depends " + + "on the stored values, not the incoming schema. Inspect the raw long values of '%s' in the existing " + + "base files: for instants after 1990 epoch-millis is around 1e12 and epoch-micros is around 1e15, " + + "so the ranges do not overlap (TimestampLogicalTypeClassifier implements this verdict). Then set " + + "'%s' to the precision the values actually are, for example '%s:timestamp-micros' to keep the " + + "current precision and coerce the incoming values, or '%s:timestamp-millis' to evolve the column. " + + "Existing base files are not rewritten by this change; rewrite them via clustering or compaction " + + "so that non-Hudi readers also see the corrected type.", + col, from, to, col, HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(), col, col)); } /** @@ -169,18 +312,18 @@ public static Schema reconcileSchema(Schema incomingSchema, Schema oldTableSchem * @param targetSchema target schema that source schema will be reconciled against * @return schema (based off {@code source} one) that has nullability constraints and datatypes reconciled */ - public static Schema reconcileSchemaRequirements(Schema sourceSchema, Schema targetSchema, boolean shouldReorderColumns) { - if (targetSchema.getType() == Schema.Type.NULL || targetSchema.getFields().isEmpty()) { + public static HoodieSchema reconcileSchemaRequirements(HoodieSchema sourceSchema, HoodieSchema targetSchema, boolean shouldReorderColumns) { + if (targetSchema.isSchemaNull() || targetSchema.getFields().isEmpty()) { return sourceSchema; } - if (sourceSchema == null || sourceSchema.getType() == Schema.Type.NULL || sourceSchema.getFields().isEmpty()) { + if (sourceSchema == null || sourceSchema.isSchemaNull() || sourceSchema.getFields().isEmpty()) { return targetSchema; } - InternalSchema targetInternalSchema = convert(HoodieSchema.fromAvroSchema(targetSchema)); + InternalSchema targetInternalSchema = convert(targetSchema); // Use existing fieldIds for consistent field ordering between commits when shouldReorderColumns is true - InternalSchema sourceInternalSchema = convert(HoodieSchema.fromAvroSchema(sourceSchema), shouldReorderColumns ? targetInternalSchema.getNameToPosition() : Collections.emptyMap()); + InternalSchema sourceInternalSchema = convert(sourceSchema, shouldReorderColumns ? targetInternalSchema.getNameToPosition() : Collections.emptyMap()); List colNamesSourceSchema = sourceInternalSchema.getAllColsFullName(); List colNamesTargetSchema = targetInternalSchema.getAllColsFullName(); @@ -200,7 +343,7 @@ public static Schema reconcileSchemaRequirements(Schema sourceSchema, Schema tar if (nullableUpdateColsInSource.isEmpty() && typeUpdateColsInSource.isEmpty()) { //standardize order of unions - return convert(sourceInternalSchema, sourceSchema.getFullName()).toAvroSchema(); + return convert(sourceInternalSchema, sourceSchema.getFullName()); } TableChanges.ColumnUpdateChange schemaChange = TableChanges.ColumnUpdateChange.get(sourceInternalSchema); @@ -218,7 +361,7 @@ public static Schema reconcileSchemaRequirements(Schema sourceSchema, Schema tar } - return convert(SchemaChangeUtils.applyTableChanges2Schema(sourceInternalSchema, schemaChange), sourceSchema.getFullName()).toAvroSchema(); + return convert(SchemaChangeUtils.applyTableChanges2Schema(sourceInternalSchema, schemaChange), sourceSchema.getFullName()); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/SchemaChangeUtils.java b/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/SchemaChangeUtils.java index f02407b986ed7..5c14e16bc5535 100644 --- a/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/SchemaChangeUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/internal/schema/utils/SchemaChangeUtils.java @@ -28,7 +28,11 @@ import lombok.NoArgsConstructor; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; /** * Helper methods for schema Change. @@ -36,6 +40,96 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SchemaChangeUtils { + /** + * Parses the {@code hoodie.write.timestamp.logical.type.overrides} value into a per-field map of + * the target timestamp {@link Type}. The value is a comma-separated list of {@code field:type} + * pairs, where type is one of timestamp-micros, timestamp-millis, local-timestamp-micros, + * local-timestamp-millis (case-insensitive). The tokens are a Hudi-owned vocabulary decoupled + * from any serialization format. + * + *

    Splits on the last {@code ':'} so dotted nested field names ({@code parent.child}) work + * unchanged. Field names containing a literal {@code ':'} are not supported. + * + * @param value the raw config value (may be null or empty) + * @return an unmodifiable map from field name to the pinned timestamp type; empty if unset + */ + public static Map parseTimestampLogicalTypeOverrides(String value) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + for (String pair : value.split(",")) { + String trimmed = pair.trim(); + if (trimmed.isEmpty()) { + continue; + } + int sep = trimmed.lastIndexOf(':'); + if (sep <= 0 || sep == trimmed.length() - 1) { + throw new IllegalArgumentException("Invalid timestamp logical type override entry '" + trimmed + + "'. Expected 'field:type' where type is one of timestamp-micros, timestamp-millis, " + + "local-timestamp-micros, local-timestamp-millis."); + } + String field = trimmed.substring(0, sep).trim(); + Type type = timestampTypeFromToken(trimmed.substring(sep + 1).trim()); + result.put(field, type); + } + return Collections.unmodifiableMap(result); + } + + private static Type timestampTypeFromToken(String token) { + switch (token.toLowerCase(Locale.ROOT)) { + case "timestamp-micros": + return Types.TimestampType.get(); + case "timestamp-millis": + return Types.TimestampMillisType.get(); + case "local-timestamp-micros": + return Types.LocalTimestampMicrosType.get(); + case "local-timestamp-millis": + return Types.LocalTimestampMillisType.get(); + default: + throw new IllegalArgumentException("Unknown timestamp logical type token '" + token + + "'. Expected one of timestamp-micros, timestamp-millis, local-timestamp-micros, " + + "local-timestamp-millis."); + } + } + + /** + * Whether a column type change is a timestamp precision change that must be authorized by an + * explicit per-field override (see {@code hoodie.write.timestamp.logical.type.overrides}). This + * covers timestamp-micros/millis flips, local-timestamp-micros/millis flips, and promoting a bare + * {@code long} to a timestamp logical type (UTC or local). + */ + public static boolean isGatedTimestampChange(Type src, Type dst) { + if (src.equals(dst)) { + return false; + } + if (isUtcTimestamp(src) && isUtcTimestamp(dst)) { + return true; + } + if (isLocalTimestamp(src) && isLocalTimestamp(dst)) { + return true; + } + return src.typeId() == Type.TypeID.LONG && (isUtcTimestamp(dst) || isLocalTimestamp(dst)); + } + + /** + * Whether a column type change crosses the UTC/local timestamp boundary. Unlike a precision + * change this has no value-level repair: the same long denotes a different instant under each + * interpretation, so rescaling cannot express the conversion. A zone change is therefore always + * rejected and no per-field override authorizes it. + */ + public static boolean isCrossZoneTimestampChange(Type src, Type dst) { + return (isUtcTimestamp(src) && isLocalTimestamp(dst)) || (isLocalTimestamp(src) && isUtcTimestamp(dst)); + } + + private static boolean isUtcTimestamp(Type type) { + return type.typeId() == Type.TypeID.TIMESTAMP || type.typeId() == Type.TypeID.TIMESTAMP_MILLIS; + } + + private static boolean isLocalTimestamp(Type type) { + return type.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MILLIS || type.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MICROS; + } + /** * Whether to allow the column type to be updated. * now only support: @@ -52,29 +146,36 @@ public class SchemaChangeUtils { * @param dst new column type. * @return whether to allow the column type to be updated. */ - public static boolean isTypeUpdateAllow(Type src, Type dst) { + public static boolean isTypeUpdateAllow(Type src, Type dst, boolean allowTimestampPrecisionEvolution) { if (src.isNestedType() || dst.isNestedType()) { throw new IllegalArgumentException("only support update primitive type"); } if (src.equals(dst)) { return true; } - return isTypeUpdateAllowInternal(src, dst); + return isTypeUpdateAllowInternal(src, dst, allowTimestampPrecisionEvolution); } public static boolean shouldPromoteType(Type src, Type dst) { if (src.equals(dst) || src.isNestedType() || dst.isNestedType()) { return false; } - return isTypeUpdateAllowInternal(src, dst); + return isTypeUpdateAllowInternal(src, dst, false); } - private static boolean isTypeUpdateAllowInternal(Type src, Type dst) { + private static boolean isTypeUpdateAllowInternal(Type src, Type dst, boolean allowTimestampPrecisionEvolution) { switch (src.typeId()) { case INT: return dst == Types.LongType.get() || dst == Types.FloatType.get() || dst == Types.DoubleType.get() || dst == Types.StringType.get() || dst.typeId() == Type.TypeID.DECIMAL || dst.typeId() == Type.TypeID.DECIMAL_FIXED; case LONG: + if (allowTimestampPrecisionEvolution + && (dst.typeId() == Type.TypeID.TIMESTAMP || dst.typeId() == Type.TypeID.TIMESTAMP_MILLIS + || dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MILLIS || dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MICROS)) { + // A bare long carries no precision signal, so promoting it to a timestamp logical type is + // authorized only by an explicit per-field override. + return true; + } return dst == Types.FloatType.get() || dst == Types.DoubleType.get() || dst == Types.StringType.get() || dst.typeId() == Type.TypeID.DECIMAL || dst.typeId() == Type.TypeID.DECIMAL_FIXED; case FLOAT: return dst == Types.DoubleType.get() || dst == Types.StringType.get() || dst.typeId() == Type.TypeID.DECIMAL || dst.typeId() == Type.TypeID.DECIMAL_FIXED; @@ -90,6 +191,18 @@ private static boolean isTypeUpdateAllowInternal(Type src, Type dst) { return isDecimalFixedUpdateAllowInternal(src, dst); case STRING: return dst == Types.DateType.get() || dst.typeId() == Type.TypeID.DECIMAL || dst.typeId() == Type.TypeID.DECIMAL_FIXED || dst == Types.BinaryType.get(); + case TIMESTAMP: + case TIMESTAMP_MILLIS: + if (!allowTimestampPrecisionEvolution) { + return false; + } + return dst.typeId() == Type.TypeID.TIMESTAMP || dst.typeId() == Type.TypeID.TIMESTAMP_MILLIS; + case LOCAL_TIMESTAMP_MILLIS: + case LOCAL_TIMESTAMP_MICROS: + if (!allowTimestampPrecisionEvolution) { + return false; + } + return dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MILLIS || dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MICROS; default: return false; } diff --git a/hudi-common/src/main/java/org/apache/hudi/io/storage/HoodieNativeAvroHFileReader.java b/hudi-common/src/main/java/org/apache/hudi/io/storage/HoodieNativeAvroHFileReader.java index 1020d1a8c9f02..4865124c69e19 100644 --- a/hudi-common/src/main/java/org/apache/hudi/io/storage/HoodieNativeAvroHFileReader.java +++ b/hudi-common/src/main/java/org/apache/hudi/io/storage/HoodieNativeAvroHFileReader.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.common.util.collection.CloseableMappingIterator; @@ -134,8 +135,11 @@ public BloomFilter readBloomFilter() { public Set> filterRowKeys(Set candidateRowKeys) { try (HFileReader reader = readerFactory.createHFileReader()) { reader.seekTo(); - // candidateRowKeys must be sorted - return (candidateRowKeys instanceof TreeSet ? candidateRowKeys : new TreeSet<>(candidateRowKeys)) + // candidateRowKeys must be sorted by UTF-8 bytes to match HFile ordering because the reader + // only seeks forward. + TreeSet sortedRowKeys = new TreeSet<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); + sortedRowKeys.addAll(candidateRowKeys); + return sortedRowKeys .stream() .filter(k -> { try { diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/BaseFileRecordParsingUtils.java b/hudi-common/src/main/java/org/apache/hudi/metadata/BaseFileRecordParsingUtils.java index e4d83111ae883..6b92f9dcf39bb 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/BaseFileRecordParsingUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/BaseFileRecordParsingUtils.java @@ -77,9 +77,10 @@ public static Iterator generateRLIMetadataHoodieRecordsForBaseFile recordStatuses); List hoodieRecords = new ArrayList<>(); if (recordStatusListMap.containsKey(RecordStatus.INSERT)) { + long instantTimeMillis = HoodieMetadataPayload.parseRecordIndexInstantTime(instantTime); hoodieRecords.addAll(recordStatusListMap.get(RecordStatus.INSERT).stream() .map(recordKey -> (HoodieRecord) HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partition, fileId, - instantTime, writesFileIdEncoding)).collect(toList())); + instantTimeMillis, writesFileIdEncoding)).collect(toList())); } if (recordStatusListMap.containsKey(RecordStatus.DELETE)) { diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/BaseTableMetadata.java b/hudi-common/src/main/java/org/apache/hudi/metadata/BaseTableMetadata.java index f59a257dd7775..c849262d2d94a 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/BaseTableMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/BaseTableMetadata.java @@ -94,7 +94,8 @@ protected BaseTableMetadata(HoodieEngineContext engineContext, if (metadataConfig.isMetricsEnabled()) { this.metrics = Option.of(new HoodieMetadataMetrics(HoodieMetricsConfig.newBuilder() - .fromProperties(metadataConfig.getProps()).withPath(dataBasePath).build(), dataMetaClient.getStorage())); + .fromProperties(metadataConfig.getProps()).withPath(dataBasePath).build(), dataMetaClient.getStorage(), + metadataConfig.isDetailedMetricsEnabled())); } else { this.metrics = Option.empty(); } diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java index 1fabe8c97786f..4f055baeb424f 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java @@ -50,6 +50,7 @@ import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.ConfigUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.common.util.collection.ClosableSortedDedupingIterator; @@ -231,9 +232,9 @@ public HoodieData> getRecordsByKeyPrefixes( boolean shouldLoadInMemory) { // Apply key encoding List sortedKeyPrefixes = new ArrayList<>(rawKeys.map(key -> key.encode()).collectAsList()); - // Sort the prefixes so that keys are looked up in order - // Sort must come after encoding. - Collections.sort(sortedKeyPrefixes); + // Sort the prefixes so that keys are looked up in order. Sort must come after encoding. + // Sort by UTF-8 bytes to match the HFile order; the reader seeks forward without rewinding. + sortedKeyPrefixes.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); // NOTE: Since we partition records to a particular file-group by full key, we will have // to scan all file-groups for all key-prefixes as each of these might contain some @@ -258,7 +259,10 @@ public HoodieData> getRecordsByKeyPrefixes( private static TreeSet getDistinctSortedKeysForSingleSlice(HoodieData keys) { List keysList = keys.collectAsList(); - return new TreeSet<>(keysList); + // Order by UTF-8 bytes to match the HFile order used for point lookups. + TreeSet sortedKeys = new TreeSet<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); + sortedKeys.addAll(keysList); + return sortedKeys; } /** @@ -316,6 +320,9 @@ private HoodieData> lookupIndexRecords(Hoodi } distinctSortedKeyIter.forEachRemaining(keysList::add); } + // The shuffle above repartitions/sorts by String (UTF-16) order, but the HFile reader below + // does a forward-only seek in UTF-8 byte order. Re-sort so the two agree. + keysList.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); FileSlice fileSlice = fileSlices.get(mappingFunction.apply(keysList.get(0), numFileSlices)); return lookupRecordsItr(partitionName, keysList, fileSlice, !isSecondaryIndex); }; @@ -601,11 +608,13 @@ private ClosableIterator readSliceWithFilter(Predicate predicate, baseFileReaders, fileGroupReaderProps); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metadataMetaClient) .withLatestCommitTime(latestMetadataInstantTime) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(SCHEMA) .withRequestedSchema(SCHEMA) .withProps(fileGroupReaderProps) diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataMetrics.java b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataMetrics.java index c7244a30d094c..75d208655af6c 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataMetrics.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataMetrics.java @@ -67,6 +67,7 @@ public class HoodieMetadataMetrics implements Serializable { public static final String INITIALIZE_STR = "initialize"; public static final String REBOOTSTRAP_STR = "rebootstrap_count"; public static final String BOOTSTRAP_ERR_STR = "bootstrap_error"; + public static final String SKIPPED_ZERO_SIZE_FILES_ON_INITIALIZE_STR = "skipped_zero_size_files_on_initialize"; // Stats names public static final String STAT_TOTAL_BASE_FILE_SIZE = "totalBaseFileSizeInBytes"; @@ -85,10 +86,12 @@ public class HoodieMetadataMetrics implements Serializable { private final transient MetricRegistry metricsRegistry; private final transient Metrics metrics; + private final boolean detailedMetricsEnabled; - public HoodieMetadataMetrics(HoodieMetricsConfig metricsConfig, HoodieStorage storage) { + public HoodieMetadataMetrics(HoodieMetricsConfig metricsConfig, HoodieStorage storage, boolean detailedMetricsEnabled) { this.metrics = Metrics.getInstance(metricsConfig, storage); this.metricsRegistry = metrics.getRegistry(); + this.detailedMetricsEnabled = detailedMetricsEnabled; } public Map getStats(boolean detailed, HoodieTableMetaClient metaClient, HoodieTableMetadata metadata, Set metadataPartitions) { @@ -101,6 +104,10 @@ public Map getStats(boolean detailed, HoodieTableMetaClient meta } } + public boolean isDetailedMetricsEnabled() { + return detailedMetricsEnabled; + } + private Map getStats(HoodieTableFileSystemView fsView, boolean detailed, HoodieTableMetadata tableMetadata, Set metadataPartitions) throws IOException { Map stats = new HashMap<>(); diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java index ac509fce962ef..325a7a5af6baa 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java @@ -60,6 +60,7 @@ import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -649,14 +650,46 @@ public static Stream createPartitionStatsRecords(String partitionP */ public static HoodieRecord createRecordIndexUpdate(String recordKey, String partition, String fileId, String instantTime, int fileIdEncoding) { + return createRecordIndexUpdate(recordKey, partition, fileId, parseRecordIndexInstantTime(instantTime), fileIdEncoding); + } - HoodieKey key = new HoodieKey(recordKey, MetadataPartitionType.RECORD_INDEX.getPartitionPath()); - long instantTimeMillis = -1; + /** + * Parses an instant time into the epoch millis stored in record index entries. + *

    + * Callers creating record index updates for many records of the same commit should parse the + * instant time once with this method and use the millis-based + * {@link #createRecordIndexUpdate(String, String, String, long, int)} overload per record. + */ + public static long parseRecordIndexInstantTime(String instantTime) { try { - instantTimeMillis = TimelineUtils.parseDateFromInstantTime(instantTime).getTime(); + return TimelineUtils.parseDateFromInstantTime(instantTime).getTime(); } catch (Exception e) { throw new HoodieMetadataException("Failed to create metadata payload for record index. Instant time parsing for " + instantTime + " failed ", e); } + } + + /** + * Create and return a {@code HoodieMetadataPayload} to insert or update an entry for the record index. + *

    + * Same as {@link #createRecordIndexUpdate(String, String, String, String, int)} but takes the + * instant time already parsed to epoch millis, so per-commit callers parse it only once. + *

    + * {@code instantTimeMillis} must be obtained from {@link #parseRecordIndexInstantTime(String)} (which + * delegates to {@link TimelineUtils#parseDateFromInstantTime(String)}); it should not be constructed by + * hand, so that the value stored here matches what the String overload would write. The instant-time + * string is interpreted in the JVM default time zone ({@link java.time.ZoneId#systemDefault()}) and the + * result is epoch milliseconds (since 1970-01-01T00:00:00Z). + * + * @param recordKey Key of the record + * @param partition Name of the partition which contains the record + * @param fileId fileId which contains the record + * @param instantTimeMillis epoch millis of the instant when the record was added, as returned by + * {@link #parseRecordIndexInstantTime(String)} + */ + public static HoodieRecord createRecordIndexUpdate(String recordKey, String partition, + String fileId, long instantTimeMillis, int fileIdEncoding) { + + HoodieKey key = new HoodieKey(recordKey, MetadataPartitionType.RECORD_INDEX.getPartitionPath()); if (fileIdEncoding == 0) { // Data file names have a -D suffix to denote the index (D = integer) of the file written // In older HUID versions the file index was missing @@ -672,8 +705,9 @@ public static HoodieRecord createRecordIndexUpdate(String fileIndex = Integer.parseInt(fileId.substring(index + 1)); } } catch (Exception e) { + // reconstruct the instant time only on this cold error path; the hot per-record path keeps the pre-parsed millis throw new HoodieMetadataException(String.format("Invalid UUID or index: fileID=%s, partition=%s, instantTime=%s", - fileId, partition, instantTime), e); + fileId, partition, TimelineUtils.formatDate(new Date(instantTimeMillis))), e); } HoodieMetadataPayload payload = new HoodieMetadataPayload(recordKey, diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java index 59e2d6dcd83f7..af7b94391810d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java @@ -144,6 +144,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -275,15 +276,19 @@ public static Map> collectColumnRa final Properties properties = new Properties(); properties.setProperty(HoodieStorageConfig.WRITE_UTC_TIMEZONE.key(), storageConfig.getString(HoodieStorageConfig.WRITE_UTC_TIMEZONE.key(), HoodieStorageConfig.WRITE_UTC_TIMEZONE.defaultValue().toString())); + // getNonNullType() rebuilds the union-member wrappers for nullable fields and depends only on the + // (fixed) target fields, so resolve it once per field instead of once per record per field. + List> nonNullFieldSchemas = targetFields.stream() + .map(p -> Pair.of(p.getKey(), p.getValue().schema().getNonNullType())) + .collect(Collectors.toList()); // Collect stats for all columns by iterating through records while accounting // corresponding stats records.forEachRemaining((record) -> { // For each column (field) we have to index update corresponding column stats // with the values from this record - targetFields.forEach(fieldNameFieldPair -> { - String fieldName = fieldNameFieldPair.getKey(); - HoodieSchemaField field = fieldNameFieldPair.getValue(); - HoodieSchema fieldSchema = field.schema().getNonNullType(); + nonNullFieldSchemas.forEach(fieldNameSchemaPair -> { + String fieldName = fieldNameSchemaPair.getKey(); + HoodieSchema fieldSchema = fieldNameSchemaPair.getValue(); if (!isColumnTypeSupported(fieldSchema, Option.of(record.getRecordType()), indexVersion)) { return; } @@ -479,31 +484,29 @@ public static List convertMetadataToFilesPartitionRecords(HoodieCo String partitionStatName = entry.getKey(); List writeStats = entry.getValue(); - HashMap updatedFilesToSizesMapping = - writeStats.stream().reduce(new HashMap<>(writeStats.size()), - (map, stat) -> { - String pathWithPartition = stat.getPath(); - if (pathWithPartition == null) { - // Empty partition - log.warn("Unable to find path in write stat to update metadata table {}", stat); - return map; - } - - String fileName = FSUtils.getFileName(pathWithPartition, partitionStatName); - - // Since write-stats are coming in no particular order, if the same - // file have previously been appended to w/in the txn, we simply pick max - // of the sizes as reported after every write, since file-sizes are - // monotonically increasing (ie file-size never goes down, unless deleted) - map.merge(fileName, stat.getFileSizeInBytes(), Math::max); - - Map cdcPathAndSizes = stat.getCdcStats(); - if (cdcPathAndSizes != null && !cdcPathAndSizes.isEmpty()) { - cdcPathAndSizes.forEach((key, value) -> map.put(FSUtils.getFileName(key, partitionStatName), value)); - } - return map; - }, - CollectionUtils::combine); + HashMap updatedFilesToSizesMapping = new HashMap<>(writeStats.size()); + for (HoodieWriteStat stat : writeStats) { + String pathWithPartition = stat.getPath(); + if (pathWithPartition == null) { + // Empty partition + log.warn("Unable to find path in write stat to update metadata table {}", stat); + continue; + } + + String fileName = FSUtils.getFileName(pathWithPartition, partitionStatName); + + // Since write-stats are coming in no particular order, if the same + // file have previously been appended to w/in the txn, we simply pick max + // of the sizes as reported after every write, since file-sizes are + // monotonically increasing (ie file-size never goes down, unless deleted) + updatedFilesToSizesMapping.merge(fileName, stat.getFileSizeInBytes(), Math::max); + + Map cdcPathAndSizes = stat.getCdcStats(); + if (cdcPathAndSizes != null && !cdcPathAndSizes.isEmpty()) { + cdcPathAndSizes.forEach((key, value) -> + updatedFilesToSizesMapping.put(FSUtils.getFileName(key, partitionStatName), value)); + } + } newFileCount.add(updatedFilesToSizesMapping.size()); return HoodieMetadataPayload.createPartitionFilesRecord(partitionStatName, updatedFilesToSizesMapping, @@ -940,8 +943,9 @@ public static HoodieData convertMetadataToRecordIndexRecords(H Set revivedKeys = revivedAndDeletedKeys.getLeft(); Set deletedKeys = revivedAndDeletedKeys.getRight(); // Process revived keys to create updates + long instantTimeMillis = HoodieMetadataPayload.parseRecordIndexInstantTime(instantTime); List revivedRecords = revivedKeys.stream() - .map(recordKey -> HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partitionPath, fileId, instantTime, writesFileIdEncoding)) + .map(recordKey -> HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partitionPath, fileId, instantTimeMillis, writesFileIdEncoding)) .collect(Collectors.toList()); // Process deleted keys to create deletes List deletedRecords = deletedKeys.stream() @@ -1809,7 +1813,7 @@ public static List> getLogFileColumnRangeM properties.setProperty(HoodieReaderConfig.MERGE_TYPE.key(), REALTIME_SKIP_MERGE); // Currently only avro is fully supported for extracting column ranges (see HUDI-8585) HoodieReaderContext readerContext = new HoodieAvroReaderContext(datasetMetaClient.getStorageConf(), datasetMetaClient.getTableConfig(), Option.empty(), Option.empty()); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(datasetMetaClient) .withLogFiles(Stream.of(logFile)) @@ -2499,7 +2503,7 @@ public static HoodieRecordGlobalLocation getLocationFromRecordIndexInfo( fileId = originalFileId; } - final java.util.Date instantDate = new java.util.Date(instantTime); + final Date instantDate = new Date(instantTime); return new HoodieRecordGlobalLocation(partition, HoodieInstantTimeGenerator.formatDate(instantDate), fileId); } @@ -2568,10 +2572,12 @@ public static HoodieData readRecordKeysFromFileSlices(HoodieEn final String partition = partitionAndBaseFile.getKey(); final FileSlice fileSlice = partitionAndBaseFile.getValue(); if (!fileSlice.getBaseFile().isPresent()) { - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContextFactory.getContext()) .withHoodieTableMetaClient(metaClient) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(tableSchema) .withRequestedSchema(HoodieSchemaUtils.getRecordKeySchema()) .withLatestCommitTime(latestCommitTime) @@ -2613,6 +2619,8 @@ private static ClosableIterator getHoodieRecordIterator(ClosableIt String instantTime, boolean isPartitionedRLI ) { + // the delete iterator never reads the instant time, so only the update path pays the parse + final long instantTimeMillis = forDelete ? -1L : HoodieMetadataPayload.parseRecordIndexInstantTime(instantTime); return new ClosableIterator() { @Override public void close() { @@ -2628,7 +2636,7 @@ public boolean hasNext() { public HoodieRecord next() { return forDelete ? HoodieMetadataPayload.createRecordIndexDelete(recordKeyIterator.next(), partition, isPartitionedRLI) - : HoodieMetadataPayload.createRecordIndexUpdate(recordKeyIterator.next(), partition, fileId, instantTime, 0); + : HoodieMetadataPayload.createRecordIndexUpdate(recordKeyIterator.next(), partition, fileId, instantTimeMillis, 0); } }; } @@ -3118,9 +3126,10 @@ public static class DirectoryInfo implements Serializable { private final List subDirectories = new ArrayList<>(); // Is this a hoodie partition private boolean isHoodiePartition = false; + private int zeroSizeFileCount = 0; public DirectoryInfo(String relativePath, List pathInfos, String maxInstantTime, Set pendingDataInstants) { - this(relativePath, pathInfos, maxInstantTime, pendingDataInstants, true); + this(relativePath, pathInfos, maxInstantTime, pendingDataInstants, true, false); } /** @@ -3128,6 +3137,11 @@ public DirectoryInfo(String relativePath, List pathInfos, Strin */ public DirectoryInfo(String relativePath, List pathInfos, String maxInstantTime, Set pendingDataInstants, boolean validateHoodiePartitions) { + this(relativePath, pathInfos, maxInstantTime, pendingDataInstants, validateHoodiePartitions, false); + } + + public DirectoryInfo(String relativePath, List pathInfos, String maxInstantTime, Set pendingDataInstants, + boolean validateHoodiePartitions, boolean skipZeroSizeFiles) { this.relativePath = relativePath; // Pre-allocate with the maximum length possible @@ -3148,10 +3162,19 @@ public DirectoryInfo(String relativePath, List pathInfos, Strin String dataFileCommitTime = FSUtils.getCommitTime(pathInfo.getPath().getName()); // Limit the file listings to files which were created by successful commits before the maxInstant time. if (!pendingDataInstants.contains(dataFileCommitTime) && compareTimestamps(dataFileCommitTime, LESSER_THAN_OR_EQUALS, maxInstantTime)) { - filenameToSizeMap.put(pathInfo.getPath().getName(), pathInfo.getLength()); + if (pathInfo.getLength() > 0 || !skipZeroSizeFiles) { + filenameToSizeMap.put(pathInfo.getPath().getName(), pathInfo.getLength()); + } else { + log.debug("Skipping zero-size data file: {}", pathInfo.getPath()); + zeroSizeFileCount++; + } } } } + if (zeroSizeFileCount > 0) { + log.warn("Skipped {} zero-size data files while listing partition {}; they remain on storage and are not tracked in the metadata table", + zeroSizeFileCount, relativePath); + } } } diff --git a/hudi-common/src/main/java/org/apache/hudi/metadata/MetadataPartitionType.java b/hudi-common/src/main/java/org/apache/hudi/metadata/MetadataPartitionType.java index 04bd9bdab2643..655fd4e1d138b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metadata/MetadataPartitionType.java +++ b/hudi-common/src/main/java/org/apache/hudi/metadata/MetadataPartitionType.java @@ -176,14 +176,16 @@ public void constructMetadataPayload(HoodieMetadataPayload payload, GenericRecor if (recordIndexRecord.hasField(RECORD_INDEX_FIELD_POSITION)) { recordIndexPosition = recordIndexRecord.get(RECORD_INDEX_FIELD_POSITION); } + // Numeric RLI fields are long/int per HoodieMetadata.avsc, so read them directly instead of + // round-tripping through String (toString + parse) for every materialized record. payload.recordIndexMetadata = new HoodieRecordIndexInfo(recordIndexRecord.get(RECORD_INDEX_FIELD_PARTITION).toString(), - Long.parseLong(recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_HIGH_BITS).toString()), - Long.parseLong(recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_LOW_BITS).toString()), - Integer.parseInt(recordIndexRecord.get(RECORD_INDEX_FIELD_FILE_INDEX).toString()), + ((Number) recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_HIGH_BITS)).longValue(), + ((Number) recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_LOW_BITS)).longValue(), + ((Number) recordIndexRecord.get(RECORD_INDEX_FIELD_FILE_INDEX)).intValue(), recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID).toString(), - Long.parseLong(recordIndexRecord.get(RECORD_INDEX_FIELD_INSTANT_TIME).toString()), - Integer.parseInt(recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_ENCODING).toString()), - recordIndexPosition != null ? Long.parseLong(recordIndexPosition.toString()) : null); + ((Number) recordIndexRecord.get(RECORD_INDEX_FIELD_INSTANT_TIME)).longValue(), + ((Number) recordIndexRecord.get(RECORD_INDEX_FIELD_FILEID_ENCODING)).intValue(), + recordIndexPosition != null ? ((Number) recordIndexPosition).longValue() : null); } }, EXPRESSION_INDEX(PARTITION_NAME_EXPRESSION_INDEX_PREFIX, "expr-index-", -1) { @@ -427,11 +429,15 @@ public static boolean shouldDeletePartitionOnRestore(String partitionPath) { && partitionType != COLUMN_STATS; } + // Cache values() once; it clones the constant array on every call, and get(int) runs once per + // record materialized from the metadata table (RLI/SI/col-stats lookups, MDT log merges). + private static final MetadataPartitionType[] VALUES = values(); + /** * Get the metadata partition type for the given record type. */ public static MetadataPartitionType get(int type) { - for (MetadataPartitionType partitionType : values()) { + for (MetadataPartitionType partitionType : VALUES) { if (partitionType.getRecordType() == type) { return partitionType; } @@ -504,7 +510,7 @@ public static boolean isNewSecondaryIndexDefinitionRequired(HoodieMetadataConfig return false; } // check the index definition already exists or not for this column - List indexDefinitions = getIndexDefinitions(secondaryIndexColumn, PARTITION_NAME_SECONDARY_INDEX, dataMetaClient); + List indexDefinitions = getIndexDefinitions(PARTITION_NAME_SECONDARY_INDEX, secondaryIndexColumn, dataMetaClient); return indexDefinitions.isEmpty(); } @@ -525,7 +531,7 @@ public static boolean isNewExpressionIndexDefinitionRequired(HoodieMetadataConfi // get all index definitions for this column and index type // check if none of the index definitions has index function matching the expression - List indexDefinitions = getIndexDefinitions(expressionIndexColumn, PARTITION_NAME_EXPRESSION_INDEX, dataMetaClient); + List indexDefinitions = getIndexDefinitions(PARTITION_NAME_EXPRESSION_INDEX, expressionIndexColumn, dataMetaClient); return indexDefinitions.isEmpty() || indexDefinitions.stream().noneMatch(indexDefinition -> indexDefinition.getIndexFunction().equals(expressionIndexOptions.get(HoodieExpressionIndex.EXPRESSION_OPTION))); } @@ -543,11 +549,6 @@ private static List getIndexDefinitions(String indexType, return indexDefinitions; } - private static boolean isIndexDefinitionPresentForColumn(String indexedColumn, String indexType, HoodieTableMetaClient dataMetaClient) { - return dataMetaClient.getIndexMetadata().isPresent() && dataMetaClient.getIndexMetadata().get().getIndexDefinitions().values().stream() - .anyMatch(indexDefinition -> indexDefinition.getSourceFields().contains(indexedColumn) && indexDefinition.getIndexType().equals(indexType)); - } - @Override public String toString() { return "Metadata partition {" diff --git a/hudi-common/src/main/java/org/apache/hudi/metrics/JmxMetricsReporter.java b/hudi-common/src/main/java/org/apache/hudi/metrics/JmxMetricsReporter.java index 623a055100a78..da36131652850 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metrics/JmxMetricsReporter.java +++ b/hudi-common/src/main/java/org/apache/hudi/metrics/JmxMetricsReporter.java @@ -60,7 +60,7 @@ public JmxMetricsReporter(HoodieMetricsConfig config, MetricRegistry registry) { "Could not start JMX server on any configured port. Ports: " + portsConfig + ". Maybe require port range for multiple hoodie tables"); } - log.info("Configured JMXReporter with {port:" + portsConfig + "}"); + log.info("Configured JMXReporter with {port: {}}", portsConfig); } catch (Exception e) { String msg = "Jmx initialize failed: "; log.error(msg, e); @@ -76,13 +76,13 @@ private void initializeJmxReporterServer(String host, int[] ports) { for (int port : ports) { try { jmxReporterServer = createJmxReport(host, port); - log.info("Started JMX server on port " + port + "."); + log.info("Started JMX server on port {}.", port); break; } catch (Exception e) { if (e.getCause() instanceof ExportException) { - log.info("Skip for initializing jmx port " + port + " because of already in use"); + log.info("Skip for initializing jmx port {} because of already in use", port); } else { - log.info("Failed to initialize jmx port " + port + ". " + e.getMessage()); + log.info("Failed to initialize jmx port {}. {}", port, e.getMessage()); } } } diff --git a/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java b/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java index b93968e23d73f..6aecc0dbfb2da 100644 --- a/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java +++ b/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.common.util.VisibleForTesting; import org.apache.hudi.config.metrics.HoodieMetricsConfig; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.metrics.custom.CustomizableMetricsReporter; @@ -40,6 +41,10 @@ @Slf4j public class MetricsReporterFactory { + @VisibleForTesting + static final String CLOUDWATCH_REPORTER_CLASS = + "org.apache.hudi.aws.metrics.cloudwatch.CloudWatchMetricsReporter"; + public static Option createReporter(HoodieMetricsConfig metricsConfig, MetricRegistry registry) { String reporterClassName = metricsConfig.getMetricReporterClassName(); @@ -84,8 +89,7 @@ public static Option createReporter(HoodieMetricsConfig metrics reporter = new ConsoleMetricsReporter(registry); break; case CLOUDWATCH: - reporter = (MetricsReporter) ReflectionUtils.loadClass("org.apache.hudi.aws.metrics.cloudwatch.CloudWatchMetricsReporter", - new Class[]{HoodieMetricsConfig.class, MetricRegistry.class}, metricsConfig, registry); + reporter = createCloudWatchReporter(metricsConfig, registry); break; case M3: reporter = new M3MetricsReporter(metricsConfig, registry); @@ -94,9 +98,68 @@ public static Option createReporter(HoodieMetricsConfig metrics reporter = new Slf4jMetricsReporter(registry); break; default: - log.error("Reporter type[" + type + "] is not supported."); + log.error("Reporter type[{}] is not supported.", type); break; } return Option.ofNullable(reporter); } + + /** + * The CloudWatch reporter ships in the optional {@code hudi-aws} module and so is loaded reflectively. + * Reflection collapses several unrelated failures into the same opaque {@link HoodieException}. Three of + * them have distinct remedies - the module is absent, it was built against a Hudi that has since moved a + * class, or the classpath carries a stale duplicate - so translate those three, and leave every other + * failure untouched. + */ + private static MetricsReporter createCloudWatchReporter(HoodieMetricsConfig metricsConfig, MetricRegistry registry) { + return createCloudWatchReporter(CLOUDWATCH_REPORTER_CLASS, metricsConfig, registry); + } + + @VisibleForTesting + static MetricsReporter createCloudWatchReporter(String reporterClass, HoodieMetricsConfig metricsConfig, + MetricRegistry registry) { + try { + return (MetricsReporter) ReflectionUtils.loadClass(reporterClass, + new Class[] {HoodieMetricsConfig.class, MetricRegistry.class}, metricsConfig, registry); + } catch (NoClassDefFoundError e) { + // Class#getConstructor resolves the parameter types of every public constructor, not just the one + // asked for, so a jar built against an older Hudi fails here on a type that has since moved - not + // with a missing-constructor error. NoClassDefFoundError is an Error, so ReflectionUtils never wraps + // it and it arrives here uncaught. Its message names the type that vanished, which is the strongest + // evidence of skew available. + throw new HoodieException(String.format( + "Cannot report metrics to CloudWatch: %s was found on the classpath but was built against a " + + "different Hudi version - resolving its constructors needs %s, which this Hudi no longer " + + "provides. Use a hudi-aws-bundle of the same version as the engine bundle, or set %s to a " + + "different reporter type.", + reporterClass, e.getMessage(), HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e); + } catch (HoodieException e) { + if (e.getCause() instanceof ClassNotFoundException) { + throw new HoodieException(String.format( + "Cannot report metrics to CloudWatch: %s was not found on the classpath. It ships in the " + + "optional hudi-aws module, which not every engine bundle includes. Add the " + + "hudi-aws-bundle jar matching your Hudi version to the classpath, or set %s to a " + + "different reporter type.", + reporterClass, HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e); + } + if (e.getCause() instanceof NoSuchMethodException) { + // The class resolved and so did every constructor's parameter types, yet none matched. A jar built + // against an older Hudi fails earlier, in the NoClassDefFoundError branch above, so what reaches + // here is a classpath carrying a stale or duplicate copy of this class or of its parameter types. + // Fully qualified names, because the package move and the shaded-codahale relocation are exactly + // what distinguishes the declared constructor from the requested one - under simple names both read + // as (HoodieMetricsConfig, MetricRegistry) and the error looks wrong. + throw new HoodieException(String.format( + "Cannot report metrics to CloudWatch: %s was found on the classpath but does not declare a " + + "(%s, %s) constructor. Some jar on the classpath is supplying a stale or duplicate copy " + + "of this class or of its parameter types. Check for more than one Hudi version on the " + + "classpath - a leftover hudi-common or hudi-client-common is the usual culprit - and " + + "align every Hudi artifact, including the engine bundle, to one version. Or set %s to a " + + "different reporter type.", + reporterClass, HoodieMetricsConfig.class.getName(), + MetricRegistry.class.getName(), HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e); + } + throw e; + } + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/BaseHoodieTableFileIndexTest.java b/hudi-common/src/test/java/org/apache/hudi/BaseHoodieTableFileIndexTest.java index a6edb8c64a88d..285e7698c5f51 100644 --- a/hudi-common/src/test/java/org/apache/hudi/BaseHoodieTableFileIndexTest.java +++ b/hudi-common/src/test/java/org/apache/hudi/BaseHoodieTableFileIndexTest.java @@ -18,15 +18,25 @@ package org.apache.hudi; +import org.apache.hudi.BaseHoodieTableFileIndex.PartitionPath; import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; public class BaseHoodieTableFileIndexTest { @@ -58,4 +68,63 @@ public void testGetMetadataConfigReturnsFieldValue() throws Exception { assertEquals(true, result.isBloomFilterIndexEnabled(), "Bloom filter index should be enabled"); assertEquals(true, result.isColumnStatsIndexEnabled(), "Column stats index should be enabled"); } + + /** + * Regression test for the empty-partition NPE that surfaces in {@code getInputFileSlices} + * when the {@code hoodie.datasource.read.file.index.list.file.statuses.using.ro.path.filter} + * code path is exercised on a COW (or READ_OPTIMIZED) table that contains a partition + * holding zero base files. + * + *

    Before the fix, {@link BaseHoodieTableFileIndex#generatePartitionFileSlicesPostROTablePathFilter} + * built its result map by iterating over the file list, so a partition with no files received + * no entry. The downstream {@code Collectors.toMap(identity, p -> cache.get(p))} in + * {@code getInputFileSlices} then dereferenced a null value and threw NPE inside + * {@code Collectors.uniqKeysMapAccumulator}. + * + *

    After the fix, every input partition appears in the returned map (with an empty list + * for empty partitions), preserving the contract already honored by the non-RO path + * ({@code filterFiles}). + */ + @Test + public void testGeneratePartitionFileSlicesPostROTablePathFilterIncludesEmptyPartitions() throws Exception { + BaseHoodieTableFileIndex fileIndex = mock(BaseHoodieTableFileIndex.class, + org.mockito.Mockito.CALLS_REAL_METHODS); + + StoragePath basePath = new StoragePath("/tmp/hudi_empty_partition_test"); + Field basePathField = BaseHoodieTableFileIndex.class.getDeclaredField("basePath"); + basePathField.setAccessible(true); + basePathField.set(fileIndex, basePath); + + PartitionPath partitionWithFiles = new PartitionPath("dt=2026-01-01", new Object[]{"2026-01-01"}); + PartitionPath emptyPartition = new PartitionPath("dt=2026-01-02", new Object[]{"2026-01-02"}); + PartitionPath anotherEmpty = new PartitionPath("dt=2026-01-03", new Object[]{"2026-01-03"}); + List partitions = Arrays.asList(partitionWithFiles, emptyPartition, anotherEmpty); + + StoragePathInfo file = new StoragePathInfo( + new StoragePath(basePath, "dt=2026-01-01/file-0_0-0-0_20260101000000001.parquet"), + 100L, false, (short) 1, 1024L, 0L); + List allFiles = Collections.singletonList(file); + + Method generateMethod = BaseHoodieTableFileIndex.class.getDeclaredMethod( + "generatePartitionFileSlicesPostROTablePathFilter", List.class, List.class); + generateMethod.setAccessible(true); + @SuppressWarnings("unchecked") + Map> result = + (Map>) generateMethod.invoke(fileIndex, partitions, allFiles); + + assertNotNull(result, "Result map must not be null"); + assertEquals(3, result.size(), + "Result map must contain an entry for every input partition, including empty ones"); + assertTrue(result.containsKey(partitionWithFiles)); + assertTrue(result.containsKey(emptyPartition), + "Empty partition must appear in the result so getInputFileSlices does not NPE"); + assertTrue(result.containsKey(anotherEmpty), + "Empty partition must appear in the result so getInputFileSlices does not NPE"); + assertEquals(1, result.get(partitionWithFiles).size(), + "Partition with files should retain its file slice"); + assertTrue(result.get(emptyPartition).isEmpty(), + "Empty partition's file slice list must be present and empty (not null, not missing)"); + assertTrue(result.get(anotherEmpty).isEmpty(), + "Empty partition's file slice list must be present and empty (not null, not missing)"); + } } \ No newline at end of file diff --git a/hudi-common/src/test/java/org/apache/hudi/TestReportJvmConfiguration.java b/hudi-common/src/test/java/org/apache/hudi/TestReportJvmConfiguration.java index ea3eeade1baaf..57edd95e1c65b 100644 --- a/hudi-common/src/test/java/org/apache/hudi/TestReportJvmConfiguration.java +++ b/hudi-common/src/test/java/org/apache/hudi/TestReportJvmConfiguration.java @@ -42,14 +42,14 @@ private void reportMemoryUsageWithMXBean() { MemoryUsage nonHeapMemoryUsage = memoryBean.getNonHeapMemoryUsage(); LOG.warn("Heap Memory Usage (MemoryMXBean):"); - LOG.warn(" Used: " + heapMemoryUsage.getUsed() + " bytes"); - LOG.warn(" Committed: " + heapMemoryUsage.getCommitted() + " bytes"); - LOG.warn(" Max: " + heapMemoryUsage.getMax() + " bytes"); + LOG.warn(" Used: {} bytes", heapMemoryUsage.getUsed()); + LOG.warn(" Committed: {} bytes", heapMemoryUsage.getCommitted()); + LOG.warn(" Max: {} bytes", heapMemoryUsage.getMax()); LOG.warn("Non-Heap Memory Usage (MemoryMXBean):"); - LOG.warn(" Used: " + nonHeapMemoryUsage.getUsed() + " bytes"); - LOG.warn(" Committed: " + nonHeapMemoryUsage.getCommitted() + " bytes"); - LOG.warn(" Max: " + nonHeapMemoryUsage.getMax() + " bytes"); + LOG.warn(" Used: {} bytes", nonHeapMemoryUsage.getUsed()); + LOG.warn(" Committed: {} bytes", nonHeapMemoryUsage.getCommitted()); + LOG.warn(" Max: {} bytes", nonHeapMemoryUsage.getMax()); } private void reportMemoryUsageWithRuntime() { @@ -60,8 +60,8 @@ private void reportMemoryUsageWithRuntime() { long usedMemory = totalMemory - freeMemory; LOG.warn("Memory Usage (Runtime):"); - LOG.warn(" Total Memory: " + totalMemory + " bytes"); - LOG.warn(" Free Memory: " + freeMemory + " bytes"); - LOG.warn(" Used Memory: " + usedMemory + " bytes"); + LOG.warn(" Total Memory: {} bytes", totalMemory); + LOG.warn(" Free Memory: {} bytes", freeMemory); + LOG.warn(" Used Memory: {} bytes", usedMemory); } } diff --git a/hudi-common/src/test/java/org/apache/hudi/avro/TestAvroRecordContext.java b/hudi-common/src/test/java/org/apache/hudi/avro/TestAvroRecordContext.java index b84738684b16c..56309cd815cb1 100644 --- a/hudi-common/src/test/java/org/apache/hudi/avro/TestAvroRecordContext.java +++ b/hudi-common/src/test/java/org/apache/hudi/avro/TestAvroRecordContext.java @@ -19,17 +19,44 @@ package org.apache.hudi.avro; +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.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.stream.Stream; +import static org.apache.hudi.avro.AvroRecordContext.getFieldValueFromIndexedRecord; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class TestAvroRecordContext { + private static final Schema RECORD_SCHEMA = new Schema.Parser().parse( + "{\"type\":\"record\",\"name\":\"top\",\"fields\":[" + + "{\"name\":\"id\",\"type\":\"int\"}," + + "{\"name\":\"name\",\"type\":[\"null\",\"string\"],\"default\":null}," + + "{\"name\":\"address\",\"type\":[\"null\",{\"type\":\"record\",\"name\":\"address\",\"fields\":[" + + "{\"name\":\"city\",\"type\":\"string\"}," + + "{\"name\":\"zip\",\"type\":[\"null\",\"int\"],\"default\":null}]}],\"default\":null}," + + "{\"name\":\"multi\",\"type\":[\"null\",\"string\",\"int\"],\"default\":null}]}"); + + private static final Schema MAP_AND_ARRAY_SCHEMA = new Schema.Parser().parse( + "{\"type\":\"record\",\"name\":\"complex\",\"fields\":[" + + "{\"name\":\"id\",\"type\":\"int\"}," + + "{\"name\":\"str_map\",\"type\":[\"null\",{\"type\":\"map\",\"values\":\"string\"}],\"default\":null}," + + "{\"name\":\"int_array\",\"type\":[\"null\",{\"type\":\"array\",\"items\":\"int\"}],\"default\":null}," + + "{\"name\":\"rec_map\",\"type\":[\"null\",{\"type\":\"map\",\"values\":{\"type\":\"record\"," + + "\"name\":\"inner\",\"fields\":[{\"name\":\"x\",\"type\":\"int\"}]}}],\"default\":null}]}"); + private static Stream testConvertValueToEngineType() { return Stream.of( Arguments.of(1L, 1L), @@ -44,4 +71,90 @@ void testConvertValueToEngineType(Comparable input, Comparable expected) { Comparable actual = AvroRecordContext.getFieldAccessorInstance().convertValueToEngineType(input); assertEquals(expected, actual); } + + private static GenericRecord buildRecord() { + GenericRecord address = new GenericData.Record(RECORD_SCHEMA.getField("address").schema().getTypes().get(1)); + address.put("city", new Utf8("sf")); + address.put("zip", 94105); + GenericRecord record = new GenericData.Record(RECORD_SCHEMA); + record.put("id", 1); + record.put("name", new Utf8("alice")); + record.put("address", address); + return record; + } + + @Test + void testGetFieldValueTopLevel() { + GenericRecord record = buildRecord(); + assertEquals(1, getFieldValueFromIndexedRecord(record, "id")); + assertEquals(new Utf8("alice"), getFieldValueFromIndexedRecord(record, "name")); + assertNull(getFieldValueFromIndexedRecord(record, "multi")); + assertNull(getFieldValueFromIndexedRecord(record, "missing")); + } + + @Test + void testGetFieldValueNested() { + GenericRecord record = buildRecord(); + // intermediate segment unwraps the [null, record] union + assertEquals(new Utf8("sf"), getFieldValueFromIndexedRecord(record, "address.city")); + assertEquals(94105, getFieldValueFromIndexedRecord(record, "address.zip")); + assertNull(getFieldValueFromIndexedRecord(record, "address.missing")); + assertNull(getFieldValueFromIndexedRecord(record, "missing.nested")); + } + + @Test + void testGetFieldValueErrorCases() { + GenericRecord record = buildRecord(); + // a union that is not [null, T] cannot be navigated into; instead of throwing, the value + // navigator returns null so callers (e.g. column-stats collection) degrade gracefully, + // matching HoodieAvroUtils.getNestedFieldVal + assertNull(getFieldValueFromIndexedRecord(record, "multi.sub")); + // an empty field name is still rejected up front + assertThrows(IllegalArgumentException.class, () -> getFieldValueFromIndexedRecord(record, "")); + } + + @Test + void testGetFieldValueMapAndArrayLeavesReturnNull() { + GenericRecord record = new GenericData.Record(MAP_AND_ARRAY_SCHEMA); + record.put("id", 7); + Map strMap = new HashMap<>(); + strMap.put(new Utf8("a"), new Utf8("v1")); + record.put("str_map", strMap); + record.put("int_array", Arrays.asList(3, 1, 2)); + // rec_map left null + + // top-level scalar still resolves normally + assertEquals(7, getFieldValueFromIndexedRecord(record, "id")); + + // Parquet-style synthetic accessors that traverse a MAP (".key_value.key/value") or an + // ARRAY (".list.element") cannot be resolved to a single value and must return null instead + // of throwing. Regression: these previously threw + // IllegalStateException "Cannot get field from schema type: MAP" during MOR log-append + // column-stats collection. Such nested leaves still get statistics from the base-file path. + assertNull(getFieldValueFromIndexedRecord(record, "str_map.key_value.key")); + assertNull(getFieldValueFromIndexedRecord(record, "str_map.key_value.value")); + assertNull(getFieldValueFromIndexedRecord(record, "int_array.list.element")); + // a deep path descending through a MAP into a record field also degrades to null + assertNull(getFieldValueFromIndexedRecord(record, "rec_map.key_value.value.x")); + + // and null when the complex field itself is absent/null + GenericRecord empty = new GenericData.Record(MAP_AND_ARRAY_SCHEMA); + empty.put("id", 0); + assertNull(getFieldValueFromIndexedRecord(empty, "str_map.key_value.value")); + assertNull(getFieldValueFromIndexedRecord(empty, "int_array.list.element")); + } + + @Test + void testGetFieldValueAcrossEqualSchemaInstances() { + // records from different files carry equal but distinct schema instances; both must intern to + // the same canonical wrapper and resolve identically + Schema schemaCopy = new Schema.Parser().parse(RECORD_SCHEMA.toString()); + GenericRecord record = buildRecord(); + GenericRecord recordWithCopy = new GenericData.Record(schemaCopy); + for (Schema.Field field : RECORD_SCHEMA.getFields()) { + recordWithCopy.put(field.pos(), record.get(field.pos())); + } + assertEquals(getFieldValueFromIndexedRecord(record, "id"), getFieldValueFromIndexedRecord(recordWithCopy, "id")); + assertEquals(getFieldValueFromIndexedRecord(record, "address.city"), getFieldValueFromIndexedRecord(recordWithCopy, "address.city")); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/bloom/InternalBloomFilterBenchmark.java b/hudi-common/src/test/java/org/apache/hudi/common/bloom/InternalBloomFilterBenchmark.java new file mode 100644 index 0000000000000..3c3751ee3f21e --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/bloom/InternalBloomFilterBenchmark.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.hudi.common.bloom; + +import org.junit.jupiter.api.Test; + +import java.util.Locale; +import java.util.Random; +import java.util.function.Supplier; + +/** + * Manual microbenchmark for bloom filter hot paths: key adds (the write-path cost paid per + * record by the HFile writer), membership tests, and serialization round trips. + *

    + * The class name intentionally does not match the surefire test patterns, so it never runs + * in CI. Run it explicitly with: + *

    + * mvn test -pl hudi-common -Dtest=InternalBloomFilterBenchmark -Dsurefire.failIfNoSpecifiedTests=false
    + * 
    + */ +public class InternalBloomFilterBenchmark { + + private static final int WARMUP_ROUNDS = 1; + private static final int MEASURED_ROUNDS = 3; + private static final int KEY_POOL_SIZE = 1_000_000; + private static final int MEMBERSHIP_PROBES = 100_000; + + @Test + public void benchmarkBloomFilterHotPaths() { + runScenario("SIMPLE, 1M entries, fpp 1e-3, 1M adds", + () -> BloomFilterFactory.createBloomFilter( + 1_000_000, 0.001, -1, BloomFilterTypeCode.SIMPLE.name()), + 1_000_000); + runScenario("SIMPLE, 10M entries, fpp 1e-9, 10M adds", + () -> BloomFilterFactory.createBloomFilter( + 10_000_000, 0.000000001, -1, BloomFilterTypeCode.SIMPLE.name()), + 10_000_000); + runScenario("DYNAMIC_V0, 60K entries, fpp 1e-9, max 100K, 10M adds", + () -> BloomFilterFactory.createBloomFilter( + 60_000, 0.000000001, 100_000, BloomFilterTypeCode.DYNAMIC_V0.name()), + 10_000_000); + } + + private void runScenario(String name, Supplier filterSupplier, int numAdds) { + String[] keys = generateKeys(KEY_POOL_SIZE, 42); + String[] absentKeys = generateKeys(MEMBERSHIP_PROBES, 4242); + System.out.println("== " + name + " =="); + BloomFilter filter = null; + for (int round = 0; round < WARMUP_ROUNDS + MEASURED_ROUNDS; round++) { + filter = filterSupplier.get(); + long start = System.nanoTime(); + for (int i = 0; i < numAdds; i++) { + filter.add(keys[i % KEY_POOL_SIZE]); + } + long addMs = (System.nanoTime() - start) / 1_000_000; + + start = System.nanoTime(); + int hits = 0; + for (int i = 0; i < MEMBERSHIP_PROBES; i++) { + if (filter.mightContain(keys[i])) { + hits++; + } + } + long hitMs = (System.nanoTime() - start) / 1_000_000; + + start = System.nanoTime(); + int falsePositives = 0; + for (String absentKey : absentKeys) { + if (filter.mightContain(absentKey)) { + falsePositives++; + } + } + long missMs = (System.nanoTime() - start) / 1_000_000; + + String label = round < WARMUP_ROUNDS ? "warmup" : "round" + (round - WARMUP_ROUNDS + 1); + System.out.println(String.format(Locale.ROOT, + "%s: adds(%d)=%d ms, membership hits(%d)=%d ms, misses(%d)=%d ms (hits=%d, falsePositives=%d)", + label, numAdds, addMs, MEMBERSHIP_PROBES, hitMs, absentKeys.length, missMs, hits, falsePositives)); + } + + for (int round = 0; round < WARMUP_ROUNDS + MEASURED_ROUNDS; round++) { + long start = System.nanoTime(); + String serialized = filter.serializeToString(); + long serMs = (System.nanoTime() - start) / 1_000_000; + start = System.nanoTime(); + BloomFilterFactory.fromString(serialized, filter.getBloomFilterTypeCode().name()); + long deserMs = (System.nanoTime() - start) / 1_000_000; + String label = round < WARMUP_ROUNDS ? "warmup" : "round" + (round - WARMUP_ROUNDS + 1); + System.out.println(String.format(Locale.ROOT, + "%s: serialize=%d ms, deserialize=%d ms (serialized length=%d)", + label, serMs, deserMs, serialized.length())); + } + } + + /** Generates fixed-seed 32-character hex keys, mirroring hash-based record keys. */ + private static String[] generateKeys(int count, long seed) { + Random random = new Random(seed); + String[] keys = new String[count]; + byte[] buffer = new byte[16]; + StringBuilder sb = new StringBuilder(32); + for (int i = 0; i < count; i++) { + random.nextBytes(buffer); + sb.setLength(0); + for (byte b : buffer) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + keys[i] = sb.toString(); + } + return keys; + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/bloom/TestInternalBloomFilter.java b/hudi-common/src/test/java/org/apache/hudi/common/bloom/TestInternalBloomFilter.java new file mode 100644 index 0000000000000..175fe725ba6c0 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/bloom/TestInternalBloomFilter.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.hudi.common.bloom; + +import org.apache.hudi.common.util.hash.Hash; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests {@link InternalBloomFilter}, pinning the serialized byte layout (bit {@code i} at + * byte {@code i >> 3} under mask {@code 1 << (i & 7)}, the format shared with Hadoop's + * {@code BloomFilter}) against a {@link BitSet}-based oracle and pre-captured golden strings. + */ +public class TestInternalBloomFilter { + + private static final String SIMPLE_GOLDEN = "/////wAAAAoBAAACz+cFPAf5vcr6feFygnZHoFAOwLY7/WznVO6WN9QF7fMmM1m+zXrzC9ICIvydFz8bNEUfQN/L" + + "vtxjs9bkOxNklqSWK6H6aacyEc1SNA0+iZW9Ae0xTLgQp2k6dg=="; + private static final String DYNAMIC_GOLDEN = "/////wAAAAoBAAABIAAAABQAAAA8AAAAA/////8AAAAKAQAAASBSsn9fX63/7M15X+Rmc+75/96eez/Tdme7//nv" + + "1JuP6WZX/tv/////AAAACgEAAAEg6fC+36p6fdz/Lr98Ppl+/QvlV2rfp5+9/Pm358cj1x/f/Hes/////wAAAAoB" + + "AAABINvt/Ha3Ped32/7//3Wtp+3rLH2o/08eqTXZa/u/j77vpvzdvg=="; + + @Test + public void testSerializedBitsMatchBitSetOracle() throws IOException { + int[][] configs = {{63, 3}, {64, 3}, {65, 3}, {127, 5}, {128, 5}, {1000, 7}, {43133, 30}}; + for (int[] config : configs) { + int vectorSize = config[0]; + int nbHash = config[1]; + InternalBloomFilter filter = new InternalBloomFilter(vectorSize, nbHash, Hash.MURMUR_HASH); + HashFunction hashFunction = new HashFunction(vectorSize, nbHash, Hash.MURMUR_HASH); + BitSet oracle = new BitSet(vectorSize); + Random random = new Random(vectorSize); + for (int i = 0; i < 200; i++) { + Key key = randomKey(random); + filter.add(key); + for (int pos : hashFunction.hash(key)) { + oracle.set(pos); + } + assertTrue(filter.membershipTest(key)); + } + assertArrayEquals(oracleBits(vectorSize, oracle), serializedBits(filter), + "Serialized bits mismatch for vectorSize=" + vectorSize); + for (int i = 0; i < 200; i++) { + Key key = randomKey(random); + boolean oracleContains = Arrays.stream(hashFunction.hash(key)).allMatch(oracle::get); + assertEquals(oracleContains, filter.membershipTest(key), + "Membership mismatch for vectorSize=" + vectorSize); + } + } + } + + @Test + public void testWriteReadFieldsRoundTrip() throws IOException { + for (int vectorSize : new int[] {63, 64, 65, 127, 128, 1000}) { + InternalBloomFilter filter = new InternalBloomFilter(vectorSize, 3, Hash.MURMUR_HASH); + Random random = new Random(vectorSize); + Key[] keys = new Key[100]; + for (int i = 0; i < keys.length; i++) { + keys[i] = randomKey(random); + filter.add(keys[i]); + } + byte[] serialized = serialize(filter); + InternalBloomFilter deserialized = new InternalBloomFilter(); + deserialized.readFields(new DataInputStream(new ByteArrayInputStream(serialized))); + for (Key key : keys) { + assertTrue(deserialized.membershipTest(key)); + } + assertArrayEquals(serialized, serialize(deserialized), + "Round-trip bytes mismatch for vectorSize=" + vectorSize); + } + } + + @Test + public void testReadFieldsIgnoresUnusedTrailingBits() throws IOException { + int vectorSize = 61; + InternalBloomFilter filter = new InternalBloomFilter(vectorSize, 3, Hash.MURMUR_HASH); + Random random = new Random(vectorSize); + Key[] keys = new Key[50]; + for (int i = 0; i < keys.length; i++) { + keys[i] = randomKey(random); + filter.add(keys[i]); + } + byte[] serialized = serialize(filter); + byte[] mutated = Arrays.copyOf(serialized, serialized.length); + // The last byte carries bits 56..60; bits 61..63 are beyond vectorSize and must be ignored. + mutated[mutated.length - 1] |= (byte) 0xE0; + InternalBloomFilter deserialized = new InternalBloomFilter(); + deserialized.readFields(new DataInputStream(new ByteArrayInputStream(mutated))); + for (Key key : keys) { + assertTrue(deserialized.membershipTest(key)); + } + assertArrayEquals(serialized, serialize(deserialized), "Unused trailing bits must not survive a round trip"); + } + + @Test + public void testBitwiseOpsMatchBitSetOracle() throws IOException { + int vectorSize = 127; + int nbHash = 5; + HashFunction hashFunction = new HashFunction(vectorSize, nbHash, Hash.MURMUR_HASH); + Random random = new Random(42); + InternalBloomFilter first = new InternalBloomFilter(vectorSize, nbHash, Hash.MURMUR_HASH); + InternalBloomFilter second = new InternalBloomFilter(vectorSize, nbHash, Hash.MURMUR_HASH); + BitSet firstOracle = new BitSet(vectorSize); + BitSet secondOracle = new BitSet(vectorSize); + for (int i = 0; i < 100; i++) { + Key key = randomKey(random); + first.add(key); + for (int pos : hashFunction.hash(key)) { + firstOracle.set(pos); + } + key = randomKey(random); + second.add(key); + for (int pos : hashFunction.hash(key)) { + secondOracle.set(pos); + } + } + + InternalBloomFilter orFilter = copy(first); + orFilter.or(second); + BitSet orOracle = (BitSet) firstOracle.clone(); + orOracle.or(secondOracle); + assertArrayEquals(oracleBits(vectorSize, orOracle), serializedBits(orFilter)); + + InternalBloomFilter andFilter = copy(first); + andFilter.and(second); + BitSet andOracle = (BitSet) firstOracle.clone(); + andOracle.and(secondOracle); + assertArrayEquals(oracleBits(vectorSize, andOracle), serializedBits(andFilter)); + + InternalBloomFilter xorFilter = copy(first); + xorFilter.xor(second); + BitSet xorOracle = (BitSet) firstOracle.clone(); + xorOracle.xor(secondOracle); + assertArrayEquals(oracleBits(vectorSize, xorOracle), serializedBits(xorFilter)); + + InternalBloomFilter notFilter = copy(first); + notFilter.not(); + BitSet notOracle = (BitSet) firstOracle.clone(); + notOracle.flip(0, vectorSize); + assertArrayEquals(oracleBits(vectorSize, notOracle), serializedBits(notFilter)); + } + + @Test + public void testSerializedStringGoldens() { + BloomFilter simple = BloomFilterFactory.createBloomFilter( + 50, 0.001, -1, BloomFilterTypeCode.SIMPLE.name()); + for (int i = 0; i < 50; i++) { + simple.add(goldenKey(i)); + } + assertEquals(SIMPLE_GOLDEN, simple.serializeToString()); + BloomFilter simpleFromGolden = BloomFilterFactory.fromString(SIMPLE_GOLDEN, BloomFilterTypeCode.SIMPLE.name()); + for (int i = 0; i < 50; i++) { + assertTrue(simpleFromGolden.mightContain(goldenKey(i))); + } + + BloomFilter dynamic = BloomFilterFactory.createBloomFilter( + 20, 0.001, 60, BloomFilterTypeCode.DYNAMIC_V0.name()); + for (int i = 0; i < 100; i++) { + dynamic.add(goldenKey(i)); + } + assertEquals(DYNAMIC_GOLDEN, dynamic.serializeToString()); + BloomFilter dynamicFromGolden = BloomFilterFactory.fromString(DYNAMIC_GOLDEN, BloomFilterTypeCode.DYNAMIC_V0.name()); + for (int i = 0; i < 100; i++) { + assertTrue(dynamicFromGolden.mightContain(goldenKey(i))); + } + } + + private static String goldenKey(int i) { + return String.format("key-%03d", i); + } + + private static Key randomKey(Random random) { + byte[] keyBytes = new byte[1 + random.nextInt(40)]; + random.nextBytes(keyBytes); + return new Key(keyBytes); + } + + /** Serializes the filter and strips the 13-byte header (version, nbHash, hashType, vectorSize). */ + private static byte[] serializedBits(InternalBloomFilter filter) throws IOException { + byte[] serialized = serialize(filter); + return Arrays.copyOfRange(serialized, 13, serialized.length); + } + + private static byte[] serialize(InternalBloomFilter filter) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + filter.write(new DataOutputStream(baos)); + return baos.toByteArray(); + } + + /** Packs the oracle bits with the Hadoop BloomFilter byte layout: bit i at byte i >> 3, mask 1 << (i & 7). */ + private static byte[] oracleBits(int vectorSize, BitSet bits) { + byte[] bytes = new byte[(vectorSize + 7) / 8]; + for (int i = 0; i < vectorSize; i++) { + if (bits.get(i)) { + bytes[i >> 3] |= (byte) (1 << (i & 7)); + } + } + return bytes; + } + + private static InternalBloomFilter copy(InternalBloomFilter filter) throws IOException { + InternalBloomFilter copied = new InternalBloomFilter(); + copied.readFields(new DataInputStream(new ByteArrayInputStream(serialize(filter)))); + return copied; + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/config/TestConfigGroups.java b/hudi-common/src/test/java/org/apache/hudi/common/config/TestConfigGroups.java new file mode 100644 index 0000000000000..acbd318421ad5 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/config/TestConfigGroups.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.hudi.common.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link ConfigGroups}. + */ +class TestConfigGroups { + + @ParameterizedTest + @EnumSource(ConfigGroups.Names.class) + void testGetDescriptionReturnsNonPlaceholderForEveryName(ConfigGroups.Names name) { + String description = ConfigGroups.getDescription(name); + assertNotNull(description); + assertFalse(description.isEmpty(), "Description should not be empty for " + name); + assertFalse(description.startsWith("Please fill in the description"), + "Every enum constant should have a real description branch, missing for " + name); + } + + @Test + void testGetDescriptionSpecificValues() { + assertEquals("Basic Hudi Table configuration parameters.", + ConfigGroups.getDescription(ConfigGroups.Names.TABLE_CONFIG)); + assertEquals("Configurations specific to Amazon Web Services.", + ConfigGroups.getDescription(ConfigGroups.Names.AWS)); + assertTrue(ConfigGroups.getDescription(ConfigGroups.Names.ENVIRONMENT_CONFIG) + .contains("hudi-defaults.conf")); + } + + @Test + void testNamesCarryHumanReadableName() { + assertEquals("Hudi Table Config", ConfigGroups.Names.TABLE_CONFIG.name); + assertEquals("Metrics Configs", ConfigGroups.Names.METRICS.name); + } + + @Test + void testSubGroupNamesCarryNameAndDescription() { + assertEquals("Index Configs", ConfigGroups.SubGroupNames.INDEX.name); + assertTrue(ConfigGroups.SubGroupNames.INDEX.getDescription().contains("indexing behavior")); + assertEquals("None", ConfigGroups.SubGroupNames.NONE.name); + assertNotNull(ConfigGroups.SubGroupNames.LOCK.getDescription()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieIndexingConfig.java b/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieIndexingConfig.java new file mode 100644 index 0000000000000..2dd94773cd7bf --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieIndexingConfig.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.hudi.common.config; + +import org.apache.hudi.common.model.HoodieIndexDefinition; +import org.apache.hudi.metadata.MetadataPartitionType; + +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link HoodieIndexingConfig}. + */ +class TestHoodieIndexingConfig { + + @Test + void testBuilderSetsProvidedValues() { + HoodieIndexingConfig config = HoodieIndexingConfig.newBuilder() + .withIndexName("idx_bloom") + .withIndexType(MetadataPartitionType.BLOOM_FILTERS.name()) + .withIndexFunction("lower") + .build(); + + assertEquals("idx_bloom", config.getIndexName()); + assertEquals(MetadataPartitionType.BLOOM_FILTERS.name(), config.getIndexType()); + assertEquals("lower", config.getIndexFunction()); + } + + @Test + void testBuildAppliesDefaultIndexType() { + HoodieIndexingConfig config = HoodieIndexingConfig.newBuilder() + .withIndexName("column_stats") + .build(); + + // INDEX_TYPE defaults to COLUMN_STATS, INDEX_NAME and INDEX_FUNCTION have no defaults. + assertEquals(MetadataPartitionType.COLUMN_STATS.name(), config.getIndexType()); + assertEquals("column_stats", config.getIndexName()); + assertNull(config.getIndexFunction()); + } + + @Test + void testIsIndexUsingHelpers() { + HoodieIndexingConfig bloom = HoodieIndexingConfig.newBuilder() + .withIndexName("idx_bloom") + .withIndexType(MetadataPartitionType.BLOOM_FILTERS.name()) + .build(); + assertTrue(bloom.isIndexUsingBloomFilter()); + assertFalse(bloom.isIndexUsingColumnStats()); + assertFalse(bloom.isIndexUsingRecordIndex()); + + HoodieIndexingConfig columnStats = HoodieIndexingConfig.newBuilder() + .withIndexName("column_stats") + .withIndexType(MetadataPartitionType.COLUMN_STATS.name()) + .build(); + assertTrue(columnStats.isIndexUsingColumnStats()); + + HoodieIndexingConfig recordIndex = HoodieIndexingConfig.newBuilder() + .withIndexName("idx_record") + .withIndexType(MetadataPartitionType.RECORD_INDEX.name()) + .build(); + assertTrue(recordIndex.isIndexUsingRecordIndex()); + } + + @Test + void testFromPropertiesCarriesOverValues() { + Properties props = new Properties(); + props.setProperty(HoodieIndexingConfig.INDEX_NAME.key(), "column_stats"); + props.setProperty(HoodieIndexingConfig.INDEX_FUNCTION.key(), "identity"); + + HoodieIndexingConfig config = HoodieIndexingConfig.newBuilder() + .fromProperties(props) + .build(); + + assertEquals("column_stats", config.getIndexName()); + assertEquals("identity", config.getIndexFunction()); + } + + @Test + void testCopyProducesEqualConfig() { + HoodieIndexingConfig source = HoodieIndexingConfig.newBuilder() + .withIndexName("idx_bloom") + .withIndexType(MetadataPartitionType.BLOOM_FILTERS.name()) + .withIndexFunction("lower") + .build(); + + HoodieIndexingConfig copy = HoodieIndexingConfig.copy(source); + assertEquals(source.getIndexName(), copy.getIndexName()); + assertEquals(source.getIndexType(), copy.getIndexType()); + assertEquals(source.getIndexFunction(), copy.getIndexFunction()); + } + + @Test + void testMergeLetsSecondConfigOverride() { + HoodieIndexingConfig first = HoodieIndexingConfig.newBuilder() + .withIndexName("idx_original") + .withIndexType(MetadataPartitionType.COLUMN_STATS.name()) + .build(); + HoodieIndexingConfig second = HoodieIndexingConfig.newBuilder() + .withIndexType(MetadataPartitionType.BLOOM_FILTERS.name()) + .withIndexFunction("upper") + .build(); + + HoodieIndexingConfig merged = HoodieIndexingConfig.merge(first, second); + assertEquals("idx_original", merged.getIndexName()); + assertEquals(MetadataPartitionType.BLOOM_FILTERS.name(), merged.getIndexType()); + assertEquals("upper", merged.getIndexFunction()); + } + + @Test + void testFromIndexDefinition() { + HoodieIndexDefinition definition = HoodieIndexDefinition.newBuilder() + .withIndexName("column_stats") + .withIndexType(MetadataPartitionType.COLUMN_STATS.name()) + .withIndexFunction("identity") + .build(); + + HoodieIndexingConfig config = HoodieIndexingConfig.fromIndexDefinition(definition); + assertEquals("column_stats", config.getIndexName()); + assertEquals(MetadataPartitionType.COLUMN_STATS.name(), config.getIndexType()); + assertEquals("identity", config.getIndexFunction()); + } + + @Test + void testGenerateAndValidateChecksum() { + Properties props = new Properties(); + props.setProperty(HoodieIndexingConfig.INDEX_NAME.key(), "column_stats"); + props.setProperty(HoodieIndexingConfig.INDEX_TYPE.key(), MetadataPartitionType.COLUMN_STATS.name()); + + long checksum = HoodieIndexingConfig.generateChecksum(props); + // Checksum must be deterministic for the same inputs. + assertEquals(checksum, HoodieIndexingConfig.generateChecksum(props)); + + props.setProperty(HoodieIndexingConfig.INDEX_DEFINITION_CHECKSUM.key(), String.valueOf(checksum)); + assertTrue(HoodieIndexingConfig.validateChecksum(props)); + + props.setProperty(HoodieIndexingConfig.INDEX_DEFINITION_CHECKSUM.key(), String.valueOf(checksum + 1)); + assertFalse(HoodieIndexingConfig.validateChecksum(props)); + } + + @Test + void testGenerateChecksumRequiresIndexName() { + Properties props = new Properties(); + props.setProperty(HoodieIndexingConfig.INDEX_TYPE.key(), MetadataPartitionType.COLUMN_STATS.name()); + assertThrows(IllegalArgumentException.class, () -> HoodieIndexingConfig.generateChecksum(props)); + } + + @Test + void testDefaultExpressionIndexRangeMetadataStorageLevel() { + HoodieIndexingConfig config = HoodieIndexingConfig.newBuilder() + .withIndexName("column_stats") + .build(); + assertEquals("MEMORY_AND_DISK_SER", config.getExpressionIndexRangeMetadataStorageLevel()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieMetadataConfig.java b/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieMetadataConfig.java index b66b432abc24d..f3091ed58fe31 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieMetadataConfig.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/config/TestHoodieMetadataConfig.java @@ -269,4 +269,28 @@ void testTableServiceManagerEnabledWithEmptyActionsRejected() { .withTableServiceManagerEnabled(true) .build()); } + + @Test + void testMetricsConfig() { + // Test default value + HoodieMetadataConfig config = HoodieMetadataConfig.newBuilder().build(); + assertFalse(config.isMetricsEnabled()); + + Properties props = new Properties(); + props.put(HoodieMetadataConfig.METRICS_ENABLE.key(), true); + config = HoodieMetadataConfig.newBuilder() + .fromProperties(props) + .build(); + assertTrue(config.isMetricsEnabled()); + assertFalse(config.isDetailedMetricsEnabled()); + + props = new Properties(); + props.put(HoodieMetadataConfig.METRICS_ENABLE.key(), true); + props.put(HoodieMetadataConfig.ENABLE_DETAILED_METRICS.key(), true); + config = HoodieMetadataConfig.newBuilder() + .fromProperties(props) + .build(); + assertTrue(config.isMetricsEnabled()); + assertTrue(config.isDetailedMetricsEnabled()); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java new file mode 100644 index 0000000000000..e6215b9a19aa2 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaTypePromotion.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.common.schema; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the internal promotion matrix used by schema compatibility checks. + */ +public class TestHoodieSchemaTypePromotion { + + @Test + public void testSameTypeAlwaysPromotable() { + for (HoodieSchemaType type : HoodieSchemaType.values()) { + assertTrue(HoodieSchemaTypePromotion.canPromote(type, type), + "same type should promote to itself: " + type); + } + } + + @Test + public void testIntWidening() { + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG, HoodieSchemaType.INT)); + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.FLOAT, HoodieSchemaType.INT)); + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.DOUBLE, HoodieSchemaType.INT)); + } + + @Test + public void testLongWidening() { + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.FLOAT, HoodieSchemaType.LONG)); + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.DOUBLE, HoodieSchemaType.LONG)); + // LONG cannot read a wider reader-narrows case + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG, HoodieSchemaType.FLOAT)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG, HoodieSchemaType.DOUBLE)); + } + + @Test + public void testFloatWidening() { + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.DOUBLE, HoodieSchemaType.FLOAT)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.FLOAT, HoodieSchemaType.DOUBLE)); + } + + @Test + public void testNarrowingNotAllowed() { + // reader cannot narrow: long data cannot be read by an int reader, etc. + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.INT, HoodieSchemaType.LONG)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.INT, HoodieSchemaType.FLOAT)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.INT, HoodieSchemaType.DOUBLE)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG, HoodieSchemaType.DOUBLE)); + // FLOAT reader CAN read LONG writer (Avro promotion allows this despite the mantissa + // precision loss). Asserted here as canonical to guard against regressions. + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.FLOAT, HoodieSchemaType.LONG)); + } + + @Test + public void testStringBytesBidirectional() { + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.STRING, HoodieSchemaType.BYTES)); + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.BYTES, HoodieSchemaType.STRING)); + // STRING can also read numeric writer types + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.STRING, HoodieSchemaType.INT)); + assertTrue(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.STRING, HoodieSchemaType.DOUBLE)); + // BYTES cannot read numeric types + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.BYTES, HoodieSchemaType.INT)); + } + + @Test + public void testUnrelatedTypesNotPromotable() { + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.BOOLEAN, HoodieSchemaType.INT)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.INT, HoodieSchemaType.BOOLEAN)); + assertFalse(HoodieSchemaTypePromotion.canPromote(HoodieSchemaType.LONG, HoodieSchemaType.STRING)); + } + + @Test + public void testDecimalWideningSameSizeIncreasedPrecision() { + HoodieSchema writer = fixedDecimal(8, 10, 2); + HoodieSchema reader = fixedDecimal(8, 15, 2); + assertTrue(HoodieSchemaTypePromotion.isDecimalWidening(reader, writer)); + } + + @Test + public void testDecimalWideningIdenticalIsAllowed() { + HoodieSchema writer = fixedDecimal(8, 10, 2); + HoodieSchema reader = fixedDecimal(8, 10, 2); + assertTrue(HoodieSchemaTypePromotion.isDecimalWidening(reader, writer)); + } + + @Test + public void testDecimalWideningRejectsDecreasedPrecision() { + HoodieSchema writer = fixedDecimal(8, 15, 2); + HoodieSchema reader = fixedDecimal(8, 10, 2); + assertFalse(HoodieSchemaTypePromotion.isDecimalWidening(reader, writer)); + } + + @Test + public void testDecimalWideningRejectsIncreasedScaleWithoutRoom() { + // integer digits shrink from 8 to 5, so widening is invalid + HoodieSchema writer = fixedDecimal(8, 10, 2); + HoodieSchema reader = fixedDecimal(8, 10, 5); + assertFalse(HoodieSchemaTypePromotion.isDecimalWidening(reader, writer)); + } + + @Test + public void testDecimalWideningRejectsDifferentFixedSize() { + HoodieSchema writer = fixedDecimal(8, 10, 2); + HoodieSchema reader = fixedDecimal(16, 10, 2); + assertFalse(HoodieSchemaTypePromotion.isDecimalWidening(reader, writer)); + } + + @Test + public void testDecimalWideningRejectsNonDecimal() { + HoodieSchema decimal = fixedDecimal(8, 10, 2); + HoodieSchema plainInt = HoodieSchema.create(HoodieSchemaType.INT); + assertFalse(HoodieSchemaTypePromotion.isDecimalWidening(decimal, plainInt)); + assertFalse(HoodieSchemaTypePromotion.isDecimalWidening(plainInt, decimal)); + } + + private static HoodieSchema fixedDecimal(int size, int precision, int scale) { + return HoodieSchema.createDecimal("FixedDecimal", null, null, precision, scale, size); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/checkpoint/TestCheckpointUtils.java b/hudi-common/src/test/java/org/apache/hudi/common/table/checkpoint/TestCheckpointUtils.java index a4b1be058799a..876c8a53162a6 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/checkpoint/TestCheckpointUtils.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/checkpoint/TestCheckpointUtils.java @@ -21,7 +21,6 @@ import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; @@ -31,8 +30,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import java.util.stream.Stream; @@ -56,7 +53,6 @@ public class TestCheckpointUtils { private HoodieActiveTimeline activeTimeline; private static final String CHECKPOINT_TO_RESUME = "20240101000000"; - private static final String GENERAL_SOURCE = "org.apache.hudi.utilities.sources.GeneralSource"; @BeforeEach public void setUp() { @@ -197,64 +193,10 @@ public void testConvertCheckpointWithUseTransitionTime() { assertEquals(completionTime, translatedCheckpoint.getCheckpointKey()); } - @ParameterizedTest - @CsvSource({ - // version, sourceClassName, expectedResult - // Version >= 8 with allowed sources should return true - "8, org.apache.hudi.utilities.sources.TestSource, true", - "9, org.apache.hudi.utilities.sources.AnotherSource, true", - // Version < 8 should return false regardless of source - "7, org.apache.hudi.utilities.sources.TestSource, false", - "6, org.apache.hudi.utilities.sources.AnotherSource, false", - // Disallowed sources should return false even with version >= 8 - "8, org.apache.hudi.utilities.sources.S3EventsHoodieIncrSource, false", - "8, org.apache.hudi.utilities.sources.GcsEventsHoodieIncrSource, false", - "8, org.apache.hudi.utilities.sources.MockS3EventsHoodieIncrSource, false", - "8, org.apache.hudi.utilities.sources.MockGcsEventsHoodieIncrSource, false" - }) - public void testTargetCheckpointV2(int version, String sourceClassName, boolean isV2Checkpoint) { - assertEquals(isV2Checkpoint, CheckpointUtils.buildCheckpointFromGeneralSource(sourceClassName, version, "ignored") instanceof StreamerCheckpointV2); - } - - @Test - public void testBuildCheckpointFromGeneralSource() { - // Test V2 checkpoint creation (newer table version + general source) - Checkpoint checkpoint1 = CheckpointUtils.buildCheckpointFromGeneralSource( - GENERAL_SOURCE, - HoodieTableVersion.EIGHT.versionCode(), - CHECKPOINT_TO_RESUME - ); - assertInstanceOf(StreamerCheckpointV2.class, checkpoint1); - assertEquals(CHECKPOINT_TO_RESUME, checkpoint1.getCheckpointKey()); - - // Test V1 checkpoint creation (older table version) - Checkpoint checkpoint2 = CheckpointUtils.buildCheckpointFromGeneralSource( - GENERAL_SOURCE, - HoodieTableVersion.SEVEN.versionCode(), - CHECKPOINT_TO_RESUME - ); - assertInstanceOf(StreamerCheckpointV1.class, checkpoint2); - assertEquals(CHECKPOINT_TO_RESUME, checkpoint2.getCheckpointKey()); - } - @Test - public void testBuildCheckpointFromConfigOverride() { - // Test checkpoint from config creation (newer table version + general source) - Checkpoint checkpoint1 = CheckpointUtils.buildCheckpointFromConfigOverride( - GENERAL_SOURCE, - HoodieTableVersion.EIGHT.versionCode(), - CHECKPOINT_TO_RESUME - ); - assertInstanceOf(UnresolvedStreamerCheckpointBasedOnCfg.class, checkpoint1); - assertEquals(CHECKPOINT_TO_RESUME, checkpoint1.getCheckpointKey()); - - // Test V1 checkpoint creation (older table version) - Checkpoint checkpoint2 = CheckpointUtils.buildCheckpointFromConfigOverride( - GENERAL_SOURCE, - HoodieTableVersion.SEVEN.versionCode(), - CHECKPOINT_TO_RESUME - ); - assertInstanceOf(StreamerCheckpointV1.class, checkpoint2); - assertEquals(CHECKPOINT_TO_RESUME, checkpoint2.getCheckpointKey()); + void testCreateCheckpoint() { + Checkpoint checkpoint = CheckpointUtils.createCheckpoint(CHECKPOINT_TO_RESUME); + assertInstanceOf(StreamerCheckpointV1.class, checkpoint); + assertEquals(CHECKPOINT_TO_RESUME, checkpoint.getCheckpointKey()); } } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestBufferedRecordMergerFactory.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestBufferedRecordMergerFactory.java new file mode 100644 index 0000000000000..8015d0dcd8f93 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestBufferedRecordMergerFactory.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.hudi.common.table.read; + +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.engine.HoodieReaderContext; +import org.apache.hudi.common.engine.RecordContext; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaType; +import org.apache.hudi.common.table.PartialUpdateMode; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.Pair; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TestBufferedRecordMergerFactory { + + @Test + void testSelectsMergerForEveryConfiguredMode() { + HoodieReaderContext context = mock(HoodieReaderContext.class); + when(context.getRecordContext()).thenReturn(mock(RecordContext.class)); + HoodieSchema schema = HoodieSchema.create(HoodieSchemaType.STRING); + TypedProperties props = new TypedProperties(); + Option recordMerger = Option.of(mock(HoodieRecordMerger.class)); + + assertMerger("CommitTimeRecordMerger", context, RecordMergeMode.COMMIT_TIME_ORDERING, false, + recordMerger, schema, Option.empty(), props, Option.empty()); + assertMerger("CommitTimePartialRecordMerger", context, RecordMergeMode.COMMIT_TIME_ORDERING, false, + recordMerger, schema, Option.empty(), props, Option.of(PartialUpdateMode.IGNORE_DEFAULTS)); + assertMerger("EventTimeRecordMerger", context, RecordMergeMode.EVENT_TIME_ORDERING, false, + recordMerger, schema, Option.empty(), props, Option.empty()); + assertMerger("EventTimePartialRecordMerger", context, RecordMergeMode.EVENT_TIME_ORDERING, false, + recordMerger, schema, Option.empty(), props, Option.of(PartialUpdateMode.FILL_UNAVAILABLE)); + assertMerger("PartialUpdateBufferedRecordMerger", context, RecordMergeMode.EVENT_TIME_ORDERING, true, + recordMerger, schema, Option.empty(), props, Option.empty()); + assertMerger("CustomRecordMerger", context, RecordMergeMode.CUSTOM, false, + recordMerger, schema, Option.empty(), props, Option.empty()); + assertMerger("CustomPayloadRecordMerger", context, RecordMergeMode.CUSTOM, false, + recordMerger, schema, Option.of(Pair.of("table.Payload", "incoming.Payload")), props, Option.empty()); + assertMerger("ExpressionPayloadRecordMerger", context, RecordMergeMode.CUSTOM, false, + recordMerger, schema, Option.of(Pair.of("table.Payload", "org.apache.spark.sql.hudi.command.payload.ExpressionPayload")), props, Option.empty()); + + assertEquals("CustomPayloadRecordMerger", BufferedRecordMergerFactory.create( + context, RecordMergeMode.CUSTOM, false, recordMerger, Option.of("payload.Class"), schema, props, Option.empty()) + .getClass().getSimpleName()); + } + + private static void assertMerger(String expected, + HoodieReaderContext context, + RecordMergeMode mode, + boolean partialMerging, + Option recordMerger, + HoodieSchema schema, + Option> payloadClasses, + TypedProperties props, + Option partialUpdateMode) { + assertEquals(expected, BufferedRecordMergerFactory.create( + context, mode, partialMerging, recordMerger, schema, payloadClasses, props, partialUpdateMode) + .getClass().getSimpleName()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java new file mode 100644 index 0000000000000..5646e34aaa205 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.common.table.read; + +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.engine.HoodieReaderContext; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.util.ConfigUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.storage.StoragePath; + +import org.apache.avro.SchemaBuilder; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER; +import static org.apache.hudi.common.table.HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * A table at version 9 or later persists a custom delete marker only under the + * {@code hoodie.record.merge.property.} prefix, and {@link ConfigUtils#getMergeProps} is what strips that + * prefix back to the plain {@code hoodie.payload.delete.field} / {@code .marker} keys that + * {@link DeleteContext} reads. Read-path callers hand {@link HoodieFileGroupReader} the properties as they + * come, so the reader is the only thing that can perform that merge before the schema handler is built. + */ +public class TestFileGroupReaderDeleteMarkerProps { + + private static final String DELETE_FIELD = "op"; + private static final String DELETE_VALUE = "D"; + + private static final HoodieSchema TABLE_SCHEMA = HoodieSchema.fromAvroSchema( + SchemaBuilder.record("rec").fields() + .requiredString(HoodieRecord.RECORD_KEY_METADATA_FIELD) + .requiredString("key") + .requiredLong("ts") + .requiredString(DELETE_FIELD) + .endRecord()); + + private static final HoodieSchema REQUESTED_SCHEMA = HoodieSchema.fromAvroSchema( + SchemaBuilder.record("rec").fields() + .requiredString("key") + .endRecord()); + + private static final HoodieSchema REQUESTED_SCHEMA_WITH_DELETE_FIELD = HoodieSchema.fromAvroSchema( + SchemaBuilder.record("rec").fields() + .requiredString("key") + .requiredString(DELETE_FIELD) + .endRecord()); + + private static HoodieTableConfig versionNineTableConfigWithCustomDeleteMarker() { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, String.valueOf(HoodieTableVersion.NINE.versionCode())); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_MODE, RecordMergeMode.COMMIT_TIME_ORDERING.name()); + tableConfig.setValue(RECORD_MERGE_PROPERTY_PREFIX + DELETE_KEY, DELETE_FIELD); + tableConfig.setValue(RECORD_MERGE_PROPERTY_PREFIX + DELETE_MARKER, DELETE_VALUE); + return tableConfig; + } + + /** + * The properties a query engine hands the reader: the table's own properties, in which the custom delete + * marker only exists in its prefixed form. Nothing un-prefixes them before the reader is built. + */ + private static TypedProperties readerProps(HoodieTableConfig tableConfig) { + return TypedProperties.copy(tableConfig.getProps()); + } + + /** + * Builds a file group reader over a log-file-bearing split and returns the schema handler it installed on + * the reader context. + */ + private static FileGroupReaderSchemaHandler schemaHandlerOfReader(TypedProperties properties, + HoodieTableConfig tableConfig) { + return schemaHandlerOfReader(properties, tableConfig, REQUESTED_SCHEMA); + } + + private static FileGroupReaderSchemaHandler schemaHandlerOfReader(TypedProperties properties, + HoodieTableConfig tableConfig, + HoodieSchema requestedSchema) { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class, RETURNS_DEEP_STUBS); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(metaClient.getBasePath()).thenReturn(new StoragePath("file:///tmp/hoodie_test_table")); + + AtomicReference> installedHandler = new AtomicReference<>(); + HoodieReaderContext readerContext = mock(HoodieReaderContext.class, RETURNS_DEEP_STUBS); + // The reader context reports log files so the schema handler takes the merge path; the split itself stays + // empty because the reader is only built, never iterated - no file is ever opened. + when(readerContext.getHasLogFiles()).thenReturn(true); + when(readerContext.getHasBootstrapBaseFile()).thenReturn(false); + when(readerContext.getInstantRange()).thenReturn(Option.empty()); + when(readerContext.getMergeMode()).thenReturn(RecordMergeMode.COMMIT_TIME_ORDERING); + when(readerContext.getRecordContext().supportsParquetRowIndex()).thenReturn(false); + doAnswer(invocation -> { + installedHandler.set(invocation.getArgument(0)); + return null; + }).when(readerContext).setSchemaHandler(any()); + when(readerContext.getSchemaHandler()).thenAnswer(invocation -> installedHandler.get()); + + HoodieFileGroupReader.builder() + .withReaderContext(readerContext) + .withHoodieTableMetaClient(metaClient) + .withLatestCommitTime("001") + .withDataSchema(TABLE_SCHEMA) + .withRequestedSchema(requestedSchema) + .withProps(properties) + .withLogFiles(Stream.empty()) + .withPartitionPath("") + .withStart(0L) + .withLength(Long.MAX_VALUE) + .build(); + + return installedHandler.get(); + } + + private static List requiredFieldNames(FileGroupReaderSchemaHandler handler) { + return handler.getRequiredSchema().getFields().stream().map(field -> field.name()).collect(Collectors.toList()); + } + + /** + * Sanity check on the premise: the un-prefixing lives in getMergeProps, so the properties the reader is + * handed do not carry the plain delete keys at all. + */ + @Test + public void mergePropsIsWhatUnprefixesTheDeleteMarker() { + HoodieTableConfig tableConfig = versionNineTableConfigWithCustomDeleteMarker(); + TypedProperties props = readerProps(tableConfig); + + assertNull(props.getProperty(DELETE_KEY), "reader props must not carry the unprefixed delete key"); + assertEquals(DELETE_FIELD, ConfigUtils.getMergeProps(props, tableConfig).getProperty(DELETE_KEY)); + + assertTrue(new DeleteContext(props, TABLE_SCHEMA).getCustomDeleteMarkerKeyValue().isEmpty(), + "DeleteContext built from the reader props sees no custom delete marker"); + assertTrue(new DeleteContext(ConfigUtils.getMergeProps(props, tableConfig), TABLE_SCHEMA) + .getCustomDeleteMarkerKeyValue().isPresent(), + "DeleteContext built from merged props sees the custom delete marker"); + } + + /** + * The reader must merge the table's record-merge properties in before building the schema handler. + * Otherwise the handler's DeleteContext carries no marker, and since FileGroupRecordBuffer takes its + * DeleteContext from that very handler, custom deletes stop being recognised on the whole read path. + */ + @Test + public void readerResolvesTheCustomDeleteMarkerFromPrefixedTableProps() { + HoodieTableConfig tableConfig = versionNineTableConfigWithCustomDeleteMarker(); + + FileGroupReaderSchemaHandler handler = schemaHandlerOfReader(readerProps(tableConfig), tableConfig); + + assertTrue(handler.getDeleteContext().getCustomDeleteMarkerKeyValue().isPresent(), + "the schema handler the reader installs must resolve the custom delete marker"); + assertTrue(requiredFieldNames(handler).contains(DELETE_FIELD), + "required schema must contain the custom delete column " + DELETE_FIELD); + } + + /** + * The delete column becomes a mandatory field, so it must not be appended twice when the query already + * asked for it. + */ + @Test + public void deleteColumnIsNotDuplicatedWhenAlreadyRequested() { + HoodieTableConfig tableConfig = versionNineTableConfigWithCustomDeleteMarker(); + + FileGroupReaderSchemaHandler handler = + schemaHandlerOfReader(readerProps(tableConfig), tableConfig, REQUESTED_SCHEMA_WITH_DELETE_FIELD); + + assertEquals(1, requiredFieldNames(handler).stream().filter(DELETE_FIELD::equals).count(), + "the custom delete column must appear exactly once in the required schema"); + } + + /** + * Control: a reader handed properties that already carry the plain delete keys - which is how the existing + * engine tests are configured - resolves the marker either way, which is why this defect stayed hidden. + */ + @Test + public void readerAlsoResolvesAMarkerSuppliedInPlainForm() { + HoodieTableConfig tableConfig = versionNineTableConfigWithCustomDeleteMarker(); + TypedProperties props = readerProps(tableConfig); + props.setProperty(DELETE_KEY, DELETE_FIELD); + props.setProperty(DELETE_MARKER, DELETE_VALUE); + + FileGroupReaderSchemaHandler handler = schemaHandlerOfReader(props, tableConfig); + + assertTrue(handler.getDeleteContext().getCustomDeleteMarkerKeyValue().isPresent()); + assertTrue(requiredFieldNames(handler).contains(DELETE_FIELD)); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderBase.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderBase.java index ffcda26a37968..158ef475d8431 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderBase.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderBase.java @@ -1040,15 +1040,17 @@ private HoodieFileGroupReader getHoodieFileGroupReader(StorageConfigurationnewBuilder() + return HoodieFileGroupReader.builder() .withReaderContext(getHoodieReaderContext(tablePath, schema, storageConf, metaClient)) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(metaClient.getActiveTimeline().lastInstant().get().requestedTime()) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(schema) .withRequestedSchema(schema) .withProps(props) - .withStart(start) + .withStart((long) start) .withLength(fileSlice.getTotalFileSize()) .withShouldUseRecordPosition(false) .withAllowInflightInstants(false) diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestIncrementalQueryAnalyzer.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestIncrementalQueryAnalyzer.java new file mode 100644 index 0000000000000..4ed4c34e41e0a --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestIncrementalQueryAnalyzer.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.hudi.common.table.read; + +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.log.InstantRange; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieTimeline; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TestIncrementalQueryAnalyzer { + + @Test + void testQueryContextRangeEdges() { + HoodieTimeline timeline = mock(HoodieTimeline.class); + HoodieInstant active = mock(HoodieInstant.class); + when(active.getCompletionTime()).thenReturn("20240102000000"); + IncrementalQueryAnalyzer.QueryContext earliestToLatest = IncrementalQueryAnalyzer.QueryContext.create( + null, null, Arrays.asList("001", "002"), Collections.emptyList(), Collections.singletonList(active), timeline, null); + + assertFalse(earliestToLatest.isEmpty()); + assertEquals("002", earliestToLatest.getLastInstant()); + assertTrue(earliestToLatest.isConsumingFromEarliest()); + assertTrue(earliestToLatest.isConsumingToLatest()); + assertTrue(earliestToLatest.getInstantRange().isEmpty()); + assertEquals("20240102000000", earliestToLatest.getMaxCompletionTime()); + assertEquals(Collections.singletonList(active), earliestToLatest.getInstants()); + + IncrementalQueryAnalyzer.QueryContext boundedEarliest = IncrementalQueryAnalyzer.QueryContext.create( + null, "002", Arrays.asList("001", "002"), Collections.emptyList(), Collections.emptyList(), timeline, null); + HoodieInstant latestActive = mock(HoodieInstant.class); + when(latestActive.getCompletionTime()).thenReturn("20240103000000"); + when(timeline.getInstantsAsStream()).thenReturn(Stream.of(latestActive)); + InstantRange boundedRange = boundedEarliest.getInstantRange().get(); + assertTrue(boundedEarliest.isConsumingFromEarliest()); + assertFalse(boundedEarliest.isConsumingToLatest()); + assertTrue(boundedRange.isInRange("001")); + assertTrue(boundedRange.isInRange("002")); + assertEquals("20240103000000", boundedEarliest.getMaxCompletionTime()); + assertNull(boundedEarliest.getArchivedTimeline()); + + IncrementalQueryAnalyzer.QueryContext exact = IncrementalQueryAnalyzer.QueryContext.create( + "001", "002", Arrays.asList("001", "002"), Collections.emptyList(), Collections.emptyList(), timeline, null); + assertFalse(exact.isConsumingFromEarliest()); + assertTrue(exact.getInstantRange().get().isInRange("001")); + assertFalse(exact.getInstantRange().get().isInRange("003")); + assertThrows(IllegalStateException.class, IncrementalQueryAnalyzer.QueryContext.EMPTY::getLastInstant); + } + + @Test + void testBuilderRequiresMetaClientAndRangeType() { + assertThrows(NullPointerException.class, () -> IncrementalQueryAnalyzer.builder() + .rangeType(InstantRange.RangeType.CLOSED_CLOSED) + .build()); + assertThrows(NullPointerException.class, () -> IncrementalQueryAnalyzer.builder() + .metaClient(mock(HoodieTableMetaClient.class)) + .build()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/BaseTestFileGroupRecordBuffer.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/BaseTestFileGroupRecordBuffer.java index e6ee09b3e6add..b2869df3310dc 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/BaseTestFileGroupRecordBuffer.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/BaseTestFileGroupRecordBuffer.java @@ -149,7 +149,7 @@ protected static KeyBasedFileGroupRecordBuffer buildKeyBasedFileG when(inputSplit.hasNoRecordsToMerge()).thenReturn(false); when(inputSplit.getRecordIterator()).thenReturn(fileGroupRecordBufferItrOpt.get()); ReaderParameters readerParameters = mock(ReaderParameters.class); - when(readerParameters.sortOutputs()).thenReturn(false); + when(readerParameters.isSortOutputs()).thenReturn(false); return (KeyBasedFileGroupRecordBuffer) recordBufferLoader.getRecordBuffer(readerContext, mockMetaClient.getStorage(), inputSplit, orderingFieldNames, mockMetaClient, props, readerParameters, readStats, Option.empty()).getKey(); } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestFileGroupRecordBufferLoader.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestFileGroupRecordBufferLoader.java index 51dad1c752af6..e1c810c56910c 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestFileGroupRecordBufferLoader.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestFileGroupRecordBufferLoader.java @@ -90,12 +90,12 @@ public void testDefaultFileGroupBufferRecordLoader(String fileGroupRecordBufferT } ReaderParameters readerParameters = mock(ReaderParameters.class); if (fileGroupRecordBufferType.contains("Sorted")) { - when(readerParameters.sortOutputs()).thenReturn(true); + when(readerParameters.isSortOutputs()).thenReturn(true); } if (fileGroupRecordBufferType.contains("Position")) { HoodieBaseFile baseFile = mock(HoodieBaseFile.class); when(inputSplit.getBaseFileOption()).thenReturn(Option.of(baseFile)); - when(readerParameters.useRecordPosition()).thenReturn(true); + when(readerParameters.shouldUseRecordPosition()).thenReturn(true); } Option fileGroupUpdateCallback = Option.empty(); diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java index 0d43c1d463ca7..599358c74fc89 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java @@ -56,6 +56,7 @@ import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY; import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; @@ -141,7 +142,7 @@ void readWithStreamingRecordBufferLoaderAndEventTimeOrdering() throws IOExceptio when(inputSplit.hasNoRecordsToMerge()).thenReturn(false); when(inputSplit.getRecordIterator()).thenReturn(inputRecords.iterator()); ReaderParameters readerParameters = mock(ReaderParameters.class); - when(readerParameters.sortOutputs()).thenReturn(true); + when(readerParameters.isSortOutputs()).thenReturn(true); SortedKeyBasedFileGroupRecordBuffer fileGroupRecordBuffer = (SortedKeyBasedFileGroupRecordBuffer) recordBufferLoader .getRecordBuffer(readerContext, mockMetaClient.getStorage(), inputSplit, Collections.singletonList("ts"), mockMetaClient, properties, readerParameters, readStats, Option.empty()).getKey(); @@ -188,6 +189,40 @@ void readLogFiles() throws IOException { assertEquals(1, readStats.getNumDeletes()); } + @Test + void readBaseFileAndLogFileWithBinaryKeys() throws IOException { + // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte 0xF0) in raw UTF-8 byte + // order, but AFTER it under String.compareTo (UTF-16). The base-file record carries the + // U+E000-prefixed (UTF-8-smaller, UTF-16-larger) key and the log carries the U+20000-prefixed + // (UTF-8-larger, UTF-16-smaller) key, so a correct merge must emit them in UTF-8 byte order. + String bmpPrivateUse = new String(Character.toChars(0xE000)); + String supplementary = new String(Character.toChars(0x20000)); + TestRecord asciiA = new TestRecord("a", 0); + TestRecord asciiB = new TestRecord("b", 0); + TestRecord bmpRecord = new TestRecord(bmpPrivateUse + "-base", 0); + TestRecord supplementaryRecord = new TestRecord(supplementary + "-log", 0); + + HoodieReadStats readStats = new HoodieReadStats(); + HoodieReaderContext mockReaderContext = mock(HoodieReaderContext.class, RETURNS_DEEP_STUBS); + SortedKeyBasedFileGroupRecordBuffer fileGroupRecordBuffer = buildSortedKeyBasedFileGroupRecordBuffer(mockReaderContext, readStats); + + // Base-file records must already be in UTF-8 byte order: "a" (0x61) then the U+E000 key (0xEE...). + fileGroupRecordBuffer.setBaseFileIterator(ClosableIterator.wrap(Arrays.asList(asciiA, bmpRecord).iterator())); + + // Log records are supplied shuffled; the buffer sorts them by UTF-8 bytes before merging. + HoodieDataBlock dataBlock = mock(HoodieDataBlock.class); + when(dataBlock.getSchema()).thenReturn(HoodieTestDataGenerator.HOODIE_SCHEMA); + when(dataBlock.getEngineRecordIterator(mockReaderContext)).thenReturn( + ClosableIterator.wrap(Arrays.asList(supplementaryRecord, asciiB).iterator())); + fileGroupRecordBuffer.processDataBlock(dataBlock, Option.empty()); + + List actualRecords = getActualRecordsForSortedKeyBased(fileGroupRecordBuffer); + // Expected UTF-8 byte order: "a", "b", U+E000 key, U+20000 key; nothing is dropped. + assertEquals(Arrays.asList(asciiA, asciiB, bmpRecord, supplementaryRecord), actualRecords); + // The U+E000-prefixed base record precedes the U+20000-prefixed log record (reverse of UTF-16). + assertTrue(actualRecords.indexOf(bmpRecord) < actualRecords.indexOf(supplementaryRecord)); + } + private SortedKeyBasedFileGroupRecordBuffer buildSortedKeyBasedFileGroupRecordBuffer(HoodieReaderContext mockReaderContext, HoodieReadStats readStats) { when(mockReaderContext.getSchemaHandler().getRequiredSchema()).thenReturn(HoodieTestDataGenerator.HOODIE_SCHEMA); when(mockReaderContext.getSchemaHandler().getInternalSchema()).thenReturn(InternalSchema.getEmptyInternalSchema()); diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.java b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.java new file mode 100644 index 0000000000000..cb6356aa807f1 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.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.hudi.common.table.timeline; + +import org.apache.hudi.avro.model.HoodieArchivedMetaEntry; +import org.apache.hudi.avro.model.HoodieLSMTimelineInstant; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2; +import org.apache.hudi.common.table.timeline.versioning.v2.InstantGeneratorV2; + +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.function.BooleanSupplier; + +import static org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.ACTION_ARCHIVED_META_FIELD; +import static org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.COMPLETION_TIME_ARCHIVED_META_FIELD; +import static org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.INSTANT_TIME_ARCHIVED_META_FIELD; +import static org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.METADATA_ARCHIVED_META_FIELD; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The {@code completionTime} field of {@code HoodieLSMTimelineInstant} is declared + * {@code ["null","string"]} with a null default, and carries no value for instants archived before the + * field existed. Every read of it therefore has to be null-safe; dereferencing it produced + * {@code NullPointerException: Cannot invoke "Object.toString()" because the return value of + * "GenericRecord.get(String)" is null} on a table upgraded from 0.x (HUDI-9655). + * + *

    This covers the two readers that build a completed {@link HoodieInstant} from such a record, both of + * which fall back to the instant time. + */ +class TestArchivedInstantCompletionTime { + + private static final String INSTANT_TIME = "00000001"; + + @Test + void completionTimeFallsBackToTheInstantTimeWhenAbsent() { + GenericRecord record = new GenericData.Record(HoodieLSMTimelineInstant.getClassSchema()); + // completionTime deliberately left unset, as it is for an instant archived before the field existed + + assertEquals(INSTANT_TIME, ArchivedTimelineV2.completionTimeOrInstantTime(record, INSTANT_TIME), + "An archived instant without a completion time should fall back to its instant time"); + } + + @Test + void completionTimeIsUsedWhenPresent() { + GenericRecord record = new GenericData.Record(HoodieLSMTimelineInstant.getClassSchema()); + record.put(COMPLETION_TIME_ARCHIVED_META_FIELD, "00001001"); + + assertEquals("00001001", ArchivedTimelineV2.completionTimeOrInstantTime(record, INSTANT_TIME), + "A present completion time should be used as-is"); + } + + /** + * The same field read on the way to a {@code HoodieArchivedMetaEntry}, which is the path a CLI or + * metadata-conversion caller takes rather than the query view. + */ + @Test + void createMetaWrapperFallsBackToTheInstantTimeWhenCompletionTimeIsAbsent() throws IOException { + GenericRecord record = new GenericData.Record(HoodieLSMTimelineInstant.getClassSchema()); + record.put(INSTANT_TIME_ARCHIVED_META_FIELD, INSTANT_TIME); + record.put(ACTION_ARCHIVED_META_FIELD, HoodieTimeline.COMMIT_ACTION); + record.put(METADATA_ARCHIVED_META_FIELD, ByteBuffer.wrap(new byte[0])); + // completionTime deliberately left unset + + HoodieArchivedMetaEntry entry = + MetadataConversionUtils.createMetaWrapper(mockMetaClientReturningEmptyCommitMetadata(), record); + + assertEquals(INSTANT_TIME, entry.getStateTransitionTime(), + "The archived entry should carry the instant time when the record has no completion time"); + assertEquals(INSTANT_TIME, entry.getCommitTime()); + } + + private static HoodieTableMetaClient mockMetaClientReturningEmptyCommitMetadata() throws IOException { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(tableConfig.getTableVersion()).thenReturn(HoodieTableVersion.EIGHT); + when(metaClient.getInstantGenerator()).thenReturn(new InstantGeneratorV2()); + + CommitMetadataSerDe serDe = mock(CommitMetadataSerDe.class); + when(serDe.deserialize(any(HoodieInstant.class), any(InputStream.class), + any(BooleanSupplier.class), eq(HoodieCommitMetadata.class))) + .thenReturn(new HoodieCommitMetadata()); + when(metaClient.getCommitMetadataSerDe()).thenReturn(serDe); + return metaClient; + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java index f8b51f79ff692..c01cf1cf681ff 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java @@ -20,6 +20,7 @@ package org.apache.hudi.common.table.timeline; import org.apache.hudi.common.table.timeline.versioning.common.InstantComparators; +import org.apache.hudi.common.table.timeline.versioning.v1.InstantComparatorV1; import org.apache.hudi.common.table.timeline.versioning.v2.InstantComparatorV2; import org.junit.jupiter.api.Test; @@ -30,6 +31,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; class TestInstantComparators { @Test @@ -46,6 +48,39 @@ void testCompletionTimeOrdering() { assertEquals(Arrays.asList(instant1, instant3, instant2, instant4, instant5), instants); } + @Test + void testOrderingComparatorPerTimelineVersion() { + // Completion order (001 completes at 005, 002 completes at 003) inverts requested order. + HoodieInstant instant1 = createCompletedHoodieInstant("001", "005"); + HoodieInstant instant2 = createCompletedHoodieInstant("002", "003"); + + // Timeline layout v1 orders by requested time. + List instants = Arrays.asList(instant2, instant1); + instants.sort(new InstantComparatorV1().orderingComparator()); + assertEquals(Arrays.asList(instant1, instant2), instants); + + // Timeline layout v2 orders by completion time. + instants = Arrays.asList(instant1, instant2); + instants.sort(new InstantComparatorV2().orderingComparator()); + assertEquals(Arrays.asList(instant2, instant1), instants); + } + + @Test + void testGetOrderingTimePerTimelineVersion() { + HoodieInstant completed = createCompletedHoodieInstant("001", "005"); + HoodieInstant inflight = createInflightHoodieInstant("002"); + + // Timeline layout v1 orders by requested time. + InstantComparator comparatorV1 = new InstantComparatorV1(); + assertEquals("001", comparatorV1.getOrderingTime(completed)); + assertEquals("002", comparatorV1.getOrderingTime(inflight)); + + // Timeline layout v2 orders by completion time, which an inflight instant does not have yet. + InstantComparator comparatorV2 = new InstantComparatorV2(); + assertEquals("005", comparatorV2.getOrderingTime(completed)); + assertNull(comparatorV2.getOrderingTime(inflight)); + } + private static HoodieInstant createCompletedHoodieInstant(String requestedTime, String completionTime) { return new HoodieInstant(HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, requestedTime, completionTime, InstantComparatorV2.COMPLETION_TIME_BASED_COMPARATOR); } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/testutils/minicluster/ZookeeperTestService.java b/hudi-common/src/test/java/org/apache/hudi/common/testutils/minicluster/ZookeeperTestService.java index d3e8359662420..779f4cb89ef75 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/testutils/minicluster/ZookeeperTestService.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/testutils/minicluster/ZookeeperTestService.java @@ -101,7 +101,7 @@ public ZooKeeperServer start() throws IOException, InterruptedException { // NOTE: Changed from the original, where InetSocketAddress was // originally created to bind to the wildcard IP, we now configure it. - log.info("Zookeeper force binding to: " + this.bindIP); + log.info("Zookeeper force binding to: {}", this.bindIP); standaloneServerFactory.configure(new InetSocketAddress(bindIP, clientPort), 1000); // Start up this ZK server @@ -118,7 +118,7 @@ public ZooKeeperServer start() throws IOException, InterruptedException { } started = true; - log.info("Zookeeper Minicluster service started on client port: " + clientPort); + log.info("Zookeeper Minicluster service started on client port: {}", clientPort); return zooKeeperServer; } @@ -217,7 +217,7 @@ private static boolean waitForServerUp(String hostname, int port, long timeout) } } catch (IOException e) { // ignore as this is expected - log.info("server " + hostname + ":" + port + " not up " + e); + log.info("server {}:{} not up {}", hostname, port, e.toString()); } if (System.currentTimeMillis() > start + timeout) { diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.java new file mode 100644 index 0000000000000..5dc1fab9c86d0 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.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.hudi.common.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for {@link BufferedRandomAccessFile}, the buffered wrapper used to + * back the log-file read path (via BitCaskDiskMap / LazyFileIterable). The buffer + * capacity is clamped to a 64K minimum, so the payloads here are deliberately + * larger than that to force reads and seeks that cross buffer boundaries. + * + *

    All fixtures are seeded via {@link Files#write} rather than the class's own + * write path since Hudi opens this class only in read mode ("r") in production. + */ +public class TestBufferedRandomAccessFile { + + // Larger than the internal 64K minimum buffer so that access crosses buffer boundaries. + private static final int PAYLOAD_SIZE = (1 << 16) * 3 + 517; + + private static byte[] deterministicBytes(int size) { + byte[] bytes = new byte[size]; + new Random(42).nextBytes(bytes); + return bytes; + } + + private static File seedFile(Path dir, String name, byte[] contents) throws Exception { + File file = new File(dir.toFile(), name); + Files.write(file.toPath(), contents); + return file; + } + + @Test + public void testReadFullyAcrossBufferBoundaries(@TempDir Path tempDir) throws Exception { + byte[] expected = deterministicBytes(PAYLOAD_SIZE); + File file = seedFile(tempDir, "read.bin", expected); + + byte[] readBack = new byte[PAYLOAD_SIZE]; + try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, "r")) { + // readFully loops over read(...) so partial reads at buffer boundaries are handled. + raf.readFully(readBack); + assertEquals(PAYLOAD_SIZE, raf.getFilePointer(), "Read should consume the whole file"); + assertEquals(-1, raf.read(), "Reading past EOF should return -1"); + } + assertArrayEquals(expected, readBack, "Bytes read back should match bytes written"); + } + + @Test + public void testSeekRandomAccessCrossingBoundaries(@TempDir Path tempDir) throws Exception { + byte[] expected = deterministicBytes(PAYLOAD_SIZE); + File file = seedFile(tempDir, "seek.bin", expected); + + try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, "r")) { + // Probe positions that fall in the first, second, third and last logical buffer blocks, + // out of order, so both forward and backward seeks are exercised. + int[] probes = {0, (1 << 16) + 7, (1 << 16) * 2 + 100, 5, PAYLOAD_SIZE - 1}; + for (int pos : probes) { + raf.seek(pos); + assertEquals(pos, raf.getFilePointer(), "getFilePointer should reflect the seek target"); + int actual = raf.read(); + assertEquals(expected[pos] & 0xFF, actual, "Byte at position " + pos + " should match"); + } + + // A ranged read that starts mid-buffer and spans a boundary must return the correct slice. + int start = (1 << 16) - 10; + int len = 40; + raf.seek(start); + byte[] slice = new byte[len]; + raf.readFully(slice); + byte[] expectedSlice = new byte[len]; + System.arraycopy(expected, start, expectedSlice, 0, len); + assertArrayEquals(expectedSlice, slice, "Ranged read across a buffer boundary should match"); + } + } + + @Test + public void testReadIntoOffsetAndLength(@TempDir Path tempDir) throws Exception { + byte[] expected = deterministicBytes(1024); + File file = seedFile(tempDir, "offset.bin", expected); + + try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, "r")) { + byte[] target = new byte[1024 + 8]; + // Leave a 4-byte prefix untouched and read the payload into the middle of the array. + raf.readFully(target, 4, 1024); + byte[] payload = new byte[1024]; + System.arraycopy(target, 4, payload, 0, 1024); + assertArrayEquals(expected, payload, "readFully into an offset should place bytes at that offset"); + assertEquals(0, target[0], "Bytes before the offset must remain untouched"); + assertEquals(0, target[3], "Byte just before the offset must remain untouched"); + assertEquals(0, target[1028], "Byte just after the range must remain untouched"); + assertEquals(0, target[1031], "Last byte after the range must remain untouched"); + } + } + + @Test + public void testLengthAndEofAfterFullRead(@TempDir Path tempDir) throws Exception { + byte[] expected = deterministicBytes(PAYLOAD_SIZE); + File file = seedFile(tempDir, "length.bin", expected); + + try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, "r")) { + assertEquals(PAYLOAD_SIZE, raf.length(), "length should report the on-disk size"); + raf.seek(PAYLOAD_SIZE); + assertEquals(-1, raf.read(), "Reading at EOF should return -1"); + assertEquals(PAYLOAD_SIZE, raf.getFilePointer(), "File pointer should stay at EOF"); + } + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestCloseableUtils.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestCloseableUtils.java new file mode 100644 index 0000000000000..f18339047905d --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestCloseableUtils.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.hudi.common.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +class TestCloseableUtils { + + @Test + void testCloseSuppressing() { + IOException primary = new IOException("primary"); + IOException closeError = new IOException("close"); + + CloseableUtils.closeSuppressing(() -> { + throw closeError; + }, primary); + + assertArrayEquals(new Throwable[] {closeError}, primary.getSuppressed()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestCollectionUtils.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestCollectionUtils.java index 75829f112a38c..1b9849078c85c 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/util/TestCollectionUtils.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestCollectionUtils.java @@ -19,6 +19,8 @@ package org.apache.hudi.common.util; +import org.apache.hudi.common.util.collection.Pair; + import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -28,14 +30,39 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.apache.hudi.common.util.CollectionUtils.append; import static org.apache.hudi.common.util.CollectionUtils.batches; +import static org.apache.hudi.common.util.CollectionUtils.combine; import static org.apache.hudi.common.util.CollectionUtils.containsAll; +import static org.apache.hudi.common.util.CollectionUtils.copy; +import static org.apache.hudi.common.util.CollectionUtils.createImmutableList; +import static org.apache.hudi.common.util.CollectionUtils.createImmutableMap; +import static org.apache.hudi.common.util.CollectionUtils.createImmutableSet; +import static org.apache.hudi.common.util.CollectionUtils.createSet; +import static org.apache.hudi.common.util.CollectionUtils.diff; +import static org.apache.hudi.common.util.CollectionUtils.diffSet; +import static org.apache.hudi.common.util.CollectionUtils.elementsEqual; +import static org.apache.hudi.common.util.CollectionUtils.isNullOrEmpty; +import static org.apache.hudi.common.util.CollectionUtils.nonEmpty; +import static org.apache.hudi.common.util.CollectionUtils.reduce; +import static org.apache.hudi.common.util.CollectionUtils.reverseMap; +import static org.apache.hudi.common.util.CollectionUtils.tail; +import static org.apache.hudi.common.util.CollectionUtils.toList; +import static org.apache.hudi.common.util.CollectionUtils.toStream; +import static org.apache.hudi.common.util.CollectionUtils.zipToMap; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class TestCollectionUtils { @@ -95,4 +122,147 @@ void getBatchesFromList() { assertEquals(Arrays.asList(1, 2, 3, 4, 5), intsBatches2.get(0)); assertEquals(Collections.singletonList(6), intsBatches2.get(1)); } + + @Test + void isNullOrEmptyAndNonEmptyForCollections() { + assertTrue(isNullOrEmpty((List) null)); + assertTrue(isNullOrEmpty(Collections.emptyList())); + assertFalse(isNullOrEmpty(Collections.singletonList("a"))); + assertFalse(nonEmpty((List) null)); + assertFalse(nonEmpty(Collections.emptyList())); + assertTrue(nonEmpty(Collections.singletonList("a"))); + } + + @Test + void isNullOrEmptyAndNonEmptyForMaps() { + assertTrue(isNullOrEmpty((Map) null)); + assertTrue(isNullOrEmpty(Collections.emptyMap())); + assertFalse(isNullOrEmpty(Collections.singletonMap("k", "v"))); + assertFalse(nonEmpty((Map) null)); + assertTrue(nonEmpty(Collections.singletonMap("k", "v"))); + } + + @Test + void reduceAppliesReducerSequentially() { + int sum = reduce(Arrays.asList(1, 2, 3, 4), 0, Integer::sum); + assertEquals(10, sum); + assertEquals(100, reduce(Collections.emptyList(), 100, Integer::sum)); + } + + @Test + void copyReturnsIndependentProperties() { + Properties original = new Properties(); + original.setProperty("k", "v"); + Properties copied = copy(original); + assertEquals("v", copied.getProperty("k")); + copied.setProperty("k2", "v2"); + assertFalse(original.containsKey("k2"), "Copy must not share state with the original"); + } + + @Test + void tailReturnsLastElementAndRejectsEmpty() { + assertEquals("c", tail(new String[] {"a", "b", "c"})); + assertThrows(IllegalArgumentException.class, () -> tail(new String[0])); + } + + @Test + void toStreamAndToListDrainIterator() { + List source = Arrays.asList(1, 2, 3); + assertEquals(source, toList(source.iterator())); + assertEquals(source, toStream(source.iterator()).collect(Collectors.toList())); + } + + @Test + void combineArraysAndAppendElement() { + Integer[] combined = combine(new Integer[] {1, 2}, new Integer[] {3, 4}); + assertEquals(Arrays.asList(1, 2, 3, 4), Arrays.asList(combined)); + Integer[] appended = append(new Integer[] {1, 2}, 3); + assertEquals(Arrays.asList(1, 2, 3), Arrays.asList(appended)); + } + + @Test + void combineListsAndMaps() { + assertEquals(Arrays.asList(1, 2, 3, 4), combine(Arrays.asList(1, 2), Arrays.asList(3, 4))); + + Map one = new HashMap<>(); + one.put("a", 1); + one.put("b", 2); + Map another = new HashMap<>(); + another.put("b", 20); + another.put("c", 3); + + Map overridden = combine(one, another); + assertEquals(20, overridden.get("b"), "Second map should override on key conflict"); + assertEquals(3, overridden.size()); + + Map merged = combine(one, another, Integer::sum); + assertEquals(22, merged.get("b"), "Merge function should combine overlapping values"); + assertEquals(1, merged.get("a")); + assertEquals(3, merged.get("c")); + } + + @Test + void zipToMapPairsKeysAndValues() { + Map zipped = zipToMap(Arrays.asList("a", "b"), Arrays.asList(1, 2)); + assertEquals(1, zipped.get("a")); + assertEquals(2, zipped.get("b")); + assertThrows(IllegalArgumentException.class, + () -> zipToMap(Arrays.asList("a", "b"), Collections.singletonList(1))); + } + + @Test + void diffAndDiffSetRemoveCommonElements() { + Set setDiff = diffSet(Arrays.asList(1, 2, 3), new HashSet<>(Arrays.asList(2, 3))); + assertEquals(Collections.singleton(1), setDiff); + + List listDiff = diff(Arrays.asList(1, 2, 2, 3), Collections.singletonList(3)); + assertEquals(Arrays.asList(1, 2, 2), listDiff); + } + + @Test + void elementsEqualComparesInOrder() { + assertTrue(elementsEqual(Arrays.asList(1, 2, 3).iterator(), Arrays.asList(1, 2, 3).iterator())); + assertFalse(elementsEqual(Arrays.asList(1, 2).iterator(), Arrays.asList(1, 2, 3).iterator())); + assertFalse(elementsEqual(Arrays.asList(1, 2, 3).iterator(), Arrays.asList(1, 2).iterator())); + assertFalse(elementsEqual(Arrays.asList(1, 9).iterator(), Arrays.asList(1, 2).iterator())); + } + + @Test + void createImmutableCollectionsRejectMutation() { + List list = createImmutableList("a", "b"); + assertEquals(Arrays.asList("a", "b"), list); + assertThrows(UnsupportedOperationException.class, () -> list.add("c")); + + Set set = createImmutableSet("a", "b"); + assertEquals(new HashSet<>(Arrays.asList("a", "b")), set); + assertThrows(UnsupportedOperationException.class, () -> set.add("c")); + + Map map = createImmutableMap(Pair.of("a", 1), Pair.of("b", 2)); + assertEquals(1, map.get("a")); + assertEquals(2, map.get("b")); + assertThrows(UnsupportedOperationException.class, () -> map.put("c", 3)); + } + + @Test + void createSetCollectsDistinctElements() { + assertEquals(new HashSet<>(Arrays.asList("a", "b")), createSet("a", "b", "a")); + } + + @Test + void reverseMapSwapsKeysAndValues() { + Map source = new HashMap<>(); + source.put("a", 1); + source.put("b", 2); + Map reversed = reverseMap(source); + assertEquals("a", reversed.get(1)); + assertEquals("b", reversed.get(2)); + assertThrows(UnsupportedOperationException.class, () -> reversed.put(3, "c")); + } + + @Test + void emptyPropsReturnsSharedSingleton() { + // Emptiness also guards against illegal mutation of the shared singleton elsewhere. + assertTrue(CollectionUtils.emptyProps().isEmpty()); + assertSame(CollectionUtils.emptyProps(), CollectionUtils.emptyProps()); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestConfigUtils.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestConfigUtils.java index 1c862803c5f23..d44ddf755e352 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/util/TestConfigUtils.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestConfigUtils.java @@ -24,8 +24,13 @@ import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.config.HoodieReaderConfig; import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.AWSDmsAvroPayload; import org.apache.hudi.common.model.HoodiePayloadProps; +import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; +import org.apache.hudi.common.model.debezium.DebeziumConstants; +import org.apache.hudi.common.model.debezium.PostgresDebeziumAvroPayload; import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.util.collection.ExternalSpillableMap.DiskMapType; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; @@ -41,6 +46,8 @@ import java.util.Map; import java.util.stream.Stream; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER; import static org.apache.hudi.common.table.HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -476,4 +483,78 @@ void testBuildFileGroupReaderPropertiesIncludesMetadataFileCacheConfig() { assertEquals("200000", fileGroupReaderProps.getProperty(HoodieReaderConfig.HFILE_BLOCK_CACHE_SIZE.key())); assertEquals("7", fileGroupReaderProps.getProperty(HoodieReaderConfig.HFILE_BLOCK_CACHE_TTL_MINUTES.key())); } -} \ No newline at end of file + + @Test + void testGetMergePropsPrefersPersistedTableConfigPayloadClass() { + // A payload override in the write props must not shadow the class persisted in the table config: + // the persisted Debezium payload wins, so the pre-v9 delete markers are derived from it. + HoodieTableConfig tableConfig = preV9TableConfig(PostgresDebeziumAvroPayload.class.getName()); + TypedProperties props = new TypedProperties(); + props.put("hoodie.datasource.write.payload.class", OverwriteWithLatestAvroPayload.class.getName()); + + TypedProperties merged = ConfigUtils.getMergeProps(props, tableConfig); + + assertEquals(DebeziumConstants.FLATTENED_OP_COL_NAME, merged.getString(DELETE_KEY)); + assertEquals(DebeziumConstants.DELETE_OP, merged.getString(DELETE_MARKER)); + } + + @Test + void testGetMergePropsFallsBackToWritePropsPayloadClass() { + // Pre-v9 table that never persisted a payload class: resolve it from the write props so the + // delete-marker derivation still fires. + HoodieTableConfig tableConfig = preV9TableConfig(null); + TypedProperties props = new TypedProperties(); + props.put("hoodie.datasource.write.payload.class", PostgresDebeziumAvroPayload.class.getName()); + + TypedProperties merged = ConfigUtils.getMergeProps(props, tableConfig); + + assertEquals(DebeziumConstants.FLATTENED_OP_COL_NAME, merged.getString(DELETE_KEY)); + assertEquals(DebeziumConstants.DELETE_OP, merged.getString(DELETE_MARKER)); + } + + @Test + void testGetMergePropsDerivesAwsDmsDeleteMarkers() { + HoodieTableConfig tableConfig = preV9TableConfig(AWSDmsAvroPayload.class.getName()); + + TypedProperties merged = ConfigUtils.getMergeProps(new TypedProperties(), tableConfig); + + assertEquals(AWSDmsAvroPayload.OP_FIELD, merged.getString(DELETE_KEY)); + assertEquals(AWSDmsAvroPayload.DELETE_OPERATION_VALUE, merged.getString(DELETE_MARKER)); + } + + @Test + void testGetMergePropsDerivesNoDeleteMarkersForVersionNine() { + // Version 9+ tables carry the merge configs directly, so the legacy delete markers are not + // derived even for a CDC payload. + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.getProps().put(HoodieTableConfig.VERSION.key(), String.valueOf(HoodieTableVersion.NINE.versionCode())); + tableConfig.getProps().put(HoodieTableConfig.PAYLOAD_CLASS_NAME.key(), PostgresDebeziumAvroPayload.class.getName()); + + TypedProperties merged = ConfigUtils.getMergeProps(new TypedProperties(), tableConfig); + + assertFalse(merged.containsKey(DELETE_KEY)); + assertFalse(merged.containsKey(DELETE_MARKER)); + } + + @Test + void testGetMergePropsDerivesNoDeleteMarkersForNonCdcPayload() { + // Pre-v9 table with a non-CDC payload and no override: no delete markers, props returned as-is. + HoodieTableConfig tableConfig = preV9TableConfig(OverwriteWithLatestAvroPayload.class.getName()); + TypedProperties props = new TypedProperties(); + props.put("normal.key", "val"); + + TypedProperties merged = ConfigUtils.getMergeProps(props, tableConfig); + + assertFalse(merged.containsKey(DELETE_KEY)); + assertEquals("val", merged.getString("normal.key")); + } + + private static HoodieTableConfig preV9TableConfig(String payloadClass) { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.getProps().put(HoodieTableConfig.VERSION.key(), String.valueOf(HoodieTableVersion.EIGHT.versionCode())); + if (payloadClass != null) { + tableConfig.getProps().put(HoodieTableConfig.PAYLOAD_CLASS_NAME.key(), payloadClass); + } + return tableConfig; + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestDateTimeUtils.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestDateTimeUtils.java index 0b886ffcca19d..d9de5b98be7b8 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/util/TestDateTimeUtils.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestDateTimeUtils.java @@ -21,11 +21,19 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -55,4 +63,104 @@ public void testParseDateTimeWithNull() { DateTimeUtils.parseDateTime(null); }); } + + @Test + public void testParseDateTimeParsesEpochMillisAsMillis() { + // A numeric string is treated as epoch millis, not ISO-8601. + assertEquals(Instant.ofEpochMilli(1612542030000L), DateTimeUtils.parseDateTime("1612542030000")); + } + + @Test + public void testMicrosInstantRoundTripForPositiveEpoch() { + Instant instant = Instant.ofEpochSecond(1, 2000); + assertEquals(1_000_002L, DateTimeUtils.instantToMicros(instant)); + assertEquals(instant, DateTimeUtils.microsToInstant(1_000_002L)); + } + + @Test + public void testMicrosInstantForNegativeEpochWithNanos() { + // Before the epoch with a sub-second nano component exercises the negative-seconds branch. + Instant instant = Instant.ofEpochSecond(-2, 500_000_000); + long micros = DateTimeUtils.instantToMicros(instant); + assertEquals(-1_500_000L, micros); + assertEquals(instant, DateTimeUtils.microsToInstant(micros)); + } + + @Test + public void testNanosInstantRoundTripForPositiveEpoch() { + Instant instant = Instant.ofEpochSecond(3, 456); + assertEquals(3_000_000_456L, DateTimeUtils.instantToNanos(instant)); + assertEquals(instant, DateTimeUtils.nanosToInstant(3_000_000_456L)); + } + + @Test + public void testNanosInstantForNegativeEpochWithNanos() { + Instant instant = Instant.ofEpochSecond(-2, 500_000_000); + long nanos = DateTimeUtils.instantToNanos(instant); + assertEquals(-1_500_000_000L, nanos); + assertEquals(instant, DateTimeUtils.nanosToInstant(nanos)); + } + + @Test + public void testMicrosMillisConversion() { + assertEquals(1234L, DateTimeUtils.microsToMillis(1_234_567L)); + // floorDiv rounds towards negative infinity for negative micros. + assertEquals(-158L, DateTimeUtils.microsToMillis(-157_500L)); + assertEquals(1_234_000L, DateTimeUtils.millisToMicros(1234L)); + } + + @ParameterizedTest + @CsvSource({ + "123, 123", + "'123ms', 123", + "'321 s', 321000", + "'2 min', 120000", + "'1 day', 86400000" + }) + public void testParseDurationValidLabels(String text, long expectedMillis) { + assertEquals(Duration.of(expectedMillis, ChronoUnit.MILLIS), DateTimeUtils.parseDuration(text)); + } + + @Test + public void testParseDurationDefaultsToMillisWhenUnitOmitted() { + assertEquals(Duration.of(500, ChronoUnit.MILLIS), DateTimeUtils.parseDuration("500")); + } + + @Test + public void testParseDurationRejectsUnknownUnit() { + assertThrows(IllegalArgumentException.class, () -> DateTimeUtils.parseDuration("10 fortnights")); + } + + @Test + public void testParseDurationRejectsMissingNumber() { + assertThrows(NumberFormatException.class, () -> DateTimeUtils.parseDuration("ms")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + public void testParseDurationRejectsBlank(String text) { + assertThrows(IllegalArgumentException.class, () -> DateTimeUtils.parseDuration(text)); + } + + @Test + public void testParseDurationRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> DateTimeUtils.parseDuration(null)); + } + + @Test + public void testFormatUnixTimestamp() { + // Uses the system default zone, so validate by re-parsing the formatted output rather + // than hardcoding a zone-dependent string. + long unixTimestamp = 1_612_542_030L; + String pattern = "yyyy-MM-dd HH:mm:ss"; + String formatted = DateTimeUtils.formatUnixTimestamp(unixTimestamp, pattern); + LocalDateTime parsed = LocalDateTime.parse(formatted, DateTimeFormatter.ofPattern(pattern)); + assertEquals(unixTimestamp, parsed.atZone(ZoneId.systemDefault()).toEpochSecond()); + assertDoesNotThrow(() -> DateTimeUtils.formatUnixTimestamp(unixTimestamp, "yyyy")); + } + + @Test + public void testFormatUnixTimestampRejectsEmptyFormat() { + assertThrows(IllegalArgumentException.class, () -> DateTimeUtils.formatUnixTimestamp(0L, "")); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/TestTimestampLogicalTypeClassifier.java b/hudi-common/src/test/java/org/apache/hudi/common/util/TestTimestampLogicalTypeClassifier.java new file mode 100644 index 0000000000000..1a81fa2cb338d --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/TestTimestampLogicalTypeClassifier.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.hudi.common.util; + +import org.apache.hudi.common.util.TimestampLogicalTypeClassifier.Bucket; +import org.apache.hudi.common.util.TimestampLogicalTypeClassifier.DataShape; + +import org.junit.jupiter.api.Test; + +import static org.apache.hudi.common.util.TimestampLogicalTypeClassifier.LogicalTimestampType.LOCAL_TIMESTAMP_MICROS; +import static org.apache.hudi.common.util.TimestampLogicalTypeClassifier.LogicalTimestampType.LOCAL_TIMESTAMP_MILLIS; +import static org.apache.hudi.common.util.TimestampLogicalTypeClassifier.LogicalTimestampType.NONE; +import static org.apache.hudi.common.util.TimestampLogicalTypeClassifier.LogicalTimestampType.TIMESTAMP_MICROS; +import static org.apache.hudi.common.util.TimestampLogicalTypeClassifier.LogicalTimestampType.TIMESTAMP_MILLIS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Tests {@link TimestampLogicalTypeClassifier}. + */ +public class TestTimestampLogicalTypeClassifier { + + // 2025-06-01 as millis (~13 digits) and micros (~16 digits). + private static final long MILLIS_2025 = 1748736000000L; + private static final long MICROS_2025 = 1748736000000000L; + // The year-9999 micros sentinel seen on real tables. + private static final long YEAR_9999_MICROS = 253402214400000000L; + + @Test + public void testValueShape() { + assertEquals(DataShape.MILLIS, TimestampLogicalTypeClassifier.classifyValueShape(MILLIS_2025)); + assertEquals(DataShape.MICROS, TimestampLogicalTypeClassifier.classifyValueShape(MICROS_2025)); + // Zero, negative, near-epoch, and sentinels are not judgeable. + assertEquals(DataShape.UNKNOWN, TimestampLogicalTypeClassifier.classifyValueShape(0L)); + assertEquals(DataShape.UNKNOWN, TimestampLogicalTypeClassifier.classifyValueShape(-1L)); + assertEquals(DataShape.UNKNOWN, TimestampLogicalTypeClassifier.classifyValueShape(1000L)); + assertEquals(DataShape.UNKNOWN, TimestampLogicalTypeClassifier.classifyValueShape(YEAR_9999_MICROS)); + } + + @Test + public void testReduceShape() { + assertEquals(DataShape.MICROS, TimestampLogicalTypeClassifier.reduceShape(null, DataShape.MICROS)); + assertEquals(DataShape.MICROS, TimestampLogicalTypeClassifier.reduceShape(DataShape.MICROS, DataShape.MICROS)); + // UNKNOWN samples do not pollute a settled shape. + assertEquals(DataShape.MICROS, TimestampLogicalTypeClassifier.reduceShape(DataShape.MICROS, DataShape.UNKNOWN)); + assertEquals(DataShape.MICROS, TimestampLogicalTypeClassifier.reduceShape(DataShape.UNKNOWN, DataShape.MICROS)); + // Genuinely mixed precision across files (for example a wrongly flipped table) surfaces as ambiguous. + assertEquals(DataShape.AMBIGUOUS, TimestampLogicalTypeClassifier.reduceShape(DataShape.MICROS, DataShape.MILLIS)); + } + + @Test + public void testBuckets() { + // Genuinely correct micros (the Apna case): all three signals agree. + assertEquals(Bucket.CORRECT, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MICROS, TIMESTAMP_MICROS, DataShape.MICROS)); + // Legit millis. + assertEquals(Bucket.CORRECT, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MILLIS, DataShape.MILLIS)); + // The 0.14.1 drift: label micros, values millis. + assertEquals(Bucket.LEGACY_0X_BUG, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MICROS, TIMESTAMP_MICROS, DataShape.MILLIS)); + // Symmetric inverse. + assertEquals(Bucket.LEGACY_0X_BUG, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MILLIS, DataShape.MICROS)); + // Dropped logical type: bare long, timestamp-shaped values. + assertEquals(Bucket.DROPPED_LOGICAL_TYPE, TimestampLogicalTypeClassifier.classifyBucket(NONE, NONE, DataShape.MICROS)); + // No logical type and no timestamp-shaped data: not a timestamp column at all. + assertEquals(Bucket.UNAFFECTED, TimestampLogicalTypeClassifier.classifyBucket(NONE, NONE, DataShape.UNKNOWN)); + // A labeled timestamp column with an unjudgeable value shape cannot be classified. + assertEquals(Bucket.AMBIGUOUS, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MICROS, TIMESTAMP_MICROS, DataShape.UNKNOWN)); + assertEquals(Bucket.AMBIGUOUS, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MICROS, TIMESTAMP_MICROS, DataShape.AMBIGUOUS)); + // Table and file disagree with no clean repair reading. + assertEquals(Bucket.DIVERGENT, TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MICROS, TIMESTAMP_MILLIS, DataShape.MICROS)); + } + + @Test + public void testSuggestedOverrideToken() { + assertEquals("timestamp-millis", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.LEGACY_0X_BUG, DataShape.MILLIS, false).get()); + assertEquals("timestamp-micros", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.CORRECT, DataShape.MICROS, false).get()); + assertEquals("local-timestamp-micros", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.DROPPED_LOGICAL_TYPE, DataShape.MICROS, true).get()); + assertEquals("local-timestamp-millis", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.DROPPED_LOGICAL_TYPE, DataShape.MILLIS, true).get()); + // Millis-side symmetry: DROPPED with millis data pins to timestamp-millis / local-timestamp-millis. + assertEquals("timestamp-millis", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.DROPPED_LOGICAL_TYPE, DataShape.MILLIS, false).get()); + assertEquals("timestamp-millis", + TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.CORRECT, DataShape.MILLIS, false).get()); + // Ambiguous / divergent / unaffected: no automatic suggestion. + assertFalse(TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.AMBIGUOUS, DataShape.UNKNOWN, false).isPresent()); + assertFalse(TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.UNAFFECTED, DataShape.UNKNOWN, false).isPresent()); + assertFalse(TimestampLogicalTypeClassifier.suggestedOverrideToken(Bucket.DIVERGENT, DataShape.MICROS, false).isPresent()); + } + + @Test + public void testBucketsMillisSideSymmetry() { + // Mirror the micros cases in testBuckets() with the millis side. The symmetric coverage guards + // against a future edit accidentally handling only one direction. + assertEquals(Bucket.CORRECT, + TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MILLIS, DataShape.MILLIS)); + // 0.14.1 drift on the millis side: label millis, values micros. + assertEquals(Bucket.LEGACY_0X_BUG, + TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MILLIS, DataShape.MICROS)); + // A millis-labeled column with unjudgeable value shape. + assertEquals(Bucket.AMBIGUOUS, + TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MILLIS, DataShape.UNKNOWN)); + // Table + file disagree in the reverse direction — falls through to DIVERGENT. + assertEquals(Bucket.DIVERGENT, + TimestampLogicalTypeClassifier.classifyBucket(TIMESTAMP_MILLIS, TIMESTAMP_MICROS, DataShape.MILLIS)); + // Dropped local-timestamp-millis: bare long everywhere, values millis. Covers the 0.x drop of + // local-timestamp logical types, millis side. + assertEquals(Bucket.DROPPED_LOGICAL_TYPE, + TimestampLogicalTypeClassifier.classifyBucket(NONE, NONE, DataShape.MILLIS)); + } + + @Test + public void testBucketsLocalTimestampVariants() { + // Local-timestamp variants must classify identically to their non-local counterparts — + // the bug happens against the same three signals, only the resulting override token differs. + assertEquals(Bucket.CORRECT, + TimestampLogicalTypeClassifier.classifyBucket(LOCAL_TIMESTAMP_MICROS, LOCAL_TIMESTAMP_MICROS, DataShape.MICROS)); + assertEquals(Bucket.CORRECT, + TimestampLogicalTypeClassifier.classifyBucket(LOCAL_TIMESTAMP_MILLIS, LOCAL_TIMESTAMP_MILLIS, DataShape.MILLIS)); + assertEquals(Bucket.LEGACY_0X_BUG, + TimestampLogicalTypeClassifier.classifyBucket(LOCAL_TIMESTAMP_MICROS, LOCAL_TIMESTAMP_MICROS, DataShape.MILLIS)); + assertEquals(Bucket.LEGACY_0X_BUG, + TimestampLogicalTypeClassifier.classifyBucket(LOCAL_TIMESTAMP_MILLIS, LOCAL_TIMESTAMP_MILLIS, DataShape.MICROS)); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InflaterDeflaterReuseRLIBenchmark.java b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InflaterDeflaterReuseRLIBenchmark.java new file mode 100644 index 0000000000000..8b780c6ad4e93 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InflaterDeflaterReuseRLIBenchmark.java @@ -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. + */ + +package org.apache.hudi.common.util.collection; + +/* + * Micro-benchmark for the Inflater/Deflater reuse change in BitCaskDiskMap — RLI payload variant. + * + * Same OLD-vs-NEW comparison as InflaterDeflaterReuseBenchmark, but the payload + * fed to compress/decompress is a kryo-serialized HoodieAvroRecord + * carrying a record-index entry. This mirrors what BitCaskDiskMap.compressBytes actually + * sees in production when spilling metadata-table record-index records. + * + * Each "op" compresses + decompresses ONE serialized record by default. To stress the + * codec with larger buffers, pass recordsPerOp > 1 — the benchmark packs that many + * serialized records into a single byte[] per op. + * + * Run via Maven (needs Hudi classes on the test classpath): + * + * JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_192.jdk/Contents/Home \ + * mvn -pl hudi-common -DskipTests test-compile + * JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_192.jdk/Contents/Home \ + * mvn -pl hudi-common exec:java -Dexec.classpathScope=test \ + * -Dexec.mainClass=org.apache.hudi.common.util.collection.InflaterDeflaterReuseRLIBenchmark \ + * -Dexec.args="8 100000 1" + * + * Args: [threads] [opsPerThread] [recordsPerOp] + */ + +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.util.SerializationUtils; +import org.apache.hudi.metadata.HoodieMetadataPayload; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +public class InflaterDeflaterReuseRLIBenchmark { + + private static final int DEFAULT_THREADS = 8; + private static final int DEFAULT_OPS_PER_THREAD = 100_000; + private static final int DEFAULT_RECORDS_PER_OP = 1; + private static final int DECOMPRESS_INTERMEDIATE_BUFFER_SIZE = 8192; + private static final int COMPRESS_INITIAL_BUFFER_SIZE = 1024 * 1024; + + public static void main(String[] args) throws Exception { + int threads = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_THREADS; + int opsPerThread = args.length > 1 ? Integer.parseInt(args[1]) : DEFAULT_OPS_PER_THREAD; + int recordsPerOp = args.length > 2 ? Integer.parseInt(args[2]) : DEFAULT_RECORDS_PER_OP; + + // Build a pool of pre-serialized RLI records so the timed loop only measures + // compress/decompress, not record construction + kryo serialization. + byte[][] serializedRecords = buildSerializedRLIRecords(2048); + int singleRecordSize = serializedRecords[0].length; + + System.out.println("=== Inflater/Deflater reuse benchmark — RLI payload ==="); + System.out.println("threads=" + threads + + " opsPerThread=" + opsPerThread + + " recordsPerOp=" + recordsPerOp + + " singleRecordSerializedBytes=" + singleRecordSize + + " payloadBytesPerOp=" + (singleRecordSize * recordsPerOp) + + " totalOps=" + ((long) threads * opsPerThread)); + System.out.println("java.version=" + System.getProperty("java.version") + + " vm=" + System.getProperty("java.vm.name")); + System.out.println(); + + // Warm both paths. + runScenario("warmup-old", 2, 2_000, serializedRecords, recordsPerOp, false); + runScenario("warmup-new", 2, 2_000, serializedRecords, recordsPerOp, true); + System.gc(); + Thread.sleep(200); + + for (int trial = 1; trial <= 3; trial++) { + System.out.println("--- Trial " + trial + " ---"); + if (trial % 2 == 1) { + runScenario("OLD (new Deflater/Inflater per call)", threads, opsPerThread, serializedRecords, recordsPerOp, false); + runScenario("NEW (reuse via ThreadLocal)", threads, opsPerThread, serializedRecords, recordsPerOp, true); + } else { + runScenario("NEW (reuse via ThreadLocal)", threads, opsPerThread, serializedRecords, recordsPerOp, true); + runScenario("OLD (new Deflater/Inflater per call)", threads, opsPerThread, serializedRecords, recordsPerOp, false); + } + System.out.println(); + } + } + + private static byte[][] buildSerializedRLIRecords(int count) throws IOException { + byte[][] out = new byte[count][]; + Random rnd = new Random(42); + String instantTime = "20260520120000"; // any valid yyyyMMddHHmmss; not what we're measuring + for (int i = 0; i < count; i++) { + String recordKey = "user_" + rnd.nextLong() + "_" + i; + String partition = "date=2026-05-" + String.format("%02d", 1 + (i % 28)); + String fileId = UUID.randomUUID().toString() + "-0"; + HoodieRecord rec = + HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partition, fileId, instantTime, 0); + out[i] = SerializationUtils.serialize(rec); + } + return out; + } + + /** + * Build the per-op payload by concatenating `recordsPerOp` serialized records. + * BitCaskDiskMap actually compresses one record at a time; this stitching mode is + * here only to simulate larger-buffer scenarios on demand. + */ + private static byte[] buildPayload(byte[][] pool, int recordsPerOp, int seed) { + if (recordsPerOp == 1) { + return pool[Math.floorMod(seed, pool.length)]; + } + int total = 0; + int start = Math.floorMod(seed, pool.length); + for (int i = 0; i < recordsPerOp; i++) { + total += pool[(start + i) % pool.length].length; + } + byte[] out = new byte[total]; + int pos = 0; + for (int i = 0; i < recordsPerOp; i++) { + byte[] r = pool[(start + i) % pool.length]; + System.arraycopy(r, 0, out, pos, r.length); + pos += r.length; + } + return out; + } + + private static void runScenario(String label, + int threads, + int opsPerThread, + byte[][] pool, + int recordsPerOp, + boolean reuse) throws Exception { + ExecutorService poolExec = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + long[][] perThreadLatenciesNs = new long[threads][]; + AtomicLong failures = new AtomicLong(); + + for (int t = 0; t < threads; t++) { + final int idx = t; + poolExec.submit(new Runnable() { + @Override + public void run() { + long[] latencies = new long[opsPerThread]; + ThreadCompressionContext ctx = new ThreadCompressionContext(); + try { + start.await(); + for (int i = 0; i < opsPerThread; i++) { + byte[] payload = buildPayload(pool, recordsPerOp, idx * 1_000_003 + i); + long t0 = System.nanoTime(); + byte[] compressed = reuse ? ctx.compressReuse(payload) : ctx.compressNew(payload); + byte[] decompressed = reuse ? ctx.decompressReuse(compressed) : ctx.decompressNew(compressed); + long t1 = System.nanoTime(); + latencies[i] = t1 - t0; + if (decompressed.length != payload.length) { + failures.incrementAndGet(); + } + } + perThreadLatenciesNs[idx] = latencies; + } catch (Throwable th) { + failures.incrementAndGet(); + th.printStackTrace(); + } finally { + ctx.close(); + done.countDown(); + } + } + }); + } + + long wallStart = System.nanoTime(); + start.countDown(); + done.await(); + long wallEnd = System.nanoTime(); + poolExec.shutdown(); + + long totalOps = (long) threads * opsPerThread; + long wallMs = (wallEnd - wallStart) / 1_000_000; + double opsPerSec = totalOps / ((wallEnd - wallStart) / 1e9); + + long[] all = flatten(perThreadLatenciesNs); + Arrays.sort(all); + + System.out.printf("%-42s wall=%6d ms throughput=%9.0f ops/s" + + " avg=%6.1f us p50=%6.1f us p95=%6.1f us p99=%6.1f us max=%7.1f us failures=%d%n", + label, + wallMs, + opsPerSec, + mean(all) / 1000.0, + percentile(all, 50) / 1000.0, + percentile(all, 95) / 1000.0, + percentile(all, 99) / 1000.0, + all[all.length - 1] / 1000.0, + failures.get()); + } + + /** Mirrors BitCaskDiskMap.CompressionHandler with both pre-fix and post-fix paths. */ + private static final class ThreadCompressionContext { + private final ByteArrayOutputStream compressBaos = + new ByteArrayOutputStream(COMPRESS_INITIAL_BUFFER_SIZE); + private final ByteArrayOutputStream decompressBaos = + new ByteArrayOutputStream(COMPRESS_INITIAL_BUFFER_SIZE); + private final byte[] intermediate = new byte[DECOMPRESS_INTERMEDIATE_BUFFER_SIZE]; + + private Deflater reusedDeflater; + private Inflater reusedInflater; + + byte[] compressNew(byte[] value) throws IOException { + compressBaos.reset(); + Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION); + DeflaterOutputStream dos = new DeflaterOutputStream(compressBaos, deflater); + try { + dos.write(value); + } finally { + dos.close(); + deflater.end(); + } + return compressBaos.toByteArray(); + } + + byte[] decompressNew(byte[] bytes) throws IOException { + decompressBaos.reset(); + try (InputStream in = new InflaterInputStream(new ByteArrayInputStream(bytes))) { + int len; + while ((len = in.read(intermediate)) > 0) { + decompressBaos.write(intermediate, 0, len); + } + } + return decompressBaos.toByteArray(); + } + + byte[] compressReuse(byte[] value) throws IOException { + compressBaos.reset(); + if (reusedDeflater == null) { + reusedDeflater = new Deflater(Deflater.BEST_COMPRESSION); + } + reusedDeflater.reset(); + try (DeflaterOutputStream dos = new DeflaterOutputStream(compressBaos, reusedDeflater)) { + dos.write(value); + } + return compressBaos.toByteArray(); + } + + byte[] decompressReuse(byte[] bytes) throws IOException { + decompressBaos.reset(); + if (reusedInflater == null) { + reusedInflater = new Inflater(); + } + reusedInflater.reset(); + try (InputStream in = new InflaterInputStream(new ByteArrayInputStream(bytes), reusedInflater)) { + int len; + while ((len = in.read(intermediate)) > 0) { + decompressBaos.write(intermediate, 0, len); + } + } + return decompressBaos.toByteArray(); + } + + void close() { + if (reusedDeflater != null) { + reusedDeflater.end(); + } + if (reusedInflater != null) { + reusedInflater.end(); + } + } + } + + private static long[] flatten(long[][] arrs) { + int total = 0; + for (long[] a : arrs) { + total += a.length; + } + long[] out = new long[total]; + int pos = 0; + for (long[] a : arrs) { + System.arraycopy(a, 0, out, pos, a.length); + pos += a.length; + } + return out; + } + + private static double mean(long[] sorted) { + long sum = 0; + for (long v : sorted) { + sum += v; + } + return (double) sum / sorted.length; + } + + private static long percentile(long[] sorted, int p) { + int idx = (int) Math.ceil(p / 100.0 * sorted.length) - 1; + if (idx < 0) { + idx = 0; + } + if (idx >= sorted.length) { + idx = sorted.length - 1; + } + return sorted[idx]; + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InspectRLISize.java b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InspectRLISize.java new file mode 100644 index 0000000000000..0ae90fcc7da83 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/InspectRLISize.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.hudi.common.util.collection; + +/* + * Quick utility: print the kryo-serialized size of an RLI record and its deflated size. + * Run: mvn -pl hudi-common exec:java -Dexec.classpathScope=test \ + * -Dexec.mainClass=org.apache.hudi.common.util.collection.InspectRLISize + */ + +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.util.SerializationUtils; +import org.apache.hudi.metadata.HoodieMetadataPayload; + +import java.io.ByteArrayOutputStream; +import java.util.UUID; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +public class InspectRLISize { + public static void main(String[] args) throws Exception { + for (int i = 0; i < 5; i++) { + String recordKey = "user_record_" + i; + String partition = "date=2026-05-22"; + String fileId = UUID.randomUUID().toString() + "-0"; + String instantTime = "20260522120000"; + HoodieRecord rec = + HoodieMetadataPayload.createRecordIndexUpdate(recordKey, partition, fileId, instantTime, 0); + byte[] serialized = SerializationUtils.serialize(rec); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Deflater d = new Deflater(Deflater.BEST_COMPRESSION); + try (DeflaterOutputStream dos = new DeflaterOutputStream(baos, d)) { + dos.write(serialized); + } + d.end(); + System.out.printf("record %d: kryoBytes=%d deflatedBytes=%d ratio=%.2fx%n", + i, serialized.length, baos.size(), + (double) serialized.length / baos.size()); + } + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/exception/TestExceptionUtil.java b/hudi-common/src/test/java/org/apache/hudi/exception/TestExceptionUtil.java new file mode 100644 index 0000000000000..c2244896d855d --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/exception/TestExceptionUtil.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.hudi.exception; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.apache.hudi.exception.ExceptionUtil.throwAsIOExceptionOrRuntimeException; +import static org.apache.hudi.exception.ExceptionUtil.validateErrorMsg; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestExceptionUtil { + + @Test + void testValidateErrorMsgInNestedCause() { + IOException rootException = new IOException("File not found: data.parquet"); + IllegalArgumentException middleException = new IllegalArgumentException("Invalid argument", rootException); + RuntimeException topException = new RuntimeException("Operation failed", middleException); + + // Check top level exception msg + assertTrue(validateErrorMsg(topException, "File not found")); + assertTrue(validateErrorMsg(topException, "data.parquet")); + // Check nested exception msg + assertTrue(validateErrorMsg(topException, "Invalid argument")); + assertTrue(validateErrorMsg(topException, "Operation failed")); + // Validate no exception matches + assertFalse(validateErrorMsg(topException, "Connection refused")); + } + + @Test + void testValidateErrorMsgWithEmptyMessage() { + RuntimeException exceptionWithMessage = new RuntimeException("Some error"); + assertFalse(validateErrorMsg(exceptionWithMessage, "")); + + RuntimeException exceptionWithoutMessage = new RuntimeException(); + // Empty string should not be found in any message (including null) + assertFalse(validateErrorMsg(exceptionWithoutMessage, "")); + } + + @Test + void testThrowAsIOExceptionOrRuntimeException() { + IOException ioException = new IOException("io"); + IOException thrownIOException = assertThrows(IOException.class, () -> throwAsIOExceptionOrRuntimeException(ioException)); + assertSame(ioException, thrownIOException); + + RuntimeException runtimeException = new RuntimeException("runtime"); + RuntimeException thrownRuntimeException = + assertThrows(RuntimeException.class, () -> throwAsIOExceptionOrRuntimeException(runtimeException)); + assertSame(runtimeException, thrownRuntimeException); + + Exception checkedException = new Exception("checked"); + IOException wrappedException = + assertThrows(IOException.class, () -> throwAsIOExceptionOrRuntimeException(checkedException)); + assertSame(checkedException, wrappedException.getCause()); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/expression/TestBindVisitor.java b/hudi-common/src/test/java/org/apache/hudi/expression/TestBindVisitor.java new file mode 100644 index 0000000000000..6ef4644224926 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/expression/TestBindVisitor.java @@ -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.hudi.expression; + +import org.apache.hudi.internal.schema.Types; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestBindVisitor { + + private static Types.RecordType schema() { + ArrayList fields = new ArrayList<>(1); + fields.add(Types.Field.get(0, true, "a", Types.StringType.get())); + return Types.RecordType.get(fields, "schema"); + } + + @Test + void testUnsupportedPredicateErrorNamesTheExpression() { + BindVisitor bindVisitor = new BindVisitor(schema(), true); + Predicates.StringStartsWithAny startsWithAny = + Predicates.startsWithAny(new NameReference("a"), Collections.singletonList(Literal.from("Ja"))); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> startsWithAny.accept(bindVisitor)); + + assertTrue(e.getMessage().contains("NameReference(name=a).startsWithAny(Ja)"), e::getMessage); + assertFalse(e.getMessage().contains(BindVisitor.class.getName()), e::getMessage); + assertTrue(e.getMessage().contains(" cannot be visited as predicate"), e::getMessage); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/expression/TestPredicates.java b/hudi-common/src/test/java/org/apache/hudi/expression/TestPredicates.java index e2a83872cdd49..85481fdfd7140 100644 --- a/hudi-common/src/test/java/org/apache/hudi/expression/TestPredicates.java +++ b/hudi-common/src/test/java/org/apache/hudi/expression/TestPredicates.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -29,6 +30,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class TestPredicates { + @Test + void testStringStartsWithToString() { + Predicates.StringStartsWith predicate = Predicates.startsWith(Literal.from("key"), Literal.from("k1")); + assertEquals("key.startsWith(k1)", predicate.toString()); + } + @Test void testStringStartsWithAnyWhenMatched() { Expression left = Literal.from("key2_any"); @@ -52,4 +59,18 @@ void testStringStartsWithAnyWhenNotMatched() { assertEquals(Expression.Operator.STARTS_WITH, predicate.getOperator()); assertFalse((boolean) predicate.eval(null)); } + + @Test + void testStringStartsWithAnyToString() { + Predicates.StringStartsWithAny predicate = + Predicates.startsWithAny(Literal.from("key"), Arrays.asList(Literal.from("k1"), Literal.from("k2"))); + assertEquals("key.startsWithAny(k1,k2)", predicate.toString()); + } + + @Test + void testStringStartsWithAnyToStringIsNullSafeForAbsentLeft() { + Predicates.StringStartsWithAny predicate = + Predicates.startsWithAny(null, Collections.singletonList(Literal.from("key1"))); + assertEquals("null.startsWithAny(key1)", predicate.toString()); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestAvroSchemaEvolutionUtils.java b/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestAvroSchemaEvolutionUtils.java index 662436de9d757..e33efec9e3fde 100644 --- a/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestAvroSchemaEvolutionUtils.java +++ b/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestAvroSchemaEvolutionUtils.java @@ -19,12 +19,14 @@ package org.apache.hudi.internal.schema.utils; import org.apache.hudi.avro.HoodieAvroUtils; +import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.schema.HoodieJsonProperties; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.common.testutils.SchemaTestUtil; import org.apache.hudi.exception.HoodieNullSchemaTypeException; +import org.apache.hudi.exception.SchemaCompatibilityException; import org.apache.hudi.internal.schema.InternalSchema; import org.apache.hudi.internal.schema.InternalSchemaBuilder; import org.apache.hudi.internal.schema.Type; @@ -49,6 +51,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -486,7 +489,8 @@ public void testEvolutionSchemaFromNewAvroSchema() { ); evolvedRecord = (Types.RecordType)InternalSchemaBuilder.getBuilder().refreshNewId(evolvedRecord, new AtomicInteger(0)); HoodieSchema evolvedSchema = InternalSchemaConverter.convert(evolvedRecord, "test1"); - InternalSchema result = AvroSchemaEvolutionUtils.reconcileSchema(evolvedSchema.getAvroSchema(), oldSchema, false); + InternalSchema result = AvroSchemaEvolutionUtils.reconcileSchema(evolvedSchema, oldSchema, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")); Types.RecordType checkedRecord = Types.RecordType.get( Types.Field.get(0, false, "id", Types.IntType.get()), Types.Field.get(1, true, "data", Types.StringType.get()), @@ -541,7 +545,8 @@ public void testReconcileSchema() { + "{\"name\":\"d2\",\"type\":[\"null\",{\"type\":\"int\",\"logicalType\":\"date\"}],\"default\":null}]}"); HoodieSchema simpleReconcileSchema = InternalSchemaConverter.convert(AvroSchemaEvolutionUtils - .reconcileSchema(incomingSchema.getAvroSchema(), InternalSchemaConverter.convert(schema), false), "schemaNameFallback"); + .reconcileSchema(incomingSchema, InternalSchemaConverter.convert(schema), false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")), "schemaNameFallback"); Assertions.assertEquals(simpleCheckSchema, simpleReconcileSchema); } @@ -563,8 +568,376 @@ public void testNotEvolveSchemaIfReconciledSchemaUnchanged() { InternalSchema oldInternalSchema = InternalSchemaConverter.convert(oldSchema); // set a non-default schema id for old table schema, e.g., 2. oldInternalSchema.setSchemaId(2); - InternalSchema evolvedSchema = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema.getAvroSchema(), oldInternalSchema, false); + InternalSchema evolvedSchema = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema, oldInternalSchema, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")); // the evolved schema should be the old table schema, since there is no type change at all. Assertions.assertEquals(oldInternalSchema, evolvedSchema); } + + /** + * When the incoming schema relaxes an existing required column to nullable, reconcileSchema must evolve + * that column to nullable in the result, even when makeMissingFieldsNullable is true. Previously the + * result was rebuilt from the required table and the relaxation was silently dropped, so records with + * null in that column failed the write / were quarantined. + */ + @Test + public void testReconcileSchemaRelaxesExistingColumnToNullable() { + // table: id (required int), flag (required boolean) -- same column set as the incoming schema + Types.RecordType oldRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, false, "flag", Types.BooleanType.get()) + ); + InternalSchema oldSchema = new InternalSchema(oldRecord); + // incoming: identical columns, but the source relaxed "flag" to nullable + Types.RecordType incomingRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, true, "flag", Types.BooleanType.get()) + ); + incomingRecord = (Types.RecordType) InternalSchemaBuilder.getBuilder().refreshNewId(incomingRecord, new AtomicInteger(0)); + HoodieSchema incomingSchema = InternalSchemaConverter.convert(incomingRecord, "test1"); + + InternalSchema result = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema, oldSchema, true, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")); + + Types.RecordType checkedRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, true, "flag", Types.BooleanType.get()) + ); + Assertions.assertEquals(checkedRecord, result.getRecord()); + } + + /** + * reconcileSchema must only ever relax (widen) an existing column's nullability, never tighten it: if the + * incoming schema marks a column required but the table has it nullable, the table stays nullable. + */ + @Test + public void testReconcileSchemaDoesNotTightenNullableToRequired() { + // table: id (required int), flag (nullable boolean) + Types.RecordType oldRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, true, "flag", Types.BooleanType.get()) + ); + InternalSchema oldSchema = new InternalSchema(oldRecord); + // incoming: source tightened "flag" to required -- must NOT tighten the table + Types.RecordType incomingRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, false, "flag", Types.BooleanType.get()) + ); + incomingRecord = (Types.RecordType) InternalSchemaBuilder.getBuilder().refreshNewId(incomingRecord, new AtomicInteger(0)); + HoodieSchema incomingSchema = InternalSchemaConverter.convert(incomingRecord, "test1"); + + InternalSchema result = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema, oldSchema, true, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")); + + Types.RecordType checkedRecord = Types.RecordType.get( + Types.Field.get(0, false, "id", Types.IntType.get()), + Types.Field.get(1, true, "flag", Types.BooleanType.get()) + ); + Assertions.assertEquals(checkedRecord, result.getRecord()); + } + + private static Schema tripAvro(Schema tsType) { + return Schema.createRecord("trip", null, null, false, Arrays.asList( + new Schema.Field("id", Schema.create(Schema.Type.STRING), null, null), + new Schema.Field("ts", tsType, null, null))); + } + + @Test + public void testReconcileSchemaTimestampPrecisionEvolution() { + // A timestamp precision change is rejected unless the field has an explicit override in + // hoodie.write.timestamp.logical.type.overrides. The override pins the field: an entry equal to + // the table type coerces the incoming values and keeps the table precision, while a different + // entry evolves the column. No entry throws, so an unverified micros/millis flip cannot happen. + HoodieSchema tableSchemaMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema incomingSchemaMillis = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + + // Guard: with no override, the precision change is rejected in either direction with an + // actionable error that names the column and the config to set. + Throwable rejectedMicrosToMillis = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingSchemaMillis, tableSchemaMicros, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + assertTrue(rejectedMicrosToMillis.getMessage().contains("without an explicit")); + assertTrue(rejectedMicrosToMillis.getMessage().contains(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key())); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(tableSchemaMicros, incomingSchemaMillis, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + + // Override to millis: the micros table evolves to millis (the genuine-repair case). + Schema evolvedToMillis = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchemaMillis, tableSchemaMicros, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("timestamp-millis", evolvedToMillis.getField("ts").schema().getLogicalType().getName()); + + // Override to micros with a millis source (the Apna case): the table stays micros, no flip; the + // incoming millis values are coerced to micros on write. + Schema pinnedToMicros = AvroSchemaEvolutionUtils.reconcileSchema(incomingSchemaMillis, tableSchemaMicros, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + Assertions.assertEquals("timestamp-micros", pinnedToMicros.getField("ts").schema().getLogicalType().getName()); + + // Override to micros against a millis table: the reverse evolution is permitted. + Schema evolvedToMicros = AvroSchemaEvolutionUtils.reconcileSchema(tableSchemaMicros, incomingSchemaMillis, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + Assertions.assertEquals("timestamp-micros", evolvedToMicros.getField("ts").schema().getLogicalType().getName()); + + // The same override applies to the local-timestamp variants. + HoodieSchema tableLocalMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema incomingLocalMillis = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMillis, tableLocalMicros, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + Schema reconciledLocal = AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMillis, tableLocalMicros, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("local-timestamp-millis", reconciledLocal.getField("ts").schema().getLogicalType().getName()); + + // 0.x did not recognize the local-timestamp logical types, so affected tables persisted those + // columns as bare long. The override must also allow attaching the logical type on forward-fix. + HoodieSchema tableBareLong = HoodieSchema.fromAvroSchema(tripAvro(Schema.create(Schema.Type.LONG))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMillis, tableBareLong, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + Schema repairedToLocalMillis = AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMillis, tableBareLong, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("local-timestamp-millis", repairedToLocalMillis.getField("ts").schema().getLogicalType().getName()); + + HoodieSchema incomingLocalMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + Schema repairedToLocalMicros = AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMicros, tableBareLong, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-micros")).toAvroSchema(); + Assertions.assertEquals("local-timestamp-micros", repairedToLocalMicros.getField("ts").schema().getLogicalType().getName()); + } + + @Test + public void testReconcileTimestampLogicalTypeGuardsNonReconcilePath() { + // reconcileTimestampLogicalType is the guard applied to the deduced writer schema on every path, + // including the default set.null=false path whose Avro compatibility check is logical-type-blind. + HoodieSchema tableMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema writerMillis = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + + // Guard: no override and the precision differs, so the flip is rejected instead of silently applied. + Throwable rejected = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(writerMillis, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + assertTrue(rejected.getMessage().contains("without an explicit")); + assertTrue(rejected.getMessage().contains("'ts'")); + + // Override to micros coerces the millis writer back to micros (no flip, the Apna case). + Schema coerced = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(writerMillis, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + Assertions.assertEquals("timestamp-micros", coerced.getField("ts").schema().getLogicalType().getName()); + + // Override to millis keeps the writer at millis (authorized evolution). + Schema evolved = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(writerMillis, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("timestamp-millis", evolved.getField("ts").schema().getLogicalType().getName()); + + // No precision difference: returned unchanged, no override required and no throw. + Schema unchanged = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(tableMicros, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("")).toAvroSchema(); + Assertions.assertEquals("timestamp-micros", unchanged.getField("ts").schema().getLogicalType().getName()); + } + + /** + * End-to-end value assertion on the coerce/pin path — the Apna case. Source declares + * timestamp-millis, table is timestamp-micros, override pins the field to the table's micros + * type. The reconcile flips the writer schema back to micros. When a record whose source Avro + * schema declared millis is rewritten to the (now-micros) writer schema, the long must still be + * rescaled by 1000 — not left as-is because writer == table. + * + *

    The prior boolean flag would have flipped the table to millis without touching values, + * causing the "reads as year 58466" failure. This test guards that value-level behavior directly. + */ + @Test + public void testReconcileTimestampLogicalTypeCoercesValuesOnPin() { + HoodieSchema tableMicrosSchema = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + Schema sourceMillisSchema = tripAvro(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG))); + + // Driver-plan step: apply the guard with the coerce override. The writer schema for `ts` + // should be pinned back to timestamp-micros (matching the table), not left as millis. + Schema writerSchema = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType( + HoodieSchema.fromAvroSchema(sourceMillisSchema), tableMicrosSchema, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + Assertions.assertEquals("timestamp-micros", writerSchema.getField("ts").schema().getLogicalType().getName()); + + // Executor step: an incoming record carrying the SOURCE schema (millis) is rewritten to the + // deduced WRITER schema (micros). rewriteRecordWithNewSchema must invoke the x1000 rescale so + // 2024-01-01T00:00:00Z millis (1704067200000L) becomes the equivalent micros + // (1704067200000000L) — not the same long reinterpreted, which would read as year 55965. + long millisValue = 1704067200000L; // 2024-01-01T00:00:00Z as epoch millis + long expectedMicros = 1704067200000000L; // same instant as epoch micros + GenericRecord sourceRecord = new GenericData.Record(sourceMillisSchema); + sourceRecord.put("id", "row-1"); + sourceRecord.put("ts", millisValue); + GenericRecord rewritten = HoodieAvroUtils.rewriteRecordWithNewSchema(sourceRecord, writerSchema); + Assertions.assertEquals(expectedMicros, rewritten.get("ts"), + "millis source value must be rescaled to micros when the writer schema is pinned to micros"); + + // Symmetric coverage: source declares micros, table is millis, override pins to millis. + // Rewrite must divide by 1000 (integer division). Pick a value that is exact. + HoodieSchema tableMillisSchema = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + Schema sourceMicrosSchema = tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG))); + Schema writerSchemaMillis = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType( + HoodieSchema.fromAvroSchema(sourceMicrosSchema), tableMillisSchema, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("timestamp-millis", writerSchemaMillis.getField("ts").schema().getLogicalType().getName()); + GenericRecord sourceMicros = new GenericData.Record(sourceMicrosSchema); + sourceMicros.put("id", "row-2"); + sourceMicros.put("ts", expectedMicros); + GenericRecord rewrittenMillis = HoodieAvroUtils.rewriteRecordWithNewSchema(sourceMicros, writerSchemaMillis); + Assertions.assertEquals(millisValue, rewrittenMillis.get("ts"), + "micros source value must be rescaled to millis when the writer schema is pinned to millis"); + } + + /** + * A UTC/local zone change is not a precision repair. The stored long means a different instant + * under each interpretation and no rescale can fix that, so a zone change must be rejected on + * every path and no per-field override may authorize it. + * + *

    Both entry points have to enforce it. reconcileSchema rejects via isTypeUpdateAllow, but + * reconcileTimestampLogicalType is the only guard on the default non-reconcile path, and the + * Avro reader/writer compatibility check that runs after it is logical-type-blind for two + * long-backed fields -- so if the guard skips a zone change, nothing else catches it. + */ + @Test + public void testCrossZoneTimestampChangeIsRejected() { + HoodieSchema tableMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema localMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema tableMillis = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + HoodieSchema localMillis = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMillis().addToSchema(Schema.create(Schema.Type.LONG)))); + + // No override: rejected by both entry points, in both zone directions. + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(localMicros, InternalSchemaConverter.convert(tableMicros), false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(tableMicros, InternalSchemaConverter.convert(localMicros), false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + Throwable guarded = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMicros, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + assertTrue(guarded.getMessage().contains("'ts'"), "Unexpected message: " + guarded.getMessage()); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(tableMicros, localMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + + // An override must NOT unlock a zone change, whichever zone it names. + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMicros, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-micros"))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMicros, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros"))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(localMicros, InternalSchemaConverter.convert(tableMicros), false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-micros"))); + + // A zone change that also crosses precision is still a zone change. + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMillis, tableMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-millis"))); + assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMicros, tableMillis, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""))); + + // Same-zone precision changes are unaffected: still gated by the override, not by the zone check. + Schema stillWorks = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(localMillis, localMicros, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-millis")).toAvroSchema(); + Assertions.assertEquals("local-timestamp-millis", stillWorks.getField("ts").schema().getLogicalType().getName()); + } + + @Test + void testLongToUtcTimestampGatedInBothReconcilePaths() { + // Bare long to a UTC timestamp is override-gated exactly like the local-timestamp case: rejected + // without a per-field override and applied with one, in both reconcile paths. The non-reconcile + // guard previously skipped this and let it through silently on the default write path. + HoodieSchema tableBareLong = HoodieSchema.fromAvroSchema(tripAvro(Schema.create(Schema.Type.LONG))); + HoodieSchema incomingMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + + // No override: rejected in both paths with the exact actionable error. + Map noOverride = SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""); + String expectedError = AvroSchemaEvolutionUtils.timestampPrecisionChangeError( + "ts", Types.LongType.get(), Types.TimestampType.get()).getMessage(); + SchemaCompatibilityException reconcileError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingMicros, tableBareLong, false, noOverride)); + assertEquals(expectedError, reconcileError.getMessage()); + SchemaCompatibilityException guardError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingMicros, tableBareLong, noOverride)); + assertEquals(expectedError, guardError.getMessage()); + + // With the override: the promotion is applied in both paths. + Schema viaReconcile = AvroSchemaEvolutionUtils.reconcileSchema(incomingMicros, tableBareLong, false, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + assertEquals("timestamp-micros", viaReconcile.getField("ts").schema().getLogicalType().getName()); + Schema viaGuard = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingMicros, tableBareLong, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema(); + assertEquals("timestamp-micros", viaGuard.getField("ts").schema().getLogicalType().getName()); + } + + @Test + void testLongToLocalTimestampGatedInBothReconcilePaths() { + // Bare long to local timestamp is override-gated (not forbidden): rejected without an override + // and applied with one, and the non-reconcile guard must agree with reconcileSchema. + HoodieSchema tableBareLong = HoodieSchema.fromAvroSchema(tripAvro(Schema.create(Schema.Type.LONG))); + HoodieSchema incomingLocalMicros = HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG)))); + + // No override: rejected in both paths with the exact actionable error. + Map noOverride = SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""); + String expectedError = AvroSchemaEvolutionUtils.timestampPrecisionChangeError( + "ts", Types.LongType.get(), Types.LocalTimestampMicrosType.get()).getMessage(); + SchemaCompatibilityException reconcileError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMicros, tableBareLong, false, noOverride)); + assertEquals(expectedError, reconcileError.getMessage()); + SchemaCompatibilityException guardError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingLocalMicros, tableBareLong, noOverride)); + assertEquals(expectedError, guardError.getMessage()); + Schema repaired = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingLocalMicros, tableBareLong, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-micros")).toAvroSchema(); + assertEquals("local-timestamp-micros", repaired.getField("ts").schema().getLogicalType().getName()); + } + + @Test + void testNestedLongToTimestampGated() { + // The gate resolves fully-qualified column names, so it applies to nested fields too. A nested + // long -> timestamp (UTC or local) is override-gated via the dotted-key override. + for (String token : new String[] {"timestamp-micros", "local-timestamp-millis"}) { + HoodieSchema tableNested = HoodieSchema.fromAvroSchema(nestedTrip(Schema.create(Schema.Type.LONG))); + HoodieSchema incoming = HoodieSchema.fromAvroSchema(nestedTrip(logicalLong(token))); + // No override: rejected in both paths with the exact actionable error. + Map noOverride = SchemaChangeUtils.parseTimestampLogicalTypeOverrides(""); + String expectedError = AvroSchemaEvolutionUtils.timestampPrecisionChangeError("payload.event_ts", Types.LongType.get(), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:" + token).get("field")).getMessage(); + SchemaCompatibilityException reconcileError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileSchema(incoming, tableNested, false, noOverride)); + assertEquals(expectedError, reconcileError.getMessage()); + SchemaCompatibilityException guardError = assertThrows(SchemaCompatibilityException.class, + () -> AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incoming, tableNested, noOverride)); + assertEquals(expectedError, guardError.getMessage()); + // The dotted-key override authorizes the nested promotion. + Schema repairedNested = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incoming, tableNested, + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("payload.event_ts:" + token)).toAvroSchema(); + assertEquals(token, + repairedNested.getField("payload").schema().getField("event_ts").schema().getLogicalType().getName()); + } + } + + private static Schema nestedTrip(Schema eventTsType) { + Schema payload = Schema.createRecord("payloadrec", null, null, false, Arrays.asList( + new Schema.Field("event_ts", eventTsType, null, null))); + return Schema.createRecord("trip", null, null, false, Arrays.asList( + new Schema.Field("id", Schema.create(Schema.Type.STRING), null, null), + new Schema.Field("payload", payload, null, null))); + } + + private static Schema logicalLong(String token) { + Schema longSchema = Schema.create(Schema.Type.LONG); + switch (token) { + case "timestamp-micros": + return LogicalTypes.timestampMicros().addToSchema(longSchema); + case "timestamp-millis": + return LogicalTypes.timestampMillis().addToSchema(longSchema); + case "local-timestamp-micros": + return LogicalTypes.localTimestampMicros().addToSchema(longSchema); + case "local-timestamp-millis": + return LogicalTypes.localTimestampMillis().addToSchema(longSchema); + default: + throw new IllegalArgumentException(token); + } + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestSchemaChangeUtils.java b/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestSchemaChangeUtils.java new file mode 100644 index 0000000000000..6d17f359f66eb --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/internal/schema/utils/TestSchemaChangeUtils.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.hudi.internal.schema.utils; + +import org.apache.hudi.internal.schema.Type; +import org.apache.hudi.internal.schema.Types; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link SchemaChangeUtils#parseTimestampLogicalTypeOverrides(String)}. Validation must + * happen at parse time — the config is threaded through the writer schema deduction path and a + * malformed value would otherwise surface deep inside deduceWriterSchema on the first commit. + */ +public class TestSchemaChangeUtils { + + @Test + public void parseEmptyValueYieldsEmptyMap() { + assertTrue(SchemaChangeUtils.parseTimestampLogicalTypeOverrides(null).isEmpty()); + assertTrue(SchemaChangeUtils.parseTimestampLogicalTypeOverrides("").isEmpty()); + assertTrue(SchemaChangeUtils.parseTimestampLogicalTypeOverrides(" ").isEmpty()); + } + + @Test + public void parseValidSingleEntry() { + Map overrides = SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-millis"); + assertEquals(1, overrides.size()); + assertEquals(Types.TimestampMillisType.get(), overrides.get("ts")); + } + + @Test + public void parseAllFourTokens() { + Map overrides = SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + "a:timestamp-micros,b:timestamp-millis,c:local-timestamp-micros,d:local-timestamp-millis"); + assertEquals(4, overrides.size()); + assertEquals(Types.TimestampType.get(), overrides.get("a")); + assertEquals(Types.TimestampMillisType.get(), overrides.get("b")); + assertEquals(Types.LocalTimestampMicrosType.get(), overrides.get("c")); + assertEquals(Types.LocalTimestampMillisType.get(), overrides.get("d")); + } + + @Test + public void parseIsCaseInsensitive() { + // Tokens are case-insensitive so an operator pasting "Timestamp-Millis" is not surprised. + assertEquals(Types.TimestampMillisType.get(), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:TIMESTAMP-MILLIS").get("ts")); + assertEquals(Types.LocalTimestampMicrosType.get(), + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:Local-Timestamp-Micros").get("ts")); + } + + @Test + public void parseSupportsDottedNestedFieldNames() { + // Nested field names use '.'; the parser splits on the LAST ':' so this works unchanged. + Map overrides = + SchemaChangeUtils.parseTimestampLogicalTypeOverrides("payload.event_time:timestamp-millis"); + assertEquals(1, overrides.size()); + assertEquals(Types.TimestampMillisType.get(), overrides.get("payload.event_time")); + } + + @Test + public void parseTrimmedWhitespaceAndSkipEmptySegments() { + Map overrides = SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + " a:timestamp-micros ,, b : timestamp-millis ,"); + assertEquals(2, overrides.size()); + assertEquals(Types.TimestampType.get(), overrides.get("a")); + assertEquals(Types.TimestampMillisType.get(), overrides.get("b")); + } + + @Test + public void parseRejectsMissingColon() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field_only")); + assertTrue(ex.getMessage().contains("field_only"), "message should include the offending entry"); + } + + @Test + public void parseRejectsMissingType() { + assertThrows(IllegalArgumentException.class, + () -> SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:")); + } + + @Test + public void parseRejectsMissingField() { + assertThrows(IllegalArgumentException.class, + () -> SchemaChangeUtils.parseTimestampLogicalTypeOverrides(":timestamp-micros")); + } + + @Test + public void parseRejectsUnknownToken() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:not-a-real-type")); + assertTrue(ex.getMessage().contains("not-a-real-type"), "message should include the bad token"); + } + + @Test + public void parseResultIsUnmodifiable() { + Map overrides = SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros"); + assertThrows(UnsupportedOperationException.class, + () -> overrides.put("other", Types.TimestampMillisType.get())); + } + + @Test + void gatedTimestampChangeCoversFlipsAndLongPromotions() { + // Precision flips (either direction) are gated. + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.TimestampType.get(), Types.TimestampMillisType.get())); + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LocalTimestampMicrosType.get(), Types.LocalTimestampMillisType.get())); + // Promoting a bare long to any timestamp logical type (UTC or local) is gated. + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(), Types.TimestampType.get())); + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(), Types.TimestampMillisType.get())); + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(), Types.LocalTimestampMillisType.get())); + assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(), Types.LocalTimestampMicrosType.get())); + // Unrelated promotions and identical types are not gated. + assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(), Types.StringType.get())); + assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.IntType.get(), Types.TimestampType.get())); + assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.TimestampType.get(), Types.TimestampType.get())); + } + + @Test + void typeUpdateAllowGatesLongToTimestampBehindTheOverride() { + // Promoting a bare long to any timestamp logical type is allowed only when the gate is open. + for (Type ts : new Type[] {Types.TimestampType.get(), Types.TimestampMillisType.get(), + Types.LocalTimestampMillisType.get(), Types.LocalTimestampMicrosType.get()}) { + assertTrue(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(), ts, true)); + assertFalse(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(), ts, false)); + } + // Existing long widening is unaffected by the gate. + assertTrue(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(), Types.DoubleType.get(), false)); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/metadata/TestBaseFileRecordParsingUtils.java b/hudi-common/src/test/java/org/apache/hudi/metadata/TestBaseFileRecordParsingUtils.java new file mode 100644 index 0000000000000..c5d75fd763853 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/metadata/TestBaseFileRecordParsingUtils.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.metadata; + +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.util.FileFormatUtils; +import org.apache.hudi.io.storage.HoodieIOFactory; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StoragePath; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static org.apache.hudi.metadata.BaseFileRecordParsingUtils.RecordStatus.DELETE; +import static org.apache.hudi.metadata.BaseFileRecordParsingUtils.RecordStatus.INSERT; +import static org.apache.hudi.metadata.BaseFileRecordParsingUtils.RecordStatus.UPDATE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class TestBaseFileRecordParsingUtils { + + @Test + void testRecordKeyStatusClassificationAndSecondaryIndexKeys() { + HoodieStorage storage = mock(HoodieStorage.class); + HoodieIOFactory ioFactory = mock(HoodieIOFactory.class); + FileFormatUtils fileFormatUtils = mock(FileFormatUtils.class); + when(ioFactory.getFileFormatUtils(HoodieFileFormat.PARQUET)).thenReturn(fileFormatUtils); + when(fileFormatUtils.readRowKeys(any(), any(StoragePath.class))).thenAnswer(invocation -> { + StoragePath path = invocation.getArgument(1); + return path.getName().equals("latest.parquet") + ? new HashSet<>(Arrays.asList("inserted", "updated")) + : new HashSet<>(Arrays.asList("updated", "deleted")); + }); + + try (MockedStatic ioFactoryMock = mockStatic(HoodieIOFactory.class)) { + ioFactoryMock.when(() -> HoodieIOFactory.getIOFactory(storage)).thenReturn(ioFactory); + + Map> statuses = + BaseFileRecordParsingUtils.getRecordKeyStatuses( + "/table", "partition", "latest.parquet", "previous.parquet", storage, + EnumSet.allOf(BaseFileRecordParsingUtils.RecordStatus.class)); + assertEquals(Collections.singletonList("inserted"), statuses.get(INSERT)); + assertEquals(Collections.singletonList("updated"), statuses.get(UPDATE)); + assertEquals(Collections.singletonList("deleted"), statuses.get(DELETE)); + + assertTrue(BaseFileRecordParsingUtils.getRecordKeyStatuses( + "/table", "partition", "latest.parquet", null, storage, EnumSet.of(UPDATE, DELETE)).isEmpty()); + assertEquals( + new HashSet<>(Arrays.asList("inserted", "updated")), + new HashSet<>(BaseFileRecordParsingUtils.getRecordKeyStatuses( + "/table", "partition", "latest.parquet", null, storage, EnumSet.of(INSERT)).get(INSERT))); + + HoodieWriteStat writeStat = mock(HoodieWriteStat.class); + when(writeStat.getPath()).thenReturn("partition/latest.parquet"); + when(writeStat.getPartitionPath()).thenReturn("partition"); + when(writeStat.getPrevBaseFile()).thenReturn("previous.parquet"); + List changedKeys = + BaseFileRecordParsingUtils.getRecordKeysDeletedOrUpdated("/table", writeStat, storage); + assertEquals(new HashSet<>(Arrays.asList("updated", "deleted")), new HashSet<>(changedKeys)); + } + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataDataCleanup.java b/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataDataCleanup.java index 03d013f4c586c..4ab59d65ccec6 100644 --- a/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataDataCleanup.java +++ b/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataDataCleanup.java @@ -18,23 +18,56 @@ package org.apache.hudi.metadata; +import org.apache.hudi.avro.model.HoodieMetadataRecord; +import org.apache.hudi.common.config.HoodieConfig; +import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.data.HoodiePairData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.expression.Expression; +import org.apache.hudi.common.function.SerializableFunction; +import org.apache.hudi.common.function.SerializableFunctionUnchecked; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieBaseFile; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.internal.schema.Types; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.read.HoodieFileGroupReader; +import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.io.storage.HoodieFileReaderFactory; +import org.apache.hudi.io.storage.HoodieIOFactory; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StorageConfiguration; +import org.apache.hudi.storage.StoragePathInfo; +import org.apache.avro.generic.IndexedRecord; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import java.io.IOException; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -239,4 +272,358 @@ public void testCleanupManagerPropagatesExceptions() throws NoSuchFieldException // Verify cleanup manager was called verify(mockCleanupManager).ensureDataCleanupOnException(any()); } -} \ No newline at end of file + + @Test + public void testSecondaryIndexUnsupportedVersion() { + HoodieData keys = HoodieListData.eager(Collections.singletonList("key")); + HoodieIndexVersion unsupportedVersion = mock(HoodieIndexVersion.class); + try (MockedStatic mockedUtil = mockStatic(HoodieTableMetadataUtil.class)) { + mockedUtil.when(() -> HoodieTableMetadataUtil.existingIndexVersionOrDefault(anyString(), any())) + .thenReturn(unsupportedVersion); + + when(mockMetadata.readSecondaryIndexLocationsWithKeys(keys, "secondary_index_test")).thenCallRealMethod(); + when(mockMetadata.readSecondaryIndexLocations(keys, "secondary_index_test")).thenCallRealMethod(); + when(mockMetadata.readSecondaryIndexDataTableRecordKeysWithKeys(keys, "secondary_index_test")).thenCallRealMethod(); + + assertThrows(IllegalArgumentException.class, + () -> mockMetadata.readSecondaryIndexLocationsWithKeys(keys, "secondary_index_test")); + assertThrows(IllegalArgumentException.class, + () -> mockMetadata.readSecondaryIndexLocations(keys, "secondary_index_test")); + assertThrows(IllegalArgumentException.class, + () -> mockMetadata.readSecondaryIndexDataTableRecordKeysWithKeys(keys, "secondary_index_test")); + } + } + + @Test + public void testSecondaryIndexEmptyAndMissingPartition() { + HoodieData emptyKeys = HoodieListData.eager(Collections.emptyList()); + String partitionName = "secondary_index_test"; + when(mockMetadata.readSecondaryIndexDataTableRecordKeysWithKeys(emptyKeys, partitionName)).thenCallRealMethod(); + + try (MockedStatic mockedUtil = mockStatic(HoodieTableMetadataUtil.class)) { + mockedUtil.when(() -> HoodieTableMetadataUtil.existingIndexVersionOrDefault(anyString(), any())) + .thenReturn(HoodieIndexVersion.V1); + assertTrue(mockMetadata.readSecondaryIndexDataTableRecordKeysWithKeys(emptyKeys, partitionName) + .collectAsList().isEmpty()); + + mockedUtil.when(() -> HoodieTableMetadataUtil.existingIndexVersionOrDefault(anyString(), any())) + .thenReturn(HoodieIndexVersion.V2); + assertTrue(mockMetadata.readSecondaryIndexDataTableRecordKeysWithKeys(emptyKeys, partitionName) + .collectAsList().isEmpty()); + + HoodieData keys = HoodieListData.eager(Collections.singletonList("key")); + mockedUtil.when(() -> HoodieTableMetadataUtil.existingIndexVersionOrDefault(anyString(), any())) + .thenReturn(HoodieIndexVersion.V1); + when(mockTableConfig.getMetadataPartitions()).thenReturn(Collections.emptySet()); + when(mockMetadata.readSecondaryIndexLocationsWithKeys(keys, partitionName)).thenCallRealMethod(); + assertThrows(IllegalStateException.class, + () -> mockMetadata.readSecondaryIndexLocationsWithKeys(keys, partitionName)); + } + } + + @Test + public void testEmptyRecordIndexAndMetadataTimeline() throws Exception { + Field fileSliceMapField = HoodieBackedTableMetadata.class.getDeclaredField("partitionFileSliceMap"); + fileSliceMapField.setAccessible(true); + Map> fileSliceMap = new HashMap<>(); + fileSliceMap.put(MetadataPartitionType.RECORD_INDEX.getPartitionPath(), Collections.emptyList()); + fileSliceMapField.set(mockMetadata, fileSliceMap); + + when(mockMetadata.readRecordIndexLocations( + org.mockito.ArgumentMatchers., List>>any())) + .thenCallRealMethod(); + assertTrue(mockMetadata.readRecordIndexLocations(slices -> slices).collectAsList().isEmpty()); + + when(mockMetadata.getSyncedInstantTime()).thenCallRealMethod(); + when(mockMetadata.getLatestCompactionTime()).thenCallRealMethod(); + assertFalse(mockMetadata.getSyncedInstantTime().isPresent()); + assertFalse(mockMetadata.getLatestCompactionTime().isPresent()); + } + + @Test + public void testPartitionFilterFallbackAndBucketValidation() throws Exception { + List selectedPartitions = Arrays.asList("year=2025", "year=2026"); + Expression expression = mock(Expression.class); + when(expression.accept(any())).thenReturn(expression); + when(mockMetadata.getPartitionPathWithPathPrefixes(any())).thenReturn(selectedPartitions); + when(mockMetadata.getPartitionPathWithPathPrefixUsingFilterExpression(any(), any(), any())) + .thenCallRealMethod(); + + assertEquals(selectedPartitions, mockMetadata.getPartitionPathWithPathPrefixUsingFilterExpression( + Collections.singletonList("year="), mock(Types.RecordType.class), expression)); + + Field partitionedMapField = + HoodieBackedTableMetadata.class.getDeclaredField("partitionedRLIFileSliceMap"); + partitionedMapField.setAccessible(true); + partitionedMapField.set(mockMetadata, new HashMap<>()); + when(mockMetadata.getBucketizedFileGroupsForPartitionedRLI(any())).thenCallRealMethod(); + assertThrows(IllegalArgumentException.class, + () -> mockMetadata.getBucketizedFileGroupsForPartitionedRLI(MetadataPartitionType.FILES)); + + when(mockMetadata.getFilegroupsForPartition(MetadataPartitionType.RECORD_INDEX)) + .thenReturn(Collections.emptyList()); + assertTrue(mockMetadata.getBucketizedFileGroupsForPartitionedRLI( + MetadataPartitionType.RECORD_INDEX).isEmpty()); + + FileSlice nonPartitionedSlice = mock(FileSlice.class); + when(nonPartitionedSlice.getFileId()).thenReturn("record-index-0000"); + when(mockMetadata.getFilegroupsForPartition(MetadataPartitionType.RECORD_INDEX)) + .thenReturn(Collections.singletonList(nonPartitionedSlice)); + assertThrows(IllegalArgumentException.class, + () -> mockMetadata.getBucketizedFileGroupsForPartitionedRLI( + MetadataPartitionType.RECORD_INDEX)); + } + + @Test + public void testPartitionedRecordIndexLookupGuards() throws Exception { + Method lookupMethod = HoodieBackedTableMetadata.class.getDeclaredMethod( + "lookupIndexRecords", HoodieData.class, String.class, List.class, Option.class); + lookupMethod.setAccessible(true); + FileSlice partitionedSlice = mock(FileSlice.class); + when(partitionedSlice.getFileId()).thenReturn("record-index-partition-x-0000"); + List slices = Collections.singletonList(partitionedSlice); + + HoodieData emptyResult = (HoodieData) lookupMethod.invoke( + mockMetadata, + HoodieListData.eager(Collections.emptyList()), + MetadataPartitionType.RECORD_INDEX.getPartitionPath(), + slices, + Option.empty()); + assertTrue(emptyResult.collectAsList().isEmpty()); + + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> lookupMethod.invoke( + mockMetadata, + HoodieListData.eager(Collections.singletonList("key")), + MetadataPartitionType.RECORD_INDEX.getPartitionPath(), + slices, + Option.empty())); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + } + + @Test + public void testSecondaryIndexEmptyIteratorPath() throws Exception { + Method method = HoodieBackedTableMetadata.class.getDeclaredMethod( + "readSliceAndFilterByKeys", String.class, List.class, FileSlice.class); + method.setAccessible(true); + Object iterator = method.invoke( + mockMetadata, + MetadataPartitionType.SECONDARY_INDEX.getPartitionPath() + "test", + Collections.emptyList(), + mock(FileSlice.class)); + assertFalse(((org.apache.hudi.common.util.collection.ClosableIterator) iterator).hasNext()); + } + + @Test + public void testInitializationFailureDisablesMetadata() throws Exception { + Field initializedField = BaseTableMetadata.class.getDeclaredField("isMetadataTableInitialized"); + initializedField.setAccessible(true); + initializedField.set(mockMetadata, true); + Field metadataBasePathField = + HoodieBackedTableMetadata.class.getDeclaredField("metadataBasePath"); + metadataBasePathField.setAccessible(true); + metadataBasePathField.set(mockMetadata, "/table/.hoodie/metadata"); + when(mockMetadata.getStorage()).thenReturn(mock(HoodieStorage.class)); + + HoodieTableMetaClient.Builder builder = mock(HoodieTableMetaClient.Builder.class); + when(builder.setStorage(any())).thenReturn(builder); + when(builder.setBasePath(anyString())).thenReturn(builder); + when(builder.build()).thenThrow(new HoodieException("initialization failed")); + + try (MockedStatic metaClientStatic = + mockStatic(HoodieTableMetaClient.class)) { + metaClientStatic.when(HoodieTableMetaClient::builder).thenReturn(builder); + Method initMethod = HoodieBackedTableMetadata.class.getDeclaredMethod("initIfNeeded"); + initMethod.setAccessible(true); + initMethod.invoke(mockMetadata); + } + + assertFalse(mockMetadata.isMetadataTableInitialized()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + public void testSecondaryIndexRecordMapping() throws Exception { + prepareFileSliceRead(false); + HoodieRecord metadataRecord = + HoodieMetadataPayload.createPartitionFilesRecord( + "key", Collections.emptyMap(), Collections.emptyList()); + IndexedRecord indexedRecord = + (IndexedRecord) metadataRecord.getData().getInsertValue( + HoodieMetadataRecord.getClassSchema()).get(); + + HoodieFileGroupReader.HoodieFileGroupReaderBuilder builder = + mock(HoodieFileGroupReader.HoodieFileGroupReaderBuilder.class); + HoodieFileGroupReader fileGroupReader = mock(HoodieFileGroupReader.class); + when(builder.withReaderContext(any())).thenReturn(builder); + when(builder.withHoodieTableMetaClient(any())).thenReturn(builder); + when(builder.withLatestCommitTime(anyString())).thenReturn(builder); + when(builder.withBaseFileOption(any())).thenReturn(builder); + when(builder.withLogFiles(any())).thenReturn(builder); + when(builder.withPartitionPath(anyString())).thenReturn(builder); + when(builder.withDataSchema(any())).thenReturn(builder); + when(builder.withRequestedSchema(any())).thenReturn(builder); + when(builder.withProps(any())).thenReturn(builder); + when(builder.withRecordBufferLoader(any())).thenReturn(builder); + when(builder.build()).thenReturn(fileGroupReader); + when(fileGroupReader.getClosableIterator()).thenReturn( + ClosableIterator.wrap(Collections.singletonList(indexedRecord).iterator())); + + FileSlice fileSlice = mock(FileSlice.class); + when(fileSlice.getPartitionPath()).thenReturn( + MetadataPartitionType.SECONDARY_INDEX.getPartitionPath() + "test"); + when(fileSlice.getBaseFile()).thenReturn(Option.empty()); + when(fileSlice.getLogFiles()).thenReturn(Stream.empty()); + + try (MockedStatic readerStatic = + mockStatic(HoodieFileGroupReader.class)) { + readerStatic.when(HoodieFileGroupReader::builder).thenReturn(builder); + Method method = HoodieBackedTableMetadata.class.getDeclaredMethod( + "readSliceAndFilterByKeys", String.class, List.class, FileSlice.class); + method.setAccessible(true); + ClosableIterator>> iterator = + (ClosableIterator>>) method.invoke( + mockMetadata, + MetadataPartitionType.SECONDARY_INDEX.getPartitionPath() + "test", + Collections.singletonList("key"), + fileSlice); + + assertTrue(iterator.hasNext()); + assertEquals("key", iterator.next().getLeft()); + assertFalse(iterator.hasNext()); + iterator.close(); + + when(fileGroupReader.getClosableIterator()) + .thenThrow(new IOException("iterator failed")); + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> method.invoke( + mockMetadata, + MetadataPartitionType.SECONDARY_INDEX.getPartitionPath() + "test", + Collections.singletonList("key"), + fileSlice)); + assertTrue(exception.getCause() instanceof org.apache.hudi.exception.HoodieIOException); + + Method scanMethod = HoodieBackedTableMetadata.class.getDeclaredMethod( + "scanRecordsItr", FileSlice.class, SerializableFunctionUnchecked.class); + scanMethod.setAccessible(true); + InvocationTargetException scanException = assertThrows( + InvocationTargetException.class, + () -> scanMethod.invoke( + mockMetadata, + fileSlice, + (SerializableFunctionUnchecked>) record -> null)); + assertTrue(scanException.getCause() instanceof org.apache.hudi.exception.HoodieIOException); + } + } + + @Test + public void testReusableReaderIOExceptionIsWrapped() throws Exception { + prepareFileSliceRead(true); + HoodieStorage storage = mock(HoodieStorage.class); + when(mockMetadata.getStorage()).thenReturn(storage); + + HoodieBaseFile baseFile = mock(HoodieBaseFile.class); + when(baseFile.getPathInfo()).thenReturn(mock(StoragePathInfo.class)); + FileSlice fileSlice = mock(FileSlice.class); + when(fileSlice.getPartitionPath()).thenReturn(MetadataPartitionType.FILES.getPartitionPath()); + when(fileSlice.getFileGroupId()).thenReturn( + new org.apache.hudi.common.model.HoodieFileGroupId( + MetadataPartitionType.FILES.getPartitionPath(), "file-id")); + when(fileSlice.getBaseFile()).thenReturn(Option.of(baseFile)); + + HoodieIOFactory ioFactory = mock(HoodieIOFactory.class); + HoodieFileReaderFactory readerFactory = mock(HoodieFileReaderFactory.class); + when(ioFactory.getReaderFactory(HoodieRecord.HoodieRecordType.AVRO)) + .thenReturn(readerFactory); + when(readerFactory.getFileReader( + any(HoodieConfig.class), + any(StoragePathInfo.class), + any(HoodieFileFormat.class), + any(Option.class))) + .thenThrow(new IOException("reader failed")); + + try (MockedStatic ioFactoryStatic = + mockStatic(HoodieIOFactory.class)) { + ioFactoryStatic.when(() -> HoodieIOFactory.getIOFactory(storage)).thenReturn(ioFactory); + Method method = HoodieBackedTableMetadata.class.getDeclaredMethod( + "readSliceWithFilter", + org.apache.hudi.expression.Predicate.class, + FileSlice.class); + method.setAccessible(true); + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> method.invoke( + mockMetadata, + mock(org.apache.hudi.expression.Predicate.class), + fileSlice)); + assertTrue(exception.getCause() instanceof org.apache.hudi.exception.HoodieIOException); + } + } + + @Test + public void testEmptyShardReturnsEmptyIterator() throws Exception { + HoodieEngineContext engineContext = mock(HoodieEngineContext.class); + HoodieData emptyKeys = HoodieListData.eager(Collections.emptyList()); + HoodieData> emptyResult = + HoodieListData.eager(Collections.emptyList()); + when(mockMetadata.getEngineContext()).thenReturn(engineContext); + when(engineContext.parallelize( + any(List.class), org.mockito.ArgumentMatchers.eq(1))).thenReturn(emptyKeys); + when(engineContext.mapGroupsByKey(any(), any(), any(), org.mockito.ArgumentMatchers.eq(true))) + .thenAnswer(invocation -> { + SerializableFunction, + java.util.Iterator>> processFunction = + invocation.getArgument(1); + assertFalse(processFunction.apply(Collections.emptyIterator()).hasNext()); + return emptyResult; + }); + + Method method = HoodieBackedTableMetadata.class.getDeclaredMethod( + "lookupIndexRecords", HoodieData.class, String.class, List.class, + Option.class); + method.setAccessible(true); + Object result = method.invoke( + mockMetadata, + emptyKeys, + MetadataPartitionType.FILES.getPartitionPath(), + Arrays.asList(mock(FileSlice.class), mock(FileSlice.class)), + Option.empty()); + + assertEquals(emptyResult, result); + } + + private void prepareFileSliceRead(boolean reuse) throws Exception { + HoodieTableMetaClient metadataMetaClient = mock(HoodieTableMetaClient.class); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class); + when(metadataMetaClient.getActiveTimeline()).thenReturn(timeline); + when(timeline.filterCompletedInstants()).thenReturn(timeline); + when(timeline.lastInstant()).thenReturn(Option.empty()); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + when(tableConfig.populateMetaFields()).thenReturn(true); + when(tableConfig.getBaseFileFormat()).thenReturn(HoodieFileFormat.PARQUET); + when(metadataMetaClient.getTableConfig()).thenReturn(tableConfig); + + Field metadataMetaClientField = + HoodieBackedTableMetadata.class.getDeclaredField("metadataMetaClient"); + metadataMetaClientField.setAccessible(true); + metadataMetaClientField.set(mockMetadata, metadataMetaClient); + Field validInstantsField = + HoodieBackedTableMetadata.class.getDeclaredField("validInstantTimestamps"); + validInstantsField.setAccessible(true); + validInstantsField.set(mockMetadata, Collections.singleton("001")); + Field reuseField = HoodieBackedTableMetadata.class.getDeclaredField("reuse"); + reuseField.setAccessible(true); + reuseField.set(mockMetadata, reuse); + Field metadataConfigField = BaseTableMetadata.class.getDeclaredField("metadataConfig"); + metadataConfigField.setAccessible(true); + metadataConfigField.set( + mockMetadata, HoodieMetadataConfig.newBuilder().enable(true).build()); + Field storageConfField = + AbstractHoodieTableMetadata.class.getDeclaredField("storageConf"); + storageConfField.setAccessible(true); + storageConfField.set(mockMetadata, mock(StorageConfiguration.class)); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieTableMetadataUtil.java b/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieTableMetadataUtil.java index 95023eebe6954..b75cfbfb9de96 100644 --- a/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieTableMetadataUtil.java +++ b/hudi-common/src/test/java/org/apache/hudi/metadata/TestHoodieTableMetadataUtil.java @@ -18,25 +18,74 @@ package org.apache.hudi.metadata; +import org.apache.hudi.avro.HoodieAvroUtils; +import org.apache.hudi.avro.model.HoodieInstantInfo; +import org.apache.hudi.avro.model.HoodieMetadataColumnStats; +import org.apache.hudi.avro.model.HoodieMetadataRecord; +import org.apache.hudi.avro.model.HoodieRollbackPlan; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.data.HoodiePairData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.engine.HoodieLocalEngineContext; +import org.apache.hudi.common.engine.HoodieReaderContext; +import org.apache.hudi.common.engine.ReaderContextFactory; import org.apache.hudi.common.function.SerializableBiFunction; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieIndexDefinition; import org.apache.hudi.common.model.HoodieIndexMetadata; +import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType; +import org.apache.hudi.common.model.HoodieRecordGlobalLocation; +import org.apache.hudi.common.model.HoodieWriteStat; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.common.table.read.HoodieFileGroupReader; +import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.InstantGenerator; +import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.Option; - +import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.ExternalSpillableMap; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieMetadataException; +import org.apache.hudi.exception.HoodieNotSupportedException; +import org.apache.hudi.stats.HoodieColumnRangeMetadata; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.HoodieStorageUtils; +import org.apache.hudi.storage.StorageConfiguration; +import org.apache.hudi.storage.StoragePath; + +import org.apache.avro.AvroTypeException; +import org.apache.avro.LogicalTypes; +import org.apache.avro.generic.GenericRecord; import org.junit.jupiter.api.Test; - +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TimeZone; +import java.util.stream.Stream; import static org.apache.hudi.metadata.HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS; import static org.apache.hudi.metadata.HoodieTableMetadataUtil.PARTITION_NAME_PARTITION_STATS; @@ -44,8 +93,17 @@ import static org.apache.hudi.metadata.SecondaryIndexKeyUtils.constructSecondaryIndexKey; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class TestHoodieTableMetadataUtil { @@ -352,4 +410,577 @@ void testVariantBlobVectorColumnsAreNotSupportedForV1ColumnStats() { "STRING should remain supported for record type " + recordType); } } + + @Test + void testCreateRecordIndexUpdateMillisOverloadMatchesStringOverload() { + String instantTime = "20260610153045678"; + long instantTimeMillis = HoodieMetadataPayload.parseRecordIndexInstantTime(instantTime); + + // uuid-encoded fileId (encoding 0) + HoodieRecord fromString = HoodieMetadataPayload.createRecordIndexUpdate( + "rk1", "p1", "49b8b3c8-9e5d-4731-9d51-a2d8e9b5c7f3-0", instantTime, 0); + HoodieRecord fromMillis = HoodieMetadataPayload.createRecordIndexUpdate( + "rk1", "p1", "49b8b3c8-9e5d-4731-9d51-a2d8e9b5c7f3-0", instantTimeMillis, 0); + assertEquals(fromString.getKey(), fromMillis.getKey()); + assertEquals(fromString.getData(), fromMillis.getData()); + + // raw fileId (encoding 1) + fromString = HoodieMetadataPayload.createRecordIndexUpdate( + "rk1", "p1", "some-raw-file-id", instantTime, 1); + fromMillis = HoodieMetadataPayload.createRecordIndexUpdate( + "rk1", "p1", "some-raw-file-id", instantTimeMillis, 1); + assertEquals(fromString.getKey(), fromMillis.getKey()); + assertEquals(fromString.getData(), fromMillis.getData()); + } + + @Test + void testRecordIndexPayloadRoundTripsThroughAvro() throws Exception { + // both fileId encodings populate the numeric RLI fields; they must survive the avro read path + // (constructMetadataPayload now reads the long/int fields directly instead of via toString+parse) + assertRecordIndexRoundTrips("49b8b3c8-9e5d-4731-9d51-a2d8e9b5c7f3-0", 0); + assertRecordIndexRoundTrips("some-raw-file-id", 1); + } + + private static void assertRecordIndexRoundTrips(String fileId, int fileIdEncoding) throws Exception { + HoodieRecord written = + HoodieMetadataPayload.createRecordIndexUpdate("rk1", "p1", fileId, "20260610153045678", fileIdEncoding); + // serialize to avro bytes and back so the read path sees a GenericRecord with boxed Long/Integer fields + byte[] bytes = HoodieAvroUtils.avroToBytes(written.getData().getInsertValue(null).get()); + GenericRecord deserialized = HoodieAvroUtils.bytesToAvro(bytes, HoodieMetadataRecord.getClassSchema()); + HoodieMetadataPayload readBack = new HoodieMetadataPayload(Option.of(deserialized)); + assertEquals(written.getData().recordIndexMetadata, readBack.recordIndexMetadata, + "RLI metadata must survive the avro read path for fileId encoding " + fileIdEncoding); + } + + @Test + void testGetLocationFromRecordIndexInfoFormatsInstantConsistently() { + long instantMillis1 = HoodieMetadataPayload.parseRecordIndexInstantTime("20260610153045678"); + long instantMillis2 = HoodieMetadataPayload.parseRecordIndexInstantTime("20260610163045678"); + String expected1 = HoodieInstantTimeGenerator.formatDate(new Date(instantMillis1)); + String expected2 = HoodieInstantTimeGenerator.formatDate(new Date(instantMillis2)); + // repeated and alternating instants must format consistently + for (long instantMillis : new long[] {instantMillis1, instantMillis1, instantMillis2, instantMillis1}) { + HoodieRecordGlobalLocation location = HoodieTableMetadataUtil.getLocationFromRecordIndexInfo( + "p1", 1, -1L, -1L, -1, "some-raw-file-id", instantMillis); + assertEquals(instantMillis == instantMillis1 ? expected1 : expected2, location.getInstantTime()); + assertEquals("p1", location.getPartitionPath()); + assertEquals("some-raw-file-id", location.getFileId()); + } + + // formatDate follows the JVM default time zone, so the decoded location must track a zone + // change; the two switches differ in offset, so at least one changes the formatted string + TimeZone originalTimeZone = TimeZone.getDefault(); + try { + for (String zoneId : new String[] {"UTC", "Asia/Kolkata"}) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + String expectedInZone = HoodieInstantTimeGenerator.formatDate(new Date(instantMillis1)); + HoodieRecordGlobalLocation location = HoodieTableMetadataUtil.getLocationFromRecordIndexInfo( + "p1", 1, -1L, -1L, -1, "some-raw-file-id", instantMillis1); + assertEquals(expectedInZone, location.getInstantTime()); + } + } finally { + TimeZone.setDefault(originalTimeZone); + } + } + + @Test + void testColumnStatsValueValidation() { + assertFalse(HoodieTableMetadataUtil.getColumnStatsValueAsString(null).isPresent()); + assertThrows(HoodieNotSupportedException.class, + () -> HoodieTableMetadataUtil.getColumnStatsValueAsString(new Object())); + } + + @Test + void testWritePartitionPathsIncludeNonPartitionedTableIdentifier() { + HoodieCommitMetadata commitMetadata = new HoodieCommitMetadata(); + commitMetadata.addWriteStat("", new HoodieWriteStat()); + commitMetadata.addWriteStat("year=2026", new HoodieWriteStat()); + + assertEquals( + new java.util.HashSet<>(Arrays.asList("", "year=2026")), + HoodieTableMetadataUtil.getWritePartitionPaths(Collections.singletonList(commitMetadata))); + } + + @Test + void testDecimalUpcastValidation() { + assertThrows(AvroTypeException.class, + () -> HoodieTableMetadataUtil.tryUpcastDecimal( + new BigDecimal("1.23"), LogicalTypes.decimal(5, 1))); + assertThrows(AvroTypeException.class, + () -> HoodieTableMetadataUtil.tryUpcastDecimal( + new BigDecimal("123"), LogicalTypes.decimal(3, 1))); + assertThrows(AvroTypeException.class, + () -> HoodieTableMetadataUtil.tryUpcastDecimal( + new BigDecimal("1234"), LogicalTypes.decimal(3, 0))); + } + + @Test + void testComparableCoercion() { + assertNull(HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.INT), null)); + assertEquals(1, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.INT), true)); + assertEquals(0L, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.LONG), false)); + assertEquals(1.5f, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.FLOAT), 1.5d)); + assertEquals(2.5d, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.DOUBLE), 2.5f)); + assertEquals(1.0f, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.FLOAT), true)); + assertEquals(0.0d, HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.DOUBLE), false)); + assertNull(HoodieTableMetadataUtil.coerceToComparable( + HoodieSchema.create(HoodieSchemaType.NULL), "ignored")); + } + + @Test + void testFileGroupCountBoundsAndInflightWriteStatusTracking() { + assertEquals(10, HoodieTableMetadataUtil.estimateFileGroupCount( + MetadataPartitionType.RECORD_INDEX, () -> 10_000L, 1, 1, 10, 1.0f, 100)); + assertEquals(5, HoodieTableMetadataUtil.estimateFileGroupCount( + MetadataPartitionType.RECORD_INDEX, () -> 500L, 1, 2, 10, 1.0f, 100)); + + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(tableConfig.isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX)).thenReturn(false); + when(tableConfig.getMetadataPartitionsInflight()) + .thenReturn(Collections.singleton(MetadataPartitionType.RECORD_INDEX.getPartitionPath())); + + assertTrue(HoodieTableMetadataUtil.getMetadataPartitionsNeedingWriteStatusTracking( + HoodieMetadataConfig.newBuilder().enable(false).build(), metaClient)); + } + + @Test + @SuppressWarnings("deprecation") + void testGenerateKeyPrefixesMatchesRawKeyEncoding() { + List columns = Arrays.asList("c1", "c2"); + assertEquals( + HoodieTableMetadataUtil.generateColumnStatsKeys(columns, "partition").stream() + .map(ColumnStatsIndexPrefixRawKey::encode) + .collect(java.util.stream.Collectors.toList()), + HoodieTableMetadataUtil.generateKeyPrefixes(columns, "partition")); + } + + @Test + void testCollectColumnRangeMetadata() { + HoodieSchema recordSchema = mock(HoodieSchema.class); + StorageConfiguration storageConfig = mock(StorageConfiguration.class); + when(storageConfig.getString( + org.apache.hudi.common.config.HoodieStorageConfig.WRITE_UTC_TIMEZONE.key(), + org.apache.hudi.common.config.HoodieStorageConfig.WRITE_UTC_TIMEZONE.defaultValue().toString())) + .thenReturn("UTC"); + + HoodieRecord record = mock(HoodieRecord.class); + when(record.getRecordType()).thenReturn(HoodieRecordType.FLINK); + when(record.getColumnValueAsJava( + org.mockito.ArgumentMatchers.eq(recordSchema), + org.mockito.ArgumentMatchers.eq("id"), + org.mockito.ArgumentMatchers.any())) + .thenReturn(7); + + HoodieSchemaField idField = HoodieSchemaField.of( + "id", HoodieSchema.create(HoodieSchemaType.INT), null, null); + HoodieSchemaField unsupportedField = HoodieSchemaField.of( + "attributes", + HoodieSchema.createMap(HoodieSchema.create(HoodieSchemaType.STRING)), + null, + null); + Map> stats = + HoodieTableMetadataUtil.collectColumnRangeMetadata( + Collections.singletonList(record).iterator(), + Arrays.asList(Pair.of("id", idField), Pair.of("attributes", unsupportedField)), + "file.parquet", + recordSchema, + storageConfig, + HoodieIndexVersion.V1); + + assertEquals(7, stats.get("id").getMinValue()); + assertEquals(7, stats.get("id").getMaxValue()); + assertNull(stats.get("attributes").getMinValue()); + } + + @Test + void testBloomAndColumnStatsConversionFast() { + HoodieLocalEngineContext engineContext = + new HoodieLocalEngineContext(mock(StorageConfiguration.class)); + Map> deletedFiles = new HashMap<>(); + deletedFiles.put("p1", Arrays.asList("file.log.1", "file_1-0-1_001.parquet")); + + HoodieData records = HoodieTableMetadataUtil.convertFilesToBloomFilterRecords( + engineContext, + deletedFiles, + Collections.emptyMap(), + "001", + mock(HoodieTableMetaClient.class), + 2, + "SIMPLE"); + assertEquals(1, records.collectAsList().size()); + + // release-1.2.1 takes an extra HoodieMetadataConfig after the meta client; master dropped + // that parameter, so pass a default config here. + assertTrue(HoodieTableMetadataUtil.convertFilesToColumnStatsRecords( + engineContext, + Collections.emptyMap(), + Collections.emptyMap(), + mock(HoodieTableMetaClient.class), + HoodieMetadataConfig.newBuilder().build(), + 1, + 1024, + Collections.singletonList("id")).collectAsList().isEmpty()); + } + + @Test + void testFilesPartitionAvailability() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(tableConfig.getMetadataPartitions()) + .thenReturn(Collections.singleton(HoodieTableMetadataUtil.PARTITION_NAME_FILES)); + + assertTrue(HoodieTableMetadataUtil.isFilesPartitionAvailable(metaClient)); + } + + @Test + void testMetadataTableDeletionOutcomes() throws Exception { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + HoodieStorage storage = mock(HoodieStorage.class); + when(metaClient.getBasePath()).thenReturn(new StoragePath("/table")); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(metaClient.getStorage()).thenReturn(storage); + + when(storage.exists(any(StoragePath.class))).thenReturn(false); + assertNull(HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenThrow(new FileNotFoundException("missing")); + assertNull(HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenThrow(new IOException("check failed")); + assertThrows(HoodieMetadataException.class, + () -> HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + when(storage.rename(any(StoragePath.class), any(StoragePath.class))).thenReturn(true); + assertTrue(HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, true) + .contains(".metadata_")); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + when(storage.rename(any(StoragePath.class), any(StoragePath.class))) + .thenThrow(new IOException("rename failed")); + assertNull(HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, true)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + org.mockito.Mockito.doThrow(new IOException("delete failed")) + .when(storage).deleteDirectory(any(StoragePath.class)); + assertThrows(HoodieMetadataException.class, + () -> HoodieTableMetadataUtil.deleteMetadataTable(metaClient, null, false)); + } + + @Test + void testMetadataPartitionDeletionOutcomes() throws Exception { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableConfig tableConfig = mock(HoodieTableConfig.class); + HoodieStorage storage = mock(HoodieStorage.class); + when(metaClient.getBasePath()).thenReturn(new StoragePath("/table")); + when(metaClient.getTableConfig()).thenReturn(tableConfig); + when(metaClient.getStorage()).thenReturn(storage); + String partition = MetadataPartitionType.COLUMN_STATS.getPartitionPath(); + + when(storage.exists(any(StoragePath.class))).thenReturn(false); + assertNull(HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, MetadataPartitionType.FILES.getPartitionPath(), false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenThrow(new FileNotFoundException("missing")); + assertNull(HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, partition, false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenThrow(new IOException("check failed")); + assertThrows(HoodieMetadataException.class, + () -> HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, partition, false)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + when(storage.rename(any(StoragePath.class), any(StoragePath.class))).thenReturn(true); + assertTrue(HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, partition, true).contains(".metadata_")); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + when(storage.rename(any(StoragePath.class), any(StoragePath.class))) + .thenThrow(new IOException("rename failed")); + assertNull(HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, partition, true)); + + reset(storage); + when(storage.exists(any(StoragePath.class))).thenReturn(true); + org.mockito.Mockito.doThrow(new IOException("delete failed")) + .when(storage).deleteDirectory(any(StoragePath.class)); + assertThrows(HoodieMetadataException.class, + () -> HoodieTableMetadataUtil.deleteMetadataTablePartition( + metaClient, null, partition, false)); + } + + @Test + void testRecordKeyReadSchemaFailureIsWrapped() { + HoodieEngineContext engineContext = mock(HoodieEngineContext.class); + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + when(metaClient.getBasePath()).thenReturn(new StoragePath("/table")); + when(metaClient.getStorageConf()).thenReturn(mock(StorageConfiguration.class)); + + assertThrows(org.apache.hudi.exception.HoodieException.class, + () -> HoodieTableMetadataUtil.readRecordKeysFromFileSlices( + engineContext, + Collections.singletonList(Pair.of("partition", mock(FileSlice.class))), + 1, + "test", + metaClient, + false)); + } + + @Test + @SuppressWarnings("unchecked") + void testPartitionStatsConversionFailureIsWrapped() { + HoodiePairData>> pairData = + mock(HoodiePairData.class); + when(pairData.flatMapValues(any())).thenThrow(new RuntimeException("conversion failed")); + + assertThrows(org.apache.hudi.exception.HoodieException.class, + () -> HoodieTableMetadataUtil.convertMetadataToPartitionStatsRecords( + pairData, + mock(HoodieTableMetaClient.class), + Collections.emptyMap(), + HoodieIndexVersion.V1)); + } + + @Test + void testMergeColumnStatsTombstoneWins() { + HoodieMetadataColumnStats previous = HoodieMetadataColumnStats.newBuilder() + .setColumnName("column") + .setIsDeleted(false) + .build(); + HoodieMetadataColumnStats tombstone = HoodieMetadataColumnStats.newBuilder() + .setColumnName("column") + .setIsDeleted(true) + .build(); + + assertEquals(tombstone, HoodieTableMetadataUtil.mergeColumnStatsRecords(previous, tombstone)); + assertEquals(previous, HoodieTableMetadataUtil.mergeColumnStatsRecords(tombstone, previous)); + } + + @Test + void testFileSliceAndSchemaResolutionEdge() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTimeline timeline = mock(HoodieTimeline.class); + when(metaClient.getCommitsTimeline()).thenReturn(timeline); + when(timeline.filterCompletedInstants()).thenReturn(timeline); + when(timeline.countInstants()).thenReturn(1); + when(metaClient.getBasePath()).thenReturn(new StoragePath("/table")); + assertThrows(org.apache.hudi.exception.HoodieException.class, + () -> HoodieTableMetadataUtil.tryResolveSchemaForTable(metaClient)); + + HoodieTableFileSystemView fileSystemView = mock(HoodieTableFileSystemView.class); + HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class); + when(metaClient.getActiveTimeline()).thenReturn(activeTimeline); + when(activeTimeline.filterCompletedInstants()).thenReturn(activeTimeline); + when(activeTimeline.lastInstant()).thenReturn(Option.empty()); + assertTrue(HoodieTableMetadataUtil.getPartitionLatestMergedFileSlices( + metaClient, fileSystemView, "files").isEmpty()); + } + + @Test + void testEmptyLogInputsAvoidReaderConstruction() { + Pair, java.util.Set> changes = + HoodieTableMetadataUtil.getRevivedAndDeletedKeysFromMergedLogs( + mock(HoodieTableMetaClient.class), + "001", + Collections.singletonList("previous.log"), + Option.empty(), + Collections.singletonList("current.log"), + "partition", + mock(org.apache.hudi.common.engine.HoodieReaderContext.class)); + + assertTrue(changes.getLeft().isEmpty()); + assertTrue(changes.getRight().isEmpty()); + } + + @Test + void testRollbackPlanFallbackAndReadFailure() throws Exception { + Method method = HoodieTableMetadataUtil.class.getDeclaredMethod( + "getRollbackedCommits", + HoodieInstant.class, + HoodieActiveTimeline.class, + InstantGenerator.class); + method.setAccessible(true); + + HoodieInstant completed = mock(HoodieInstant.class); + HoodieInstant requested = mock(HoodieInstant.class); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class); + InstantGenerator instantGenerator = mock(InstantGenerator.class); + HoodieRollbackPlan rollbackPlan = mock(HoodieRollbackPlan.class); + HoodieInstantInfo instantInfo = mock(HoodieInstantInfo.class); + when(completed.getAction()).thenReturn(HoodieTimeline.ROLLBACK_ACTION); + when(completed.requestedTime()).thenReturn("002"); + when(timeline.readRollbackMetadata(completed)).thenThrow(new IOException("empty rollback")); + when(instantGenerator.createNewInstant( + HoodieInstant.State.REQUESTED, HoodieTimeline.ROLLBACK_ACTION, "002")) + .thenReturn(requested); + when(timeline.readRollbackPlan(requested)).thenReturn(rollbackPlan); + when(rollbackPlan.getInstantToRollback()).thenReturn(instantInfo); + when(instantInfo.getCommitTime()).thenReturn("001"); + assertEquals(Collections.singletonList("001"), method.invoke( + null, completed, timeline, instantGenerator)); + + when(completed.getAction()).thenReturn(HoodieTimeline.RESTORE_ACTION); + when(timeline.readRestoreMetadata(completed)).thenThrow(new IOException("broken restore")); + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> method.invoke(null, completed, timeline, instantGenerator)); + assertTrue(exception.getCause() instanceof HoodieMetadataException); + } + + @Test + void testMetadataPartitionExistenceFailureIsWrapped() throws Exception { + HoodieEngineContext context = mock(HoodieEngineContext.class); + StorageConfiguration storageConfiguration = mock(StorageConfiguration.class); + HoodieStorage storage = mock(HoodieStorage.class); + org.mockito.Mockito.doReturn(storageConfiguration).when(context).getStorageConf(); + when(storage.exists(any(StoragePath.class))).thenThrow(new IOException("failed")); + + try (MockedStatic storageUtils = mockStatic(HoodieStorageUtils.class)) { + storageUtils.when(() -> HoodieStorageUtils.getStorage(any(String.class), any())) + .thenReturn(storage); + assertThrows(org.apache.hudi.exception.HoodieIOException.class, + () -> HoodieTableMetadataUtil.metadataPartitionExists( + "/table", context, MetadataPartitionType.FILES.getPartitionPath())); + } + } + + @Test + @SuppressWarnings("unchecked") + void testDeletedFileStatsCreateStubs() throws Exception { + Method method = HoodieTableMetadataUtil.class.getDeclaredMethod( + "getFileStatsRangeMetadata", + String.class, + String.class, + HoodieTableMetaClient.class, + List.class, + boolean.class, + int.class, + HoodieIndexVersion.class); + method.setAccessible(true); + + List> stats = + (List>) method.invoke( + null, + "partition", + "file.parquet", + mock(HoodieTableMetaClient.class), + Arrays.asList("c1", "c2"), + true, + 1024, + HoodieIndexVersion.V1); + assertEquals(2, stats.size()); + } + + @Test + void testCommitPartitionExtraction() throws Exception { + HoodieCommitMetadata commitMetadata = new HoodieCommitMetadata(); + commitMetadata.addWriteStat("", new HoodieWriteStat()); + commitMetadata.addWriteStat("partition", new HoodieWriteStat()); + Method method = HoodieTableMetadataUtil.class.getDeclaredMethod( + "getPartitionsAdded", HoodieCommitMetadata.class); + method.setAccessible(true); + + assertEquals( + new java.util.HashSet<>(Arrays.asList(HoodieTableMetadata.NON_PARTITIONED_NAME, "partition")), + new java.util.HashSet<>((List) method.invoke(null, commitMetadata))); + } + + @Test + void testInflightFileSliceViewIsClosedWhenCreatedInternally() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieTableFileSystemView fileSystemView = mock(HoodieTableFileSystemView.class); + when(fileSystemView.getLatestFileSlicesIncludingInflight("files")) + .thenReturn(Stream.empty()); + + try (MockedStatic util = + mockStatic(HoodieTableMetadataUtil.class, org.mockito.Answers.CALLS_REAL_METHODS)) { + util.when(() -> HoodieTableMetadataUtil.getFileSystemViewForMetadataTable(metaClient)) + .thenReturn(fileSystemView); + assertTrue(HoodieTableMetadataUtil.getPartitionLatestFileSlicesIncludingInflight( + metaClient, Option.empty(), "files").isEmpty()); + } + + verify(fileSystemView).close(); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void testLogOnlyFileSliceRecordKeys() throws Exception { + HoodieEngineContext engineContext = mock(HoodieEngineContext.class); + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieSchema schema = mock(HoodieSchema.class); + FileSlice fileSlice = mock(FileSlice.class); + List> slices = + Collections.singletonList(Pair.of("partition", fileSlice)); + when(metaClient.getBasePath()).thenReturn(new StoragePath("/table")); + StorageConfiguration storageConfiguration = mock(StorageConfiguration.class); + when(storageConfiguration.getEnum( + any(String.class), any(ExternalSpillableMap.DiskMapType.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + org.mockito.Mockito.doReturn(storageConfiguration).when(metaClient).getStorageConf(); + HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class); + when(metaClient.getActiveTimeline()).thenReturn(timeline); + when(timeline.filterCompletedInstants()).thenReturn(timeline); + when(timeline.lastInstant()).thenReturn(Option.empty()); + when(fileSlice.getBaseFile()).thenReturn(Option.empty()); + when(fileSlice.getLogFiles()).thenReturn(Stream.empty()); + when(fileSlice.getPartitionPath()).thenReturn("partition"); + when(fileSlice.getFileId()).thenReturn("file-id"); + when(fileSlice.getBaseInstantTime()).thenReturn("20240101000000000"); + org.mockito.Mockito.doReturn(HoodieListData.eager(slices)) + .when(engineContext).parallelize(anyList(), anyInt()); + + ReaderContextFactory readerContextFactory = mock(ReaderContextFactory.class); + HoodieReaderContext readerContext = mock(HoodieReaderContext.class); + org.mockito.Mockito.doReturn(readerContextFactory) + .when(engineContext).getReaderContextFactory(metaClient); + when(readerContextFactory.getContext()).thenReturn(readerContext); + + HoodieFileGroupReader.HoodieFileGroupReaderBuilder builder = + mock(HoodieFileGroupReader.HoodieFileGroupReaderBuilder.class); + HoodieFileGroupReader fileGroupReader = mock(HoodieFileGroupReader.class); + when(builder.withReaderContext(any())).thenReturn(builder); + when(builder.withHoodieTableMetaClient(any())).thenReturn(builder); + when(builder.withBaseFileOption(any())).thenReturn(builder); + when(builder.withLogFiles(any())).thenReturn(builder); + when(builder.withPartitionPath(any())).thenReturn(builder); + when(builder.withDataSchema(any())).thenReturn(builder); + when(builder.withRequestedSchema(any())).thenReturn(builder); + when(builder.withLatestCommitTime(any())).thenReturn(builder); + when(builder.withProps(any())).thenReturn(builder); + when(builder.build()).thenReturn(fileGroupReader); + when(fileGroupReader.getClosableKeyIterator()) + .thenReturn(ClosableIterator.wrap(Collections.emptyIterator())); + + try (MockedConstruction ignored = + mockConstruction(TableSchemaResolver.class, + (resolver, context) -> when(resolver.getTableSchema()).thenReturn(schema)); + MockedStatic readerStatic = + mockStatic(HoodieFileGroupReader.class)) { + readerStatic.when(HoodieFileGroupReader::builder).thenReturn(builder); + assertTrue(HoodieTableMetadataUtil.readRecordKeysFromFileSlices( + engineContext, slices, 1, "test", metaClient, false).collectAsList().isEmpty()); + } + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/metadata/TestMetadataPartitionType.java b/hudi-common/src/test/java/org/apache/hudi/metadata/TestMetadataPartitionType.java index 2ccddf81add3c..062b4b967d8d0 100644 --- a/hudi-common/src/test/java/org/apache/hudi/metadata/TestMetadataPartitionType.java +++ b/hudi-common/src/test/java/org/apache/hudi/metadata/TestMetadataPartitionType.java @@ -19,6 +19,8 @@ package org.apache.hudi.metadata; +import org.apache.hudi.avro.model.HoodieMetadataBloomFilter; +import org.apache.hudi.avro.model.HoodieMetadataRecord; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.HoodieIndexDefinition; import org.apache.hudi.common.model.HoodieIndexMetadata; @@ -28,6 +30,8 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -35,11 +39,13 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; +import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -312,4 +318,76 @@ public void testIsExpressionOrSecondaryIndex() { } } } + + @Test + public void testProjectedPayloadConstruction() { + HoodieMetadataPayload payload = new HoodieMetadataPayload(Option.empty()); + Schema projectedSchema = Schema.createRecord("ProjectedMetadataRecord", null, null, false); + projectedSchema.setFields(Collections.emptyList()); + GenericData.Record projectedRecord = new GenericData.Record(projectedSchema); + + MetadataPartitionType.FILES.constructMetadataPayload(payload, projectedRecord); + MetadataPartitionType.COLUMN_STATS.constructMetadataPayload(payload, projectedRecord); + assertThrows(UnsupportedOperationException.class, + () -> MetadataPartitionType.EXPRESSION_INDEX.constructMetadataPayload(payload, projectedRecord)); + + GenericData.Record invalidBloomFilterRecord = + new GenericData.Record(HoodieMetadataRecord.getClassSchema()); + assertThrows(IllegalArgumentException.class, + () -> MetadataPartitionType.BLOOM_FILTERS.constructMetadataPayload(payload, invalidBloomFilterRecord)); + assertThrows(IllegalArgumentException.class, () -> MetadataPartitionType.get(Integer.MAX_VALUE)); + } + + @Test + public void testBloomFilterCombinationAndAllPartitionsEnablement() { + HoodieMetadataPayload older = new HoodieMetadataPayload("key", + new HoodieMetadataBloomFilter("SIMPLE", "1", ByteBuffer.wrap(new byte[] {1}), false)); + HoodieMetadataPayload newer = new HoodieMetadataPayload("key", + new HoodieMetadataBloomFilter("SIMPLE", "2", ByteBuffer.wrap(new byte[] {2}), false)); + + HoodieMetadataPayload combined = MetadataPartitionType.BLOOM_FILTERS.combineMetadataPayloads(older, newer); + + assertEquals(newer.getBloomFilterMetadata(), combined.getBloomFilterMetadata()); + assertTrue(MetadataPartitionType.ALL_PARTITIONS.isMetadataPartitionEnabled( + HoodieMetadataConfig.newBuilder().enable(true).build(), mock(HoodieTableConfig.class))); + } + + @Test + public void testNewIndexDefinitionChecks() { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + HoodieIndexDefinition secondaryIndex = createIndexDefinition( + MetadataPartitionType.SECONDARY_INDEX, "existing", HoodieTableMetadataUtil.PARTITION_NAME_SECONDARY_INDEX, + null, Collections.singletonList("secondary_col"), null); + HoodieIndexDefinition expressionIndex = createIndexDefinition( + MetadataPartitionType.EXPRESSION_INDEX, "existing", HoodieTableMetadataUtil.PARTITION_NAME_EXPRESSION_INDEX, + "lower", Collections.singletonList("expression_col"), null); + HoodieIndexMetadata indexMetadata = new HoodieIndexMetadata(createIndexDefinitions(secondaryIndex, expressionIndex)); + when(metaClient.getIndexMetadata()).thenReturn(Option.of(indexMetadata)); + + HoodieMetadataConfig secondaryConfig = HoodieMetadataConfig.newBuilder() + .withSecondaryIndexForColumn("secondary_col") + .build(); + assertFalse(MetadataPartitionType.isNewSecondaryIndexDefinitionRequired(secondaryConfig, metaClient)); + + HoodieMetadataConfig expressionConfig = HoodieMetadataConfig.newBuilder() + .withExpressionIndexColumn("expression_col") + .withExpressionIndexOptions(Collections.singletonMap("expr", "lower")) + .build(); + assertFalse(MetadataPartitionType.isNewExpressionIndexDefinitionRequired(expressionConfig, metaClient)); + + HoodieMetadataConfig differentExpressionConfig = HoodieMetadataConfig.newBuilder() + .withExpressionIndexColumn("expression_col") + .withExpressionIndexOptions(Collections.singletonMap("expr", "upper")) + .build(); + assertTrue(MetadataPartitionType.isNewExpressionIndexDefinitionRequired(differentExpressionConfig, metaClient)); + + HoodieMetadataConfig noExpressionConfig = HoodieMetadataConfig.newBuilder() + .withExpressionIndexColumn("expression_col") + .build(); + assertFalse(MetadataPartitionType.isNewExpressionIndexDefinitionRequired(noExpressionConfig, metaClient)); + } + + private static Map createIndexDefinitions(HoodieIndexDefinition... definitions) { + return Arrays.stream(definitions).collect(Collectors.toMap(HoodieIndexDefinition::getIndexName, definition -> definition)); + } } diff --git a/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java b/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java index d456748d97692..7b083caec560d 100644 --- a/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java +++ b/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java @@ -37,6 +37,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import java.lang.reflect.InvocationTargetException; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -82,7 +83,7 @@ void metricsReporterFactoryShouldReturnCloudWatchReporter() { try (MockedStatic mockedStatic = Mockito.mockStatic(ReflectionUtils.class)) { mockedStatic.when(() -> ReflectionUtils.loadClass( - eq("org.apache.hudi.aws.metrics.cloudwatch.CloudWatchMetricsReporter"), + eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), any(Class[].class), eq(metricsConfig), eq(registry) @@ -94,6 +95,153 @@ void metricsReporterFactoryShouldReturnCloudWatchReporter() { } } + /** + * {@code hudi-aws} is deliberately absent from this module's test classpath, which is exactly the + * situation a user hits on an engine bundle that does not shade that module: the reflectively loaded + * CloudWatch reporter cannot be found. The failure must name the missing class and how to fix it, + * not just report that some class could not be loaded. + */ + @Test + void metricsReporterFactoryShouldExplainHowToEnableCloudWatchWhenHudiAwsIsMissing() { + when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH); + + HoodieException exception = assertThrows(HoodieException.class, + () -> MetricsReporterFactory.createReporter(metricsConfig, registry)); + + String message = exception.getMessage(); + assertTrue(message.contains(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), + () -> "The failure should name the missing reporter class, but was: " + message); + assertTrue(message.contains("hudi-aws-bundle"), + () -> "The failure should name the bundle that provides the reporter, but was: " + message); + assertTrue(message.contains(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), + () -> "The failure should name the config to change, but was: " + message); + // Every string above also appears in the constructor-mismatch message, so without this the two branches + // could be merged or reordered and both tests would still pass. + assertTrue(message.contains("was not found on the classpath"), + () -> "A missing class must not be reported as a constructor mismatch, but was: " + message); + } + + /** + * The classpath-based test above exercises the real failure but passes for any resolution failure. + * This pins the mapping itself: a ClassNotFoundException cause is what triggers the rewrite. + */ + @Test + void metricsReporterFactoryRewritesClassNotFoundIntoAnActionableMessage() { + when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH); + try (MockedStatic mockedStatic = Mockito.mockStatic(ReflectionUtils.class)) { + mockedStatic.when(() -> ReflectionUtils.loadClass( + eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), any(Class[].class), eq(metricsConfig), eq(registry))) + .thenThrow(new HoodieException("Unable to load class " + MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS, + new ClassNotFoundException(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS))); + + HoodieException exception = assertThrows(HoodieException.class, + () -> MetricsReporterFactory.createReporter(metricsConfig, registry)); + assertTrue(exception.getMessage().contains("hudi-aws-bundle"), + () -> "Expected the remedy to be named, but was: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("was not found on the classpath"), + () -> "Expected this branch's own phrase, not one shared with the mismatch branch, but was: " + + exception.getMessage()); + } + } + + /** + * A jar built against an older Hudi does not reach this branch - resolving its constructors fails first + * with {@link NoClassDefFoundError}, covered below. What reaches here is a classpath carrying a stale or + * duplicate copy, so that is the remedy the message has to give. + */ + @Test + void metricsReporterFactoryExplainsAConstructorMismatch() { + HoodieException exception = captureCloudWatchFailure( + new HoodieException("Unable to instantiate class " + MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS, + new NoSuchMethodException(""))); + + String message = exception.getMessage(); + assertTrue(message.contains("constructor"), + () -> "The failure should say the constructor did not match, but was: " + message); + assertTrue(message.contains("stale or duplicate copy"), + () -> "The failure should name a stale duplicate as the cause, but was: " + message); + assertTrue(message.contains(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), + () -> "The failure should name the reporter class, but was: " + message); + assertTrue(message.contains(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), + () -> "The failure should name the config to change, but was: " + message); + assertTrue(message.contains(HoodieMetricsConfig.class.getName()) + && message.contains(MetricRegistry.class.getName()), + () -> "Fully qualified parameter types are what distinguish the requested constructor from the " + + "declared one, but was: " + message); + // Positive rather than an assertFalse on the other branch's prose: a vacuous assertFalse never fails. + assertTrue(message.contains("was found on the classpath but"), + () -> "A class that resolved must not be reported as missing, but was: " + message); + assertEquals(NoSuchMethodException.class, exception.getCause().getCause().getClass(), + "The original NoSuchMethodException must stay in the chain - it is the evidence #12902 needed"); + } + + /** + * The clean version-skew case, and the one the mismatch message used to claim. {@code getConstructor} + * resolves the parameter types of every public constructor, so a jar built against an older Hudi dies on a + * type that has since moved. That is an {@link Error}, so {@code ReflectionUtils} never wraps it and it + * reaches the factory uncaught. + */ + @Test + void metricsReporterFactoryExplainsAVanishedParameterType() { + HoodieException exception = captureCloudWatchFailure( + new NoClassDefFoundError("org/apache/hudi/config/metrics/HoodieMetricsConfig")); + + String message = exception.getMessage(); + assertTrue(message.contains("built against a different Hudi version"), + () -> "The failure should name version skew, but was: " + message); + assertTrue(message.contains("org/apache/hudi/config/metrics/HoodieMetricsConfig"), + () -> "The failure should name the type that vanished, but was: " + message); + assertEquals(NoClassDefFoundError.class, exception.getCause().getClass(), + "The Error must stay in the chain - its message is the evidence of skew"); + } + + /** + * The gap every mocked test above shares: none of them proves that real reflection produces the shapes the + * production code branches on. This drives the translation through the real {@code ReflectionUtils} with a + * fixture whose only public constructor does not match, and asserts on the actual throwable. + */ + @Test + void metricsReporterFactoryTranslatesARealReflectionFailure() { + HoodieException exception = assertThrows(HoodieException.class, + () -> MetricsReporterFactory.createCloudWatchReporter( + MismatchedReporter.class.getName(), metricsConfig, registry)); + + String message = exception.getMessage(); + assertTrue(message.contains("stale or duplicate copy"), + () -> "A real non-matching constructor should reach the mismatch branch, but was: " + message); + assertEquals(NoSuchMethodException.class, exception.getCause().getCause().getClass(), + "and the real NoSuchMethodException should be chained"); + } + + /** Public, with a single constructor that deliberately does not match (HoodieMetricsConfig, MetricRegistry). */ + public static class MismatchedReporter { + public MismatchedReporter(String somethingElse) { + // never called; exists so getConstructor has a public constructor to reject + } + } + + /** + * The other direction, and the branch most likely to regress: a failure that is neither a missing class + * nor a missing constructor must pass through untouched, so an error raised by the reporter's own + * constructor is never rewritten into a classpath diagnosis. + */ + @Test + void metricsReporterFactoryLeavesOtherFailuresUntouched() { + when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH); + try (MockedStatic mockedStatic = Mockito.mockStatic(ReflectionUtils.class)) { + mockedStatic.when(() -> ReflectionUtils.loadClass( + eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), any(Class[].class), eq(metricsConfig), eq(registry))) + .thenThrow(new HoodieException("Unable to instantiate class " + MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS, + new InvocationTargetException(new IllegalStateException("no AWS region configured")))); + + HoodieException exception = assertThrows(HoodieException.class, + () -> MetricsReporterFactory.createReporter(metricsConfig, registry)); + assertEquals("Unable to instantiate class " + MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS, + exception.getMessage(), + "A failure that is neither a missing class nor a missing constructor must not be rewritten"); + } + } + @Test void metricsReporterFactoryShouldReturnUserDefinedReporter() { when(metricsConfig.getMetricReporterClassName()).thenReturn(DummyMetricsReporter.class.getName()); @@ -115,6 +263,17 @@ void metricsReporterFactoryShouldThrowExceptionWhenMetricsReporterClassIsIllegal assertThrows(HoodieException.class, () -> MetricsReporterFactory.createReporter(metricsConfig, registry)); } + private HoodieException captureCloudWatchFailure(Throwable reflectionFailure) { + when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH); + try (MockedStatic mockedStatic = Mockito.mockStatic(ReflectionUtils.class)) { + mockedStatic.when(() -> ReflectionUtils.loadClass( + eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), any(Class[].class), eq(metricsConfig), eq(registry))) + .thenThrow(reflectionFailure); + return assertThrows(HoodieException.class, + () -> MetricsReporterFactory.createReporter(metricsConfig, registry)); + } + } + public static class DummyMetricsReporter extends CustomizableMetricsReporter { public DummyMetricsReporter(Properties props, MetricRegistry registry) { diff --git a/hudi-common/src/test/java/org/apache/hudi/metrics/m3/TestM3ScopeReporterAdaptor.java b/hudi-common/src/test/java/org/apache/hudi/metrics/m3/TestM3ScopeReporterAdaptor.java new file mode 100644 index 0000000000000..a445e82a7f756 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/metrics/m3/TestM3ScopeReporterAdaptor.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.hudi.metrics.m3; + +import com.codahale.metrics.Counter; +import com.codahale.metrics.Histogram; +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import com.codahale.metrics.UniformReservoir; +import com.uber.m3.tally.Gauge; +import com.uber.m3.tally.Scope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies that {@link M3ScopeReporterAdaptor} maps codahale registry metrics + * onto the target m3 tally {@link Scope}. The scope is a mock so that the exact + * counters and gauges reaching it can be asserted without any network I/O. + */ +public class TestM3ScopeReporterAdaptor { + + private MetricRegistry registry; + private Scope scope; + private com.uber.m3.tally.Counter scopeCounter; + private Gauge scopeGauge; + private M3ScopeReporterAdaptor reporter; + + @BeforeEach + public void setUp() { + registry = new MetricRegistry(); + scope = mock(Scope.class); + scopeCounter = mock(com.uber.m3.tally.Counter.class); + scopeGauge = mock(Gauge.class); + when(scope.counter(org.mockito.ArgumentMatchers.anyString())).thenReturn(scopeCounter); + when(scope.gauge(org.mockito.ArgumentMatchers.anyString())).thenReturn(scopeGauge); + reporter = new M3ScopeReporterAdaptor(registry, scope); + } + + @Test + public void testCounterIsForwardedToScope() { + Counter counter = registry.counter("requests"); + counter.inc(7); + + reporter.report(); + + verify(scope).counter("requests"); + verify(scopeCounter).inc(7L); + } + + @Test + public void testGaugeValueIsForwardedToScope() { + registry.register("in_flight", (com.codahale.metrics.Gauge) () -> 42); + + reporter.report(); + + verify(scope).gauge("in_flight"); + verify(scopeGauge).update(42.0d); + } + + @Test + public void testHistogramEmitsCountAndSnapshotGauges() { + Histogram histogram = new Histogram(new UniformReservoir()); + registry.register("latency", histogram); + histogram.update(10); + histogram.update(20); + + reporter.report(); + + // count is emitted as its own gauge suffix + verify(scope).gauge("latency.count"); + // the snapshot expands into ten percentile/stat gauges + verify(scope).gauge("latency.max"); + verify(scope).gauge("latency.mean"); + verify(scope).gauge("latency.min"); + verify(scope).gauge("latency.stddev"); + verify(scope).gauge("latency.p50"); + verify(scope).gauge("latency.p75"); + verify(scope).gauge("latency.p95"); + verify(scope).gauge("latency.p98"); + verify(scope).gauge("latency.p99"); + verify(scope).gauge("latency.p999"); + // no bare "latency" gauge, only the suffixed ones + verify(scope, never()).gauge("latency"); + } + + @Test + public void testMeterEmitsCountCounterAndRateGauges() { + Meter meter = registry.meter("throughput"); + meter.mark(5); + + reporter.report(); + + verify(scope).counter("throughput.count"); + verify(scopeCounter).inc(5L); + verify(scope).gauge("throughput.m1_rate"); + verify(scope).gauge("throughput.m5_rate"); + verify(scope).gauge("throughput.m15_rate"); + verify(scope).gauge("throughput.mean_rate"); + } + + @Test + public void testTimerEmitsBothMeteredAndSnapshotMetrics() { + Timer timer = registry.timer("op_time"); + timer.update(java.time.Duration.ofMillis(3)); + timer.update(java.time.Duration.ofMillis(9)); + + reporter.report(); + + // timer reports the metered count as a counter + verify(scope).counter("op_time.count"); + // and the four rate gauges from the metered portion + verify(scope).gauge("op_time.m1_rate"); + verify(scope).gauge("op_time.mean_rate"); + // plus the full snapshot set from the timer's histogram + verify(scope).gauge("op_time.max"); + verify(scope).gauge("op_time.p99"); + } + + @Test + public void testEmptyRegistryTouchesNothing() { + reporter.report(); + + verify(scope, never()).counter(org.mockito.ArgumentMatchers.anyString()); + verify(scope, never()).gauge(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void testMultipleGaugesEachMapped() { + registry.register("g1", (com.codahale.metrics.Gauge) () -> 1L); + registry.register("g2", (com.codahale.metrics.Gauge) () -> 2L); + + reporter.report(); + + verify(scope).gauge("g1"); + verify(scope).gauge("g2"); + verify(scopeGauge).update(1.0d); + verify(scopeGauge).update(2.0d); + verify(scope, times(2)).gauge(org.mockito.ArgumentMatchers.startsWith("g")); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/stats/TestValueType.java b/hudi-common/src/test/java/org/apache/hudi/stats/TestValueType.java index d49664525583a..8c2ce71f657ba 100644 --- a/hudi-common/src/test/java/org/apache/hudi/stats/TestValueType.java +++ b/hudi-common/src/test/java/org/apache/hudi/stats/TestValueType.java @@ -19,9 +19,31 @@ package org.apache.hudi.stats; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaType; + +import org.apache.avro.generic.GenericData; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.UUID; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class TestValueType { @@ -54,4 +76,211 @@ public void testValueTypeNumbering() { // IN THE FUTURE assertEquals(21, ValueType.values().length); } + + @Test + public void testFromOrdinalRoundTrips() { + for (ValueType type : ValueType.values()) { + assertSame(type, ValueType.fromOrdinal(type.ordinal())); + } + } + + private static Comparable standardize(ValueType type, Object val) { + return type.standardizeJavaTypeAndPromote(val, ValueMetadata.NULL_METADATA); + } + + @Test + public void testCastToInteger() { + assertNull(standardize(ValueType.INT, null)); + assertEquals(7, standardize(ValueType.INT, 7)); + assertEquals(1, standardize(ValueType.INT, Boolean.TRUE)); + assertEquals(0, standardize(ValueType.INT, Boolean.FALSE)); + // best effort parse from a string representation + assertEquals(42, standardize(ValueType.INT, "42")); + } + + @Test + public void testCastToLong() { + assertEquals(5L, standardize(ValueType.LONG, 5)); + assertEquals(9L, standardize(ValueType.LONG, 9L)); + assertEquals(1L, standardize(ValueType.LONG, Boolean.TRUE)); + assertEquals(123L, standardize(ValueType.LONG, "123")); + } + + @Test + public void testCastToFloatAndDouble() { + assertEquals(3.0f, standardize(ValueType.FLOAT, 3)); + assertEquals(4.0f, standardize(ValueType.FLOAT, 4L)); + assertEquals(2.5f, standardize(ValueType.FLOAT, 2.5f)); + assertEquals(1.0f, standardize(ValueType.FLOAT, Boolean.TRUE)); + assertEquals(6.0d, standardize(ValueType.DOUBLE, 6)); + assertEquals(7.0d, standardize(ValueType.DOUBLE, 7L)); + assertEquals(0.0d, standardize(ValueType.DOUBLE, Boolean.FALSE)); + assertEquals(8.25d, standardize(ValueType.DOUBLE, 8.25d)); + } + + @Test + public void testCastToBoolean() { + assertEquals(Boolean.TRUE, ValueType.BOOLEAN.standardizeJavaTypeAndPromote(true, ValueMetadata.NULL_METADATA)); + assertThrows(UnsupportedOperationException.class, + () -> ValueType.BOOLEAN.standardizeJavaTypeAndPromote("nope", ValueMetadata.NULL_METADATA)); + } + + @Test + public void testCastToString() { + assertEquals("abc", ValueType.castToString("abc")); + assertEquals("11", ValueType.castToString(11)); + assertEquals("true", ValueType.castToString(Boolean.TRUE)); + assertEquals("bin", ValueType.castToString(Binary.fromString("bin"))); + assertThrows(UnsupportedOperationException.class, () -> ValueType.castToString(new Object())); + } + + @Test + public void testCastToBytesFromVariousSources() { + byte[] raw = "hello".getBytes(StandardCharsets.UTF_8); + assertEquals(ByteBuffer.wrap(raw), ValueType.castToBytes(ByteBuffer.wrap(raw))); + assertEquals(ByteBuffer.wrap(raw), ValueType.castToBytes(raw)); + assertEquals(ByteBuffer.wrap(raw), ValueType.castToBytes(Binary.fromConstantByteArray(raw))); + assertEquals(ByteBuffer.wrap(raw), ValueType.castToBytes("hello")); + assertThrows(UnsupportedOperationException.class, () -> ValueType.castToBytes(new Object())); + } + + @Test + public void testCastToFixedRejectsString() { + byte[] raw = {1, 2, 3}; + assertEquals(ByteBuffer.wrap(raw), ValueType.castToFixed(raw)); + // castToFixed, unlike castToBytes, does not accept String + assertThrows(UnsupportedOperationException.class, () -> ValueType.castToFixed("abc")); + } + + @Test + public void testDecimalRoundTrip() { + ValueMetadata.DecimalMetadata meta = ValueMetadata.DecimalMetadata.create(10, 2); + BigDecimal value = new BigDecimal("12.34"); + // fromDecimal produces the primitive representation, toDecimal reverses it + ByteBuffer primitive = ValueType.fromDecimal(value, meta); + BigDecimal roundTripped = ValueType.toDecimal(primitive, meta); + assertEquals(value, roundTripped); + // castToDecimal accepts an already-typed BigDecimal unchanged + assertEquals(value, ValueType.castToDecimal(value, meta)); + // integer input scaled by the metadata scale + assertEquals(new BigDecimal("1.00"), ValueType.castToDecimal(100, meta)); + assertThrows(UnsupportedOperationException.class, () -> ValueType.castToDecimal(new Object(), meta)); + } + + @Test + public void testUuidRoundTrip() { + UUID uuid = UUID.fromString("12345678-1234-1234-1234-1234567890ab"); + assertEquals(uuid, ValueType.castToUUID(uuid, ValueMetadata.NULL_METADATA)); + assertEquals(uuid, ValueType.castToUUID(uuid.toString(), ValueMetadata.NULL_METADATA)); + String primitive = ValueType.fromUUID(uuid, ValueMetadata.NULL_METADATA); + assertEquals(uuid, ValueType.toUUID(primitive, ValueMetadata.NULL_METADATA)); + assertThrows(UnsupportedOperationException.class, + () -> ValueType.castToUUID(1, ValueMetadata.NULL_METADATA)); + } + + @Test + public void testDateRoundTrip() { + LocalDate date = LocalDate.of(2020, 1, 2); + int epochDay = (int) date.toEpochDay(); + assertEquals(date, ValueType.castToDate(date, ValueMetadata.NULL_METADATA)); + assertEquals(date, ValueType.castToDate(epochDay, ValueMetadata.NULL_METADATA)); + assertEquals(date, ValueType.castToDate(java.sql.Date.valueOf(date), ValueMetadata.NULL_METADATA)); + assertEquals(epochDay, ValueType.fromDate(date, ValueMetadata.NULL_METADATA)); + assertEquals(date, ValueType.toDate(epochDay, ValueMetadata.NULL_METADATA)); + assertThrows(UnsupportedOperationException.class, + () -> ValueType.castToDate("2020-01-02", ValueMetadata.NULL_METADATA)); + } + + @Test + public void testTimeMillisAndMicrosRoundTrip() { + LocalTime time = LocalTime.of(1, 2, 3, 4_000_000); + int millisOfDay = time.toSecondOfDay() * 1000 + time.getNano() / 1_000_000; + assertEquals(time, ValueType.castToTimeMillis(time, ValueMetadata.NULL_METADATA)); + assertEquals(time, ValueType.castToTimeMillis(millisOfDay, ValueMetadata.NULL_METADATA)); + assertEquals(millisOfDay, ValueType.fromTimeMillis(time, ValueMetadata.NULL_METADATA)); + assertEquals(time, ValueType.toTimeMillis(millisOfDay, ValueMetadata.NULL_METADATA)); + + LocalTime microTime = LocalTime.of(4, 5, 6, 7_000); + long microsOfDay = microTime.toSecondOfDay() * 1_000_000L + microTime.getNano() / 1_000; + assertEquals(microTime, ValueType.castToTimeMicros(microsOfDay, ValueMetadata.NULL_METADATA)); + assertEquals(microsOfDay, ValueType.fromTimeMicros(microTime, ValueMetadata.NULL_METADATA)); + } + + @Test + public void testTimestampMillisMicrosNanosRoundTrip() { + Instant instant = Instant.ofEpochMilli(1_600_000_000_123L); + assertEquals(instant, ValueType.castToTimestampMillis(instant, ValueMetadata.NULL_METADATA)); + assertEquals(instant, ValueType.castToTimestampMillis(Timestamp.from(instant), ValueMetadata.NULL_METADATA)); + assertEquals(instant, ValueType.castToTimestampMillis(instant.toEpochMilli(), ValueMetadata.NULL_METADATA)); + assertEquals(instant.toEpochMilli(), ValueType.fromTimestampMillis(instant, ValueMetadata.NULL_METADATA)); + assertEquals(instant, ValueType.toTimestampMillis(instant.toEpochMilli(), ValueMetadata.NULL_METADATA)); + + Instant micros = Instant.ofEpochSecond(1_600_000_000L, 123_000L); + long microVal = ValueType.fromTimestampMicros(micros, ValueMetadata.NULL_METADATA); + assertEquals(micros, ValueType.toTimestampMicros(microVal, ValueMetadata.NULL_METADATA)); + + Instant nanos = Instant.ofEpochSecond(1_600_000_000L, 123_456L); + long nanoVal = ValueType.fromTimestampNanos(nanos, ValueMetadata.NULL_METADATA); + assertEquals(nanos, ValueType.toTimestampNanos(nanoVal, ValueMetadata.NULL_METADATA)); + } + + @Test + public void testLocalTimestampRoundTrip() { + LocalDateTime local = LocalDateTime.of(2021, 5, 6, 7, 8, 9, 10_000_000); + long millis = ValueType.fromLocalTimestampMillis(local, ValueMetadata.NULL_METADATA); + assertEquals(local, ValueType.toLocalTimestampMillis(millis, ValueMetadata.NULL_METADATA)); + assertEquals(local, ValueType.castToLocalTimestampMillis(local, ValueMetadata.NULL_METADATA)); + assertEquals(local, + ValueType.castToLocalTimestampMillis(local.toInstant(ZoneOffset.UTC).toEpochMilli(), ValueMetadata.NULL_METADATA)); + + LocalDateTime micros = LocalDateTime.of(2021, 5, 6, 7, 8, 9, 11_000); + long microVal = ValueType.fromLocalTimestampMicros(micros, ValueMetadata.NULL_METADATA); + assertEquals(micros, ValueType.toLocalTimestampMicros(microVal, ValueMetadata.NULL_METADATA)); + + LocalDateTime nanos = LocalDateTime.of(2021, 5, 6, 7, 8, 9, 12_345); + long nanoVal = ValueType.fromLocalTimestampNanos(nanos, ValueMetadata.NULL_METADATA); + assertEquals(nanos, ValueType.toLocalTimestampNanos(nanoVal, ValueMetadata.NULL_METADATA)); + } + + @Test + public void testFromParquetPrimitiveType() { + assertEquals(ValueType.LONG, valueTypeFor(PrimitiveType.PrimitiveTypeName.INT64)); + assertEquals(ValueType.INT, valueTypeFor(PrimitiveType.PrimitiveTypeName.INT32)); + assertEquals(ValueType.BOOLEAN, valueTypeFor(PrimitiveType.PrimitiveTypeName.BOOLEAN)); + assertEquals(ValueType.BYTES, valueTypeFor(PrimitiveType.PrimitiveTypeName.BINARY)); + assertEquals(ValueType.FLOAT, valueTypeFor(PrimitiveType.PrimitiveTypeName.FLOAT)); + assertEquals(ValueType.DOUBLE, valueTypeFor(PrimitiveType.PrimitiveTypeName.DOUBLE)); + } + + private static ValueType valueTypeFor(PrimitiveType.PrimitiveTypeName name) { + PrimitiveType type = name == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY + ? Types.required(name).length(4).named("f") + : Types.required(name).named("f"); + return ValueType.fromParquetPrimitiveType(type); + } + + @Test + public void testFromSchemaPrimitives() { + assertEquals(ValueType.INT, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.INT))); + assertEquals(ValueType.LONG, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.LONG))); + assertEquals(ValueType.STRING, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.STRING))); + assertEquals(ValueType.BOOLEAN, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.BOOLEAN))); + assertEquals(ValueType.DATE, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.DATE))); + assertEquals(ValueType.UUID, ValueType.fromSchema(HoodieSchema.create(HoodieSchemaType.UUID))); + } + + @Test + public void testFromSchemaUnwrapsUnion() { + HoodieSchema nullableInt = HoodieSchema.createNullable(HoodieSchemaType.INT); + assertEquals(ValueType.INT, ValueType.fromSchema(nullableInt)); + } + + @Test + public void testCastToBytesFromFixed() { + byte[] raw = {9, 8, 7}; + GenericData.Fixed fixed = new GenericData.Fixed( + org.apache.avro.Schema.createFixed("f", null, null, raw.length), raw); + assertEquals(ByteBuffer.wrap(raw), ValueType.castToBytes(fixed)); + assertTrue(ValueType.castToBytes(fixed).hasArray()); + } } diff --git a/hudi-common/src/test/resources/variant_backward_compat/variant_mor_avro.zip b/hudi-common/src/test/resources/variant_backward_compat/variant_mor_avro.zip index 59244af7dd226..610188de288bf 100644 Binary files a/hudi-common/src/test/resources/variant_backward_compat/variant_mor_avro.zip and b/hudi-common/src/test/resources/variant_backward_compat/variant_mor_avro.zip differ diff --git a/hudi-examples/hudi-examples-flink/pom.xml b/hudi-examples/hudi-examples-flink/pom.xml index 75a21c17053a0..da370499262c2 100644 --- a/hudi-examples/hudi-examples-flink/pom.xml +++ b/hudi-examples/hudi-examples-flink/pom.xml @@ -194,12 +194,6 @@ ${flink.version} provided - - org.apache.flink - ${flink.table.planner.artifactId} - ${flink.version} - provided - org.apache.flink ${flink.statebackend.rocksdb.artifactId} @@ -371,6 +365,13 @@ test test-jar + + + org.apache.flink + ${flink.table.planner.artifactId} + ${flink.version} + test + org.apache.flink flink-csv diff --git a/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/common/RandomJsonSource.java b/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/common/RandomJsonSource.java index c8fcd6b04e2a3..97c0a5ec4fa7d 100644 --- a/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/common/RandomJsonSource.java +++ b/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/common/RandomJsonSource.java @@ -37,6 +37,8 @@ import java.util.List; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; + public class RandomJsonSource extends JsonSource { private final HoodieExampleDataGenerator dataGen; private final TimeGenerator timeGenerator; @@ -54,6 +56,6 @@ protected InputBatch> readFromCheckpoint(Option last String commitTime = TimelineUtils.generateInstantTime(true, timeGenerator); List inserts = dataGen.convertToStringList(dataGen.generateInserts(commitTime, 20)); - return new InputBatch<>(Option.of(sparkContext.parallelize(inserts, 1)), commitTime); + return new InputBatch<>(Option.of(sparkContext.parallelize(inserts, 1)), createCheckpoint(commitTime)); } } diff --git a/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/spark/HoodieWriteClientExample.java b/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/spark/HoodieWriteClientExample.java index e4300af3c5efc..1f431563aa5f0 100644 --- a/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/spark/HoodieWriteClientExample.java +++ b/hudi-examples/hudi-examples-spark/src/main/java/org/apache/hudi/examples/spark/HoodieWriteClientExample.java @@ -101,7 +101,7 @@ public static void main(String[] args) throws Exception { // inserts String newCommitTime = client.startCommit(); - log.info("Starting commit " + newCommitTime); + log.info("Starting commit {}", newCommitTime); List> records = dataGen.generateInserts(newCommitTime, 10); List> recordsSoFar = new ArrayList<>(records); @@ -110,7 +110,7 @@ public static void main(String[] args) throws Exception { // updates newCommitTime = client.startCommit(); - log.info("Starting commit " + newCommitTime); + log.info("Starting commit {}", newCommitTime); List> toBeUpdated = dataGen.generateUpdates(newCommitTime, 2); records.addAll(toBeUpdated); recordsSoFar.addAll(toBeUpdated); @@ -119,7 +119,7 @@ public static void main(String[] args) throws Exception { // Delete newCommitTime = client.startCommit(); - log.info("Starting commit " + newCommitTime); + log.info("Starting commit {}", newCommitTime); // just delete half of the records int numToDelete = recordsSoFar.size() / 2; List toBeDeleted = recordsSoFar.stream().map(HoodieRecord::getKey).limit(numToDelete).collect(Collectors.toList()); @@ -128,7 +128,7 @@ public static void main(String[] args) throws Exception { // Delete by partition newCommitTime = client.startCommit(HoodieTimeline.REPLACE_COMMIT_ACTION); - log.info("Starting commit " + newCommitTime); + log.info("Starting commit {}", newCommitTime); // The partition where the data needs to be deleted List partitionList = toBeDeleted.stream().map(s -> s.getPartitionPath()).distinct().collect(Collectors.toList()); List deleteList = recordsSoFar.stream().filter(f -> !partitionList.contains(f.getPartitionPath())) diff --git a/hudi-examples/hudi-examples-spark/src/test/java/org/apache/hudi/examples/common/TestRandomJsonSource.java b/hudi-examples/hudi-examples-spark/src/test/java/org/apache/hudi/examples/common/TestRandomJsonSource.java new file mode 100644 index 0000000000000..0691834abcd30 --- /dev/null +++ b/hudi-examples/hudi-examples-spark/src/test/java/org/apache/hudi/examples/common/TestRandomJsonSource.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.hudi.examples.common; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.utilities.sources.InputBatch; + +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for RandomJsonSource. + */ +class TestRandomJsonSource { + + private static JavaSparkContext jsc; + private static SparkSession spark; + + @BeforeAll + static void initSpark() { + spark = SparkSession.builder().master("local[1]").appName("TestRandomJsonSource").getOrCreate(); + jsc = new JavaSparkContext(spark.sparkContext()); + } + + @AfterAll + static void stopSpark() { + if (jsc != null) { + jsc.stop(); + } + if (spark != null) { + spark.stop(); + } + } + + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testFetchNextReturnsTwentyRecordsWithCorrectCheckpointVersion(int writeTableVersion) { + TypedProperties props = new TypedProperties(); + props.setProperty(WRITE_TABLE_VERSION.key(), String.valueOf(writeTableVersion)); + + RandomJsonSource source = new RandomJsonSource(props, jsc, spark, null); + InputBatch> batch = source.fetchNext(Option.empty(), Long.MAX_VALUE); + + assertNotNull(batch.getBatch()); + assertEquals(20, batch.getBatch().get().count()); + assertNotNull(batch.getCheckpointForNextBatch()); + assertEquals(StreamerCheckpointV1.class, batch.getCheckpointForNextBatch().getClass()); + } +} diff --git a/hudi-flink-datasource/hudi-flink/pom.xml b/hudi-flink-datasource/hudi-flink/pom.xml index 1c8f323d08006..2fb0525c85a51 100644 --- a/hudi-flink-datasource/hudi-flink/pom.xml +++ b/hudi-flink-datasource/hudi-flink/pom.xml @@ -78,7 +78,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -234,12 +234,6 @@ ${flink.version} provided - - org.apache.flink - ${flink.table.planner.artifactId} - ${flink.version} - provided - org.apache.flink ${flink.statebackend.rocksdb.artifactId} @@ -452,6 +446,13 @@ test test-jar + + + org.apache.flink + ${flink.table.planner.artifactId} + ${flink.version} + test + org.apache.flink flink-csv diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java index 4910c721e09cc..fbe29f93ef5b4 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java @@ -188,6 +188,12 @@ public class FlinkOptions extends HoodieConfig { .defaultValue(false) // keep sync with hoodie style .withDescription("If enabled, the checkpoint Id will also be written to hudi metadata."); + public static final ConfigOption TABLE_SERVICES_ENABLED = ConfigOptions + .key(HoodieWriteConfig.TABLE_SERVICES_ENABLED.key()) + .booleanType() + .defaultValue(HoodieWriteConfig.TABLE_SERVICES_ENABLED.defaultValue()) + .withDescription("Master control to disable all table services including archive, clean, compact, cluster, etc."); + // ------------------------------------------------------------------------ // Changelog Capture Options // ------------------------------------------------------------------------ diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsInference.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsInference.java index 6b5fddfacfab9..70a44b1df7c29 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsInference.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsInference.java @@ -149,8 +149,7 @@ public static void setupIndexConfigs(Configuration conf) { conf.set(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS, hashingConfig.getExpressions()); conf.set(FlinkOptions.BUCKET_INDEX_PARTITION_RULE, hashingConfig.getRule()); conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, hashingConfig.getDefaultBucketNumber()); - log.info("Loaded Latest Hashing Config " + hashingConfig - + ". Reset hoodie.bucket.index.num.buckets to " + hashingConfig.getDefaultBucketNumber()); + log.info("Loaded Latest Hashing Config {}. Reset hoodie.bucket.index.num.buckets to {}", hashingConfig, hashingConfig.getDefaultBucketNumber()); } } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java index 628474bde7d62..5956c185f24e0 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/OptionsResolver.java @@ -40,7 +40,10 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.bucket.partition.PartitionBucketIndexUtils; +import org.apache.hudi.keygen.KeyGenUtils; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; +import org.apache.hudi.metadata.HoodieTableMetadataUtil; +import org.apache.hudi.metadata.MetadataPartitionType; import org.apache.hudi.sink.buffer.BufferMemoryType; import org.apache.hudi.sink.overwrite.PartitionOverwriteMode; import org.apache.hudi.table.format.FilePathUtils; @@ -60,12 +63,22 @@ import java.util.Map; import static org.apache.hudi.common.config.HoodieCommonConfig.INCREMENTAL_READ_HANDLE_HOLLOW_COMMIT; +import static org.apache.hudi.common.config.HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP; +import static org.apache.hudi.common.config.HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP; +import static org.apache.hudi.common.config.HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP; +import static org.apache.hudi.common.config.HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP; +import static org.apache.hudi.common.config.HoodieMetadataConfig.RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP; +import static org.apache.hudi.common.config.HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP; +import static org.apache.hudi.metadata.HoodieBackedTableMetadataWriter.RECORD_INDEX_AVERAGE_RECORD_SIZE; /** * Tool helping to resolve the flink options {@link FlinkOptions}. */ public class OptionsResolver { + // Value to override the default minimum file group count for global record level index. + public static String GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_DEFAULT = "8"; + /** * Returns whether the current runtime mode is adaptive batch execution. */ @@ -168,21 +181,15 @@ public static String getRecordKeyStr(Configuration conf) { */ public static String[] getRecordKeys(Configuration conf) { final String recordKeyStr = conf.get(FlinkOptions.RECORD_KEY_FIELD); - if (StringUtils.isNullOrEmpty(recordKeyStr)) { - return new String[]{}; - } - return recordKeyStr.split(","); + return KeyGenUtils.getRecordKeyFields(recordKeyStr).toArray(new String[0]); } /** * Return the bucket index keys as an array. */ public static String[] getBucketIndexKeys(Configuration conf) { - final String indexKeyStr = conf.get(FlinkOptions.INDEX_KEY_FIELD); - if (StringUtils.isNullOrEmpty(indexKeyStr)) { - return new String[]{}; - } - return indexKeyStr.split(","); + final String indexKeyStr = getIndexKeyField(conf); + return KeyGenUtils.getIndexKeyFields(indexKeyStr).toArray(new String[0]); } /** @@ -232,6 +239,35 @@ public static boolean isGlobalRecordLevelIndex(Configuration conf) { return indexType == HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX; } + /** + * Estimates the file group count to use for RLI partition of a new table. + */ + public static int estimateFileGroupCountForRLI(Configuration conf) { + int minFileGroupCount; + int maxFileGroupCount; + if (OptionsResolver.isRecordLevelIndex(conf)) { + minFileGroupCount = Integer.parseInt(conf.getString(RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), + RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.defaultValue() + "")); + maxFileGroupCount = Integer.parseInt(conf.getString(RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(), + RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.defaultValue() + "")); + } else { + minFileGroupCount = Integer.parseInt(conf.getString(GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), + GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_DEFAULT)); + maxFileGroupCount = Integer.parseInt(conf.getString(GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(), + GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.defaultValue() + "")); + } + return HoodieTableMetadataUtil.estimateFileGroupCount( + MetadataPartitionType.RECORD_INDEX, + () -> 0L, + RECORD_INDEX_AVERAGE_RECORD_SIZE, + minFileGroupCount, + maxFileGroupCount, + Float.parseFloat(conf.getString(RECORD_INDEX_GROWTH_FACTOR_PROP.key(), + RECORD_INDEX_GROWTH_FACTOR_PROP.defaultValue() + "")), + Long.parseLong(conf.getString(RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(), + RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.defaultValue() + ""))); + } + /** * Returns whether it is a MERGE_ON_READ table, and updates by bucket index. */ @@ -292,7 +328,7 @@ public static boolean emitDeletes(Configuration conf) { * @param conf The flink configuration. */ public static boolean needsAsyncCompaction(Configuration conf) { - return OptionsResolver.isMorTable(conf) && conf.get(FlinkOptions.COMPACTION_ASYNC_ENABLED); + return OptionsResolver.isMorTable(conf) && areTableServicesEnabled(conf) && conf.get(FlinkOptions.COMPACTION_ASYNC_ENABLED); } /** @@ -301,7 +337,7 @@ public static boolean needsAsyncCompaction(Configuration conf) { * @param conf The flink configuration. */ public static boolean needsAsyncMetadataCompaction(Configuration conf) { - return isStreamingIndexWriteEnabled(conf) && conf.get(FlinkOptions.METADATA_COMPACTION_ASYNC_ENABLED); + return isStreamingIndexWriteEnabled(conf) && areTableServicesEnabled(conf) && conf.get(FlinkOptions.METADATA_COMPACTION_ASYNC_ENABLED); } /** @@ -310,7 +346,7 @@ public static boolean needsAsyncMetadataCompaction(Configuration conf) { * @param conf The flink configuration. */ public static boolean needsScheduleMdtCompaction(Configuration conf) { - return isStreamingIndexWriteEnabled(conf) && conf.get(FlinkOptions.METADATA_COMPACTION_SCHEDULE_ENABLED); + return isStreamingIndexWriteEnabled(conf) && areTableServicesEnabled(conf) && conf.get(FlinkOptions.METADATA_COMPACTION_SCHEDULE_ENABLED); } /** @@ -320,7 +356,9 @@ public static boolean needsScheduleMdtCompaction(Configuration conf) { */ public static boolean needsScheduleCompaction(Configuration conf) { return OptionsResolver.isMorTable(conf) - && conf.get(FlinkOptions.COMPACTION_SCHEDULE_ENABLED) && !isAppendMode(conf); + && areTableServicesEnabled(conf) + && conf.get(FlinkOptions.COMPACTION_SCHEDULE_ENABLED) + && !isAppendMode(conf); } /** @@ -329,7 +367,7 @@ public static boolean needsScheduleCompaction(Configuration conf) { * @param conf The flink configuration. */ public static boolean needsAsyncClustering(Configuration conf) { - return isInsertOperation(conf) && conf.get(FlinkOptions.CLUSTERING_ASYNC_ENABLED); + return isInsertOperation(conf) && areTableServicesEnabled(conf) && conf.get(FlinkOptions.CLUSTERING_ASYNC_ENABLED); } /** @@ -338,6 +376,9 @@ public static boolean needsAsyncClustering(Configuration conf) { * @param conf The flink configuration. */ public static boolean needsScheduleClustering(Configuration conf) { + if (!areTableServicesEnabled(conf)) { + return false; + } if (!conf.get(FlinkOptions.CLUSTERING_SCHEDULE_ENABLED)) { return false; } @@ -501,6 +542,13 @@ public static String getIndexKeyField(Configuration conf) { return conf.getString(FlinkOptions.INDEX_KEY_FIELD.key(), getRecordKeyStr(conf)); } + /** + * Returns the index key fields as a list, parsing the comma-separated config value once. + */ + public static List getIndexKeyFields(Configuration conf) { + return KeyGenUtils.getIndexKeyFields(getIndexKeyField(conf)); + } + /** * Returns the conflict resolution strategy. */ @@ -544,6 +592,20 @@ public static boolean isNonBlockingConcurrencyControl(Configuration config) { return WriteConcurrencyMode.isNonBlockingConcurrencyControl(config.getString(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(), HoodieWriteConfig.WRITE_CONCURRENCY_MODE.defaultValue())); } + /** + * Returns whether the cleaning for failed writes is enabled as lazy. + */ + public static boolean isLazyFailedWritesCleaning(Configuration conf) { + return needsAsyncCleaning(conf) && isLazyFailedWritesCleanPolicy(conf); + } + + /** + * Returns whether there is need for async cleaning (planning & execution). + */ + public static boolean needsAsyncCleaning(Configuration conf) { + return areTableServicesEnabled(conf); + } + /** * Returns whether Cleaner's failed writes policy is set to lazy */ @@ -574,6 +636,13 @@ public static boolean isBlockingInstantGeneration(Configuration conf) { return (isCowTable(conf) || conf.get(FlinkOptions.CDC_ENABLED)) && isUpsertOperation(conf); } + /** + * Returns whether table services are enabled. + */ + public static boolean areTableServicesEnabled(Configuration conf) { + return conf.get(FlinkOptions.TABLE_SERVICES_ENABLED); + } + /** * Returns the customized insert partitioner instance. */ diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java index 7e9d8cfa4cf55..c0cc62bc66409 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java @@ -252,10 +252,14 @@ public void start() throws Exception { if (OptionsResolver.isMultiWriter(conf)) { initClientIds(conf); } - restoreEvents(); - } catch (Throwable throwable) { - log.error("Failed to start operator coordinator.", throwable); - context.failJob(throwable); + restoreEvents(Long.MAX_VALUE); + } catch (Exception exception) { + // Rethrow instead of context.failJob(): failJob triggers an in-graph global failover that + // keeps this same coordinator instance alive without calling start() again, leaving the + // half-initialized null fields (executor, writeClient, metaClient ...) to be reused and NPE + // later. Rethrowing surfaces the failure as a JobMaster start failure so the partially + // initialized instance is discarded rather than kept serving. + throw new HoodieException("Failed to start operator coordinator.", exception); } } @@ -325,14 +329,16 @@ public void notifyCheckpointComplete(long checkpointId) { @Override public void resetToCheckpoint(long checkpointID, byte[] checkpointData) { if (checkpointData != null) { - initEventBufferIfNecessary(); - this.eventBuffers.addEventsToBuffer(SerializationUtils.deserialize(checkpointData)); // resetToCheckpoint() is called in two cases: // 1. The job is restarted from state, start() will be called later. // 2. The job is recovered from global failover. The coordinator is already started, and start() will not be called again. - if (executor != null && tableState.isRecordLevelIndex) { - // use sync execution here to make sure the recommitting finishes before RLI bootstrapping - this.executor.executeSync(this::restoreEvents, "Recommit pending instants on resetting to checkpoint: %s.", checkpointID); + if (this.eventBuffers == null) { + // case1: the events restore is moved to start() since it requires the write/meta client instantiation. + initEventBuffer(); + this.eventBuffers.addEventsToBuffer(SerializationUtils.deserialize(checkpointData)); + } else { + // case2: restore the events directly. + restoreEvents(checkpointID); } } } @@ -433,10 +439,11 @@ private CompletableFuture handleInFlightInstantsRequest(Co // Utilities // ------------------------------------------------------------------------- - private void restoreEvents() { + private void restoreEvents(long checkpointId) { if (this.eventBuffers.nonEmpty()) { - final HoodieTimeline completedTimeline = this.metaClient.getActiveTimeline().filterCompletedInstants(); + final HoodieTimeline completedTimeline = this.metaClient.reloadActiveTimeline().filterCompletedInstants(); this.eventBuffers.getEventBufferStream() + .filter(entry -> entry.getKey() < checkpointId) .forEach(entry -> recommitInstant(completedTimeline, entry.getKey(), entry.getValue().getLeft(), entry.getValue().getRight())); this.metaClient.reloadActiveTimeline(); } @@ -503,6 +510,10 @@ private void initEventBufferIfNecessary() { if (this.eventBuffers != null) { return; } + initEventBuffer(); + } + + private void initEventBuffer() { // initialize event buffer this.eventBuffers = EventBuffers.getInstance(conf, this.parallelism); } @@ -544,13 +555,12 @@ private boolean recommitInstant(HoodieTimeline completedTimeline, long checkpoin log.info("Recommit instant {}", instant); // Recommit should start heartbeat for lazy failed writes clean policy to avoid aborting for heartbeat expired; // The following up checkpoints would recommit the instant. - if (writeClient.getConfig().getFailedWritesCleanPolicy().isLazy()) { - writeClient.getHeartbeatClient().start(instant); - } + writeClient.restartHeartbeat(instant); return commitInstant(checkpointId, instant, bootstrapBuffer); } else { // clean the corresponding event buffer if the instant is already committed. eventBuffers.reset(checkpointId); + writeClient.cleanResources(instant); return false; } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithBIMBufferSort.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithBIMBufferSort.java index 091f018c3c5ff..11017d9e4fc26 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithBIMBufferSort.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithBIMBufferSort.java @@ -33,7 +33,6 @@ import org.apache.flink.runtime.operators.sort.QuickSort; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator; import org.apache.flink.table.runtime.generated.GeneratedNormalizedKeyComputer; import org.apache.flink.table.runtime.generated.GeneratedRecordComparator; import org.apache.flink.table.runtime.operators.sort.BinaryInMemorySortBuffer; @@ -89,9 +88,8 @@ public void open(Configuration parameters) throws Exception { // Resolve sort keys (defaults to record key if not specified) List sortKeyList = AppendWriteFunctions.resolveSortKeys(config); SortOperatorGen sortOperatorGen = new SortOperatorGen(rowType, sortKeyList.toArray(new String[0])); - SortCodeGenerator codeGenerator = sortOperatorGen.createSortCodeGenerator(); - GeneratedNormalizedKeyComputer keyComputer = codeGenerator.generateNormalizedKeyComputer("SortComputer"); - GeneratedRecordComparator recordComparator = codeGenerator.generateRecordComparator("SortComparator"); + GeneratedNormalizedKeyComputer keyComputer = sortOperatorGen.generateNormalizedKeyComputer("SortComputer"); + GeneratedRecordComparator recordComparator = sortOperatorGen.generateRecordComparator("SortComparator"); this.memorySegmentPools = this.memorySegmentPoolFactory.createMemorySegmentPools(config, 2, OptionsResolver.getWriteBufferSizeInBytes(config)); this.activeBuffer = BufferUtils.createBuffer(rowType, diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithContinuousSort.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithContinuousSort.java index caffecbb34ba3..e1f03fdfb2a80 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithContinuousSort.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithContinuousSort.java @@ -31,7 +31,6 @@ import org.apache.flink.core.memory.MemorySegment; import org.apache.flink.core.memory.MemorySegmentFactory; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator; import org.apache.flink.table.runtime.typeutils.RowDataSerializer; import org.apache.flink.table.runtime.generated.GeneratedNormalizedKeyComputer; import org.apache.flink.table.runtime.generated.GeneratedRecordComparator; @@ -127,9 +126,8 @@ public void open(Configuration parameters) throws Exception { // Create sort code generator for normalized key computation and record comparison SortOperatorGen sortOperatorGen = new SortOperatorGen(rowType, sortKeyList.toArray(new String[0])); - SortCodeGenerator codeGenerator = sortOperatorGen.createSortCodeGenerator(); - GeneratedNormalizedKeyComputer generatedKeyComputer = codeGenerator.generateNormalizedKeyComputer("ContinuousSortKeyComputer"); - GeneratedRecordComparator generatedComparator = codeGenerator.generateRecordComparator("ContinuousSortComparator"); + GeneratedNormalizedKeyComputer generatedKeyComputer = sortOperatorGen.generateNormalizedKeyComputer("ContinuousSortKeyComputer"); + GeneratedRecordComparator generatedComparator = sortOperatorGen.generateRecordComparator("ContinuousSortComparator"); // Instantiate code-generated components ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithDisruptorBufferSort.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithDisruptorBufferSort.java index df99a71b9bbe0..347ad81da22d4 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithDisruptorBufferSort.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/append/AppendWriteFunctionWithDisruptorBufferSort.java @@ -35,7 +35,6 @@ import org.apache.flink.runtime.operators.sort.QuickSort; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator; import org.apache.flink.table.runtime.generated.GeneratedNormalizedKeyComputer; import org.apache.flink.table.runtime.generated.GeneratedRecordComparator; import org.apache.flink.table.runtime.operators.sort.BinaryInMemorySortBuffer; @@ -95,9 +94,8 @@ public void open(Configuration parameters) throws Exception { // Create Flink-native sort components SortOperatorGen sortOperatorGen = new SortOperatorGen(rowType, sortKeyList.toArray(new String[0])); - SortCodeGenerator codeGenerator = sortOperatorGen.createSortCodeGenerator(); - this.keyComputer = codeGenerator.generateNormalizedKeyComputer("SortComputer"); - this.recordComparator = codeGenerator.generateRecordComparator("SortComparator"); + this.keyComputer = sortOperatorGen.generateNormalizedKeyComputer("SortComputer"); + this.recordComparator = sortOperatorGen.generateRecordComparator("SortComparator"); this.memorySegmentPool = this.memorySegmentPoolFactory.createMemorySegmentPool(config, OptionsResolver.getWriteBufferSizeInBytes(config)); initDisruptorBuffer(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/RLIBootstrapOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/RLIBootstrapOperator.java index 515b180d3cad0..d0cb16a789e53 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/RLIBootstrapOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/RLIBootstrapOperator.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.function.SerializableFunctionUnchecked; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieRecordGlobalLocation; +import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.metadata.HoodieBackedTableMetadata; @@ -52,7 +53,7 @@ public class RLIBootstrapOperator extends AbstractBootstrapOperator { - private transient HoodieBackedTableMetadata metadataTable; + private transient HoodieBackedTableMetadata tableMetadata; private transient long loadedCnt; public RLIBootstrapOperator(Configuration conf) { @@ -63,13 +64,13 @@ public RLIBootstrapOperator(Configuration conf) { public void initializeState(StateInitializationContext context) throws Exception { loadedCnt = 0; HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(conf); - this.metadataTable = (HoodieBackedTableMetadata) metaClient.getTableFormat().getMetadataFactory().create( + this.tableMetadata = new HoodieBackedTableMetadata( HoodieFlinkEngineContext.DEFAULT, metaClient.getStorage(), StreamerUtil.metadataConfig(conf), conf.get(FlinkOptions.PATH)); // Load RLI records - preLoadRLIRecords(); + preLoadRLIRecords(metaClient.getTableConfig()); } @Override @@ -82,10 +83,20 @@ public void close() throws Exception { // Utilities // ------------------------------------------------------------------------- - private void preLoadRLIRecords() { + private void preLoadRLIRecords(HoodieTableConfig tableConfig) { int taskID = RuntimeContextUtils.getIndexOfThisSubtask(getRuntimeContext()); int parallelism = RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()); + if (!tableMetadata.enabled()) { + if (tableConfig.isMetadataTableAvailable()) { + throw new RuntimeException("Can not initialize the table metadata"); + } + log.info("Skip loading RLI records because table metadata is not initialized, taskId = {}", taskID); + waitForBootstrapReady(taskID); + closeMetadataTable(); + return; + } + log.info("Start loading RLI records from metadata table, taskId = {}, parallelism = {}", taskID, parallelism); SerializableFunctionUnchecked, List> fileSlicesFilter = fileSlices -> { @@ -102,7 +113,7 @@ private void preLoadRLIRecords() { // Each subtask loads buckets assigned to it long startTime = System.currentTimeMillis(); - HoodiePairData rliData = metadataTable.readRecordIndexLocations(fileSlicesFilter); + HoodiePairData rliData = tableMetadata.readRecordIndexLocations(fileSlicesFilter); rliData.forEach(locationPair -> emitIndexRecord(locationPair.getLeft(), locationPair.getRight())); long costMs = System.currentTimeMillis() - startTime; log.info("Finish loading RLI records, total records: {}, cost: {} ms, taskId = {}", loadedCnt, costMs, taskID); @@ -133,13 +144,13 @@ private void emitIndexRecord(String recordKey, HoodieRecordGlobalLocation locati } private void closeMetadataTable() { - if (metadataTable != null) { + if (tableMetadata != null) { try { - metadataTable.close(); + tableMetadata.close(); } catch (Exception e) { log.warn("Failed to close metadata table", e); } - metadataTable = null; + tableMetadata = null; } } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java index fad1f7e9272ba..34b4afd85457d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketBulkInsertWriterHelper.java @@ -19,7 +19,6 @@ package org.apache.hudi.sink.bucket; import org.apache.hudi.config.HoodieWriteConfig; -import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.index.bucket.BucketIdentifier; import org.apache.hudi.index.bucket.partition.NumBucketsFunction; import org.apache.hudi.io.storage.row.HoodieRowDataCreateHandle; @@ -38,6 +37,7 @@ import org.apache.flink.table.types.logical.RowType; import java.io.IOException; +import java.util.List; import java.util.Map; /** @@ -64,7 +64,7 @@ public void write(RowData tuple) throws IOException { String partitionPath = keyGen.getPartitionPath(record); String fileId = tuple.getString(0).toString(); if ((lastFileId == null) || !lastFileId.equals(fileId)) { - log.info("Creating new file for partition path " + partitionPath); + log.info("Creating new file for partition path {}", partitionPath); handle = getRowCreateHandle(partitionPath, fileId); lastFileId = fileId; } @@ -93,20 +93,19 @@ public static SortOperatorGen getFileIdSorterGen(RowType rowType) { return new SortOperatorGen(rowType, new String[] {FILE_GROUP_META_FIELD}); } - private static String getFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, String indexKeys, Configuration conf, boolean needFixedFileIdSuffix) { + private static String getFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, List indexKeyFields, + NumBucketsFunction numBucketsFunction, boolean needFixedFileIdSuffix) { String recordKey = keyGen.getRecordKey(record); String partition = keyGen.getPartitionPath(record); - NumBucketsFunction numBucketsFunction = new NumBucketsFunction(conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS), conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), - conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS)); - final int numBuckets = numBucketsFunction.getNumBuckets(partition); - final int bucketNum = BucketIdentifier.getBucketId(recordKey, indexKeys, numBuckets); + final int bucketNum = BucketIdentifier.getBucketId(recordKey, indexKeyFields, numBuckets); String bucketId = partition + bucketNum; return bucketIdToFileId.computeIfAbsent(bucketId, k -> needFixedFileIdSuffix ? BucketIdentifier.newBucketFileIdForNBCC(bucketNum) : BucketIdentifier.newBucketFileIdPrefix(bucketNum)); } - public static RowData rowWithFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, String indexKeys, Configuration conf, boolean needFixedFileIdSuffix) { - final String fileId = getFileId(bucketIdToFileId, keyGen, record, indexKeys, conf, needFixedFileIdSuffix); + public static RowData rowWithFileId(Map bucketIdToFileId, RowDataKeyGen keyGen, RowData record, List indexKeyFields, + NumBucketsFunction numBucketsFunction, boolean needFixedFileIdSuffix) { + final String fileId = getFileId(bucketIdToFileId, keyGen, record, indexKeyFields, numBucketsFunction, needFixedFileIdSuffix); return GenericRowData.of(StringData.fromString(fileId), record); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketStreamWriteFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketStreamWriteFunction.java index 6763400b02c39..0c732f6507a31 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketStreamWriteFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/BucketStreamWriteFunction.java @@ -39,6 +39,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -54,7 +55,9 @@ public class BucketStreamWriteFunction extends StreamWriteFunction { private int parallelism; - private String indexKeyFields; + // parsed once in open(); the per-record defineRecordLocation path uses the List overload of + // getBucketId so the comma-separated config string is not re-split per record + private List indexKeyFieldList; private boolean isNonBlockingConcurrencyControl; @@ -97,7 +100,7 @@ public BucketStreamWriteFunction(Configuration config, RowType rowType) { @Override public void open(Configuration parameters) throws IOException { super.open(parameters); - this.indexKeyFields = OptionsResolver.getIndexKeyField(config); + this.indexKeyFieldList = OptionsResolver.getIndexKeyFields(config); this.isNonBlockingConcurrencyControl = OptionsResolver.isNonBlockingConcurrencyControl(config); this.taskID = RuntimeContextUtils.getIndexOfThisSubtask(getRuntimeContext()); this.parallelism = RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext()); @@ -135,7 +138,7 @@ private void defineRecordLocation(HoodieFlinkInternalRow record) { bootstrapIndexIfNeed(partition); } Map bucketToFileId = bucketIndex.computeIfAbsent(partition, p -> new HashMap<>()); - final int bucketNum = BucketIdentifier.getBucketId(record.getRecordKey(), indexKeyFields, numBucketsFunction.getNumBuckets(record.getPartitionPath())); + final int bucketNum = BucketIdentifier.getBucketId(record.getRecordKey(), indexKeyFieldList, numBucketsFunction.getNumBuckets(record.getPartitionPath())); final String bucketId = partition + "/" + bucketNum; if (incBucketIndex.contains(bucketId)) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/ConsistentBucketAssignFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/ConsistentBucketAssignFunction.java index ec2fa104c58e8..089224ac985d1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/ConsistentBucketAssignFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bucket/ConsistentBucketAssignFunction.java @@ -68,7 +68,7 @@ public class ConsistentBucketAssignFunction extends ProcessFunctionAdapter indexKeyFields = Arrays.asList(config.get(FlinkOptions.INDEX_KEY_FIELD).split(",")); + List indexKeyFields = Arrays.asList(OptionsResolver.getBucketIndexKeys(config)); this.updateStrategy = new ConsistentBucketUpdateStrategy(this.writeClient, indexKeyFields); log.info("Create update strategy with index key fields: {}", indexKeyFields); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java index 8cb5ef9fdeb45..27f9b375de314 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriterHelper.java @@ -144,7 +144,7 @@ private HoodieRowDataCreateHandle getRowCreateHandle(String partitionPath) throw close(); } - log.info("Creating new file for partition path " + partitionPath); + log.info("Creating new file for partition path {}", partitionPath); writeMetrics.ifPresent(FlinkStreamWriteMetrics::startHandleCreation); HoodieRowDataCreateHandle rowCreateHandle = new HoodieRowDataCreateHandle(hoodieTable, writeConfig, partitionPath, getNextFileId(), instantTime, taskPartitionId, totalSubtaskNum, taskEpochId, rowType, preserveHoodieMetadata, isAppendMode && !populateMetaFields); @@ -154,7 +154,7 @@ private HoodieRowDataCreateHandle getRowCreateHandle(String partitionPath) throw } else if (!handles.get(partitionPath).canWrite()) { // even if there is a handle to the partition path, it could have reached its max size threshold. So, we close the handle here and // create a new one. - log.info("Rolling max-size file for partition path " + partitionPath); + log.info("Rolling max-size file for partition path {}", partitionPath); writeStatusList.add(closeWriteHandle(handles.remove(partitionPath))); HoodieRowDataCreateHandle rowCreateHandle = createWriteHandle(partitionPath); handles.put(partitionPath, rowCreateHandle); @@ -172,7 +172,7 @@ public void close() throws IOException { allOf(handles.values().stream() .map(rowCreateHandle -> CompletableFuture.supplyAsync(() -> { try { - log.info("Closing bulk insert file " + rowCreateHandle.getFileName()); + log.info("Closing bulk insert file {}", rowCreateHandle.getFileName()); return rowCreateHandle.close(); } catch (IOException e) { throw new HoodieIOException("IOE during rowCreateHandle.close()", e); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/sort/SortOperatorGen.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/sort/SortOperatorGen.java index 068f7540f9538..2db08f9764562 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/sort/SortOperatorGen.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/sort/SortOperatorGen.java @@ -20,40 +20,449 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.operators.OneInputStreamOperator; -import org.apache.flink.table.api.TableConfig; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator; -import org.apache.flink.table.planner.plan.nodes.exec.spec.SortSpec; +import org.apache.flink.table.runtime.generated.GeneratedNormalizedKeyComputer; +import org.apache.flink.table.runtime.generated.GeneratedRecordComparator; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.TimestampType; +import org.apache.flink.table.types.logical.ZonedTimestampType; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; /** * Tools to generate the sort operator. */ public class SortOperatorGen { + private static final int MAX_NORMALIZED_KEY_BYTES = 16; + private static final int VARIABLE_LENGTH_NORMALIZED_KEY_BYTES = 8; + private final int[] sortIndices; private final RowType rowType; - private final TableConfig tableConfig = TableConfig.getDefault(); + private final RowData.FieldGetter[] fieldGetters; public SortOperatorGen(RowType rowType, String[] sortFields) { - this.sortIndices = Arrays.stream(sortFields).mapToInt(rowType::getFieldIndex).toArray(); this.rowType = rowType; + this.sortIndices = Arrays.stream(sortFields).mapToInt(field -> { + int index = rowType.getFieldIndex(field); + if (index < 0) { + throw new IllegalArgumentException("Can not find sort field '" + field + "' in row type " + rowType); + } + return index; + }).toArray(); + this.fieldGetters = Arrays.stream(sortIndices) + .mapToObj(index -> RowData.createFieldGetter(rowType.getTypeAt(index), index)) + .toArray(RowData.FieldGetter[]::new); } public OneInputStreamOperator createSortOperator(Configuration conf) { - SortCodeGenerator codeGen = createSortCodeGenerator(); return new SortOperator( - codeGen.generateNormalizedKeyComputer("SortComputer"), - codeGen.generateRecordComparator("SortComparator"), + generateNormalizedKeyComputer("SortComputer"), + generateRecordComparator("SortComparator"), conf); } - public SortCodeGenerator createSortCodeGenerator() { - SortSpec.SortSpecBuilder builder = SortSpec.builder(); + public GeneratedNormalizedKeyComputer generateNormalizedKeyComputer(String name) { + String className = generatedClassName(name); + return new GeneratedNormalizedKeyComputer(className, generateNormalizedKeyComputerCode(className)); + } + + public GeneratedRecordComparator generateRecordComparator(String name) { + String className = generatedClassName(name); + return new GeneratedRecordComparator(className, generateRecordComparatorCode(className), fieldGetters); + } + + private String generatedClassName(String name) { + String normalizedName = name.replaceAll("[^A-Za-z0-9_$]", "_"); + if (normalizedName.isEmpty() || !Character.isJavaIdentifierStart(normalizedName.charAt(0))) { + normalizedName = "_" + normalizedName; + } + int hash = 31 * Arrays.hashCode(sortIndices) + rowType.asSerializableString().hashCode(); + return normalizedName + "_" + Integer.toUnsignedString(hash); + } + + private String generateRecordComparatorCode(String className) { + StringBuilder code = new StringBuilder(); + code.append("public final class ").append(className) + .append(" implements org.apache.flink.table.runtime.generated.RecordComparator {\n") + .append(" private final Object[] references;\n") + .append(" public ").append(className).append("(Object[] references) {\n") + .append(" this.references = references;\n") + .append(" }\n") + .append(" @Override\n") + .append(" public int compare(org.apache.flink.table.data.RowData row1, ") + .append("org.apache.flink.table.data.RowData row2) {\n"); + for (int i = 0; i < sortIndices.length; i++) { + int sortIndex = sortIndices[i]; + code.append(" boolean isNull1_").append(i).append(" = row1.isNullAt(").append(sortIndex).append(");\n") + .append(" boolean isNull2_").append(i).append(" = row2.isNullAt(").append(sortIndex).append(");\n") + .append(" if (isNull1_").append(i).append(" || isNull2_").append(i).append(") {\n") + .append(" if (isNull1_").append(i).append(" && isNull2_").append(i).append(") {\n") + .append(" } else {\n") + .append(" return isNull1_").append(i).append(" ? 1 : -1;\n") + .append(" }\n") + .append(" } else {\n") + .append(" int cmp_").append(i).append(" = ").append(compareExpression(i, sortIndex)).append(";\n") + .append(" if (cmp_").append(i).append(" != 0) {\n") + .append(" return cmp_").append(i).append(";\n") + .append(" }\n") + .append(" }\n"); + } + code.append(" return 0;\n") + .append(" }\n") + .append(" private int compareFallback(org.apache.flink.table.data.RowData row1, ") + .append("org.apache.flink.table.data.RowData row2, int referenceIndex) {\n") + .append(" Object value1 = ((org.apache.flink.table.data.RowData.FieldGetter) references[referenceIndex])") + .append(".getFieldOrNull(row1);\n") + .append(" Object value2 = ((org.apache.flink.table.data.RowData.FieldGetter) references[referenceIndex])") + .append(".getFieldOrNull(row2);\n") + .append(" return compareValues(value1, value2);\n") + .append(" }\n") + .append(" private static int compareValues(Object value1, Object value2) {\n") + .append(" if (value1 == value2) {\n") + .append(" return 0;\n") + .append(" }\n") + .append(" if (value1 == null) {\n") + .append(" return 1;\n") + .append(" }\n") + .append(" if (value2 == null) {\n") + .append(" return -1;\n") + .append(" }\n") + .append(" if (value1 instanceof byte[] && value2 instanceof byte[]) {\n") + .append(" return compareUnsignedBytes((byte[]) value1, (byte[]) value2);\n") + .append(" }\n") + .append(" if (value1 instanceof java.lang.Comparable && value2 instanceof java.lang.Comparable) {\n") + .append(" return ((java.lang.Comparable) value1).compareTo(value2);\n") + .append(" }\n") + .append(" throw new IllegalArgumentException(\"Unsupported sort field value type: \" ") + .append("+ value1.getClass().getName());\n") + .append(" }\n") + .append(" private static int compareUnsignedBytes(byte[] bytes1, byte[] bytes2) {\n") + .append(" int len = java.lang.Math.min(bytes1.length, bytes2.length);\n") + .append(" for (int i = 0; i < len; i++) {\n") + .append(" int result = java.lang.Byte.toUnsignedInt(bytes1[i]) ") + .append("- java.lang.Byte.toUnsignedInt(bytes2[i]);\n") + .append(" if (result != 0) {\n") + .append(" return result;\n") + .append(" }\n") + .append(" }\n") + .append(" return bytes1.length - bytes2.length;\n") + .append(" }\n") + .append("}\n"); + return code.toString(); + } + + private String compareExpression(int referenceIndex, int sortIndex) { + LogicalType logicalType = rowType.getTypeAt(sortIndex); + switch (logicalType.getTypeRoot()) { + case BOOLEAN: + return "java.lang.Boolean.compare(row1.getBoolean(" + sortIndex + "), row2.getBoolean(" + sortIndex + "))"; + case TINYINT: + return "java.lang.Byte.compare(row1.getByte(" + sortIndex + "), row2.getByte(" + sortIndex + "))"; + case SMALLINT: + return "java.lang.Short.compare(row1.getShort(" + sortIndex + "), row2.getShort(" + sortIndex + "))"; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTERVAL_YEAR_MONTH: + return "java.lang.Integer.compare(row1.getInt(" + sortIndex + "), row2.getInt(" + sortIndex + "))"; + case BIGINT: + case INTERVAL_DAY_TIME: + return "java.lang.Long.compare(row1.getLong(" + sortIndex + "), row2.getLong(" + sortIndex + "))"; + case FLOAT: + return "java.lang.Float.compare(row1.getFloat(" + sortIndex + "), row2.getFloat(" + sortIndex + "))"; + case DOUBLE: + return "java.lang.Double.compare(row1.getDouble(" + sortIndex + "), row2.getDouble(" + sortIndex + "))"; + case CHAR: + case VARCHAR: + return "row1.getString(" + sortIndex + ").compareTo(row2.getString(" + sortIndex + "))"; + case BINARY: + case VARBINARY: + return "compareUnsignedBytes(row1.getBinary(" + sortIndex + "), row2.getBinary(" + sortIndex + "))"; + case DECIMAL: + DecimalType decimalType = (DecimalType) logicalType; + return "row1.getDecimal(" + sortIndex + ", " + decimalType.getPrecision() + ", " + + decimalType.getScale() + ").compareTo(row2.getDecimal(" + sortIndex + ", " + + decimalType.getPrecision() + ", " + decimalType.getScale() + "))"; + case TIMESTAMP_WITHOUT_TIME_ZONE: + return timestampCompareExpression(sortIndex, ((TimestampType) logicalType).getPrecision()); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return timestampCompareExpression(sortIndex, ((LocalZonedTimestampType) logicalType).getPrecision()); + case TIMESTAMP_WITH_TIME_ZONE: + return timestampCompareExpression(sortIndex, ((ZonedTimestampType) logicalType).getPrecision()); + default: + return "compareFallback(row1, row2, " + referenceIndex + ")"; + } + } + + private String timestampCompareExpression(int sortIndex, int precision) { + return "row1.getTimestamp(" + sortIndex + ", " + precision + ").compareTo(row2.getTimestamp(" + + sortIndex + ", " + precision + "))"; + } + + private String generateNormalizedKeyComputerCode(String className) { + List normalizedKeyFields = normalizedKeyFields(); + int numKeyBytes = normalizedKeyFields.stream().mapToInt(NormalizedKeyField::totalBytes).sum(); + boolean fullyDetermines = normalizedKeyFields.size() == sortIndices.length + && normalizedKeyFields.stream().allMatch(NormalizedKeyField::fullyDetermines); + + StringBuilder code = new StringBuilder(); + code.append("public final class ").append(className) + .append(" implements org.apache.flink.table.runtime.generated.NormalizedKeyComputer {\n") + .append(" public ").append(className).append("(Object[] references) {\n") + .append(" }\n") + .append(" @Override\n") + .append(" public void putKey(org.apache.flink.table.data.RowData rowData, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset) {\n"); + int keyOffset = 0; + for (NormalizedKeyField normalizedKeyField : normalizedKeyFields) { + int valueOffset = keyOffset + 1; + int sortIndex = normalizedKeyField.sortIndex; + int valueBytes = normalizedKeyField.valueBytes; + code.append(" if (rowData.isNullAt(").append(sortIndex).append(")) {\n") + .append(" target.put(offset + ").append(keyOffset).append(", (byte) 1);\n") + .append(" zeroBytes(target, offset + ").append(valueOffset).append(", ") + .append(valueBytes).append(");\n") + .append(" } else {\n") + .append(" target.put(offset + ").append(keyOffset).append(", (byte) 0);\n") + .append(" ").append(normalizedKeyExpression(sortIndex, valueOffset, valueBytes)).append("\n") + .append(" }\n"); + keyOffset += normalizedKeyField.totalBytes(); + } + code.append(" }\n") + .append(" @Override\n") + .append(" public int compareKey(org.apache.flink.core.memory.MemorySegment memorySegment, int i, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset) {\n") + .append(" for (int j = 0; j < ").append(numKeyBytes).append("; j++) {\n") + .append(" int cmp = java.lang.Byte.toUnsignedInt(memorySegment.get(i + j))\n") + .append(" - java.lang.Byte.toUnsignedInt(target.get(offset + j));\n") + .append(" if (cmp != 0) {\n") + .append(" return cmp;\n") + .append(" }\n") + .append(" }\n") + .append(" return 0;\n") + .append(" }\n") + .append(" @Override\n") + .append(" public void swapKey(org.apache.flink.core.memory.MemorySegment seg1, int index1, ") + .append("org.apache.flink.core.memory.MemorySegment seg2, int index2) {\n") + .append(" for (int j = 0; j < ").append(numKeyBytes).append("; j++) {\n") + .append(" byte tmp = seg1.get(index1 + j);\n") + .append(" seg1.put(index1 + j, seg2.get(index2 + j));\n") + .append(" seg2.put(index2 + j, tmp);\n") + .append(" }\n") + .append(" }\n") + .append(" @Override\n") + .append(" public int getNumKeyBytes() {\n") + .append(" return ").append(numKeyBytes).append(";\n") + .append(" }\n") + .append(" @Override\n") + .append(" public boolean isKeyFullyDetermines() {\n") + .append(" return ").append(fullyDetermines).append(";\n") + .append(" }\n") + .append(" @Override\n") + .append(" public boolean invertKey() {\n") + .append(" return false;\n") + .append(" }\n") + .append(" private static void zeroBytes(org.apache.flink.core.memory.MemorySegment target, ") + .append("int offset, int numBytes) {\n") + .append(" for (int i = 0; i < numBytes; i++) {\n") + .append(" target.put(offset + i, (byte) 0);\n") + .append(" }\n") + .append(" }\n") + .append(" private static void putBytesNormalizedKey(byte[] bytes, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset, int numBytes) {\n") + .append(" int len = java.lang.Math.min(bytes.length, numBytes);\n") + .append(" for (int i = 0; i < len; i++) {\n") + .append(" target.put(offset + i, bytes[i]);\n") + .append(" }\n") + .append(" zeroBytes(target, offset + len, numBytes - len);\n") + .append(" }\n") + .append(" private static void putFloatNormalizedKey(float value, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset, int numBytes) {\n") + .append(" int bits = java.lang.Float.floatToIntBits(value);\n") + .append(" int normalized = bits >= 0 ? bits ^ java.lang.Integer.MIN_VALUE : ~bits;\n") + .append(" org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil") + .append(".putUnsignedIntegerNormalizedKey(normalized, target, offset, numBytes);\n") + .append(" }\n") + .append(" private static void putDoubleNormalizedKey(double value, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset, int numBytes) {\n") + .append(" long bits = java.lang.Double.doubleToLongBits(value);\n") + .append(" long normalized = bits >= 0 ? bits ^ java.lang.Long.MIN_VALUE : ~bits;\n") + .append(" org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil") + .append(".putUnsignedLongNormalizedKey(normalized, target, offset, numBytes);\n") + .append(" }\n") + .append(" private static void putTimestampNormalizedKey(org.apache.flink.table.data.TimestampData timestamp, ") + .append("org.apache.flink.core.memory.MemorySegment target, int offset, int numBytes) {\n") + .append(" org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil") + .append(".putLongNormalizedKey(timestamp.getMillisecond(), target, offset, java.lang.Math.min(numBytes, 8));\n") + .append(" if (numBytes > 8) {\n") + .append(" org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil") + .append(".putIntNormalizedKey(timestamp.getNanoOfMillisecond(), target, offset + 8, numBytes - 8);\n") + .append(" }\n") + .append(" }\n") + .append("}\n"); + return code.toString(); + } + + private List normalizedKeyFields() { + List normalizedKeyFields = new ArrayList<>(); + int remainingBytes = MAX_NORMALIZED_KEY_BYTES; for (int sortIndex : sortIndices) { - builder.addField(sortIndex, true, true); + LogicalType logicalType = rowType.getTypeAt(sortIndex); + int maxValueBytes = maxNormalizedKeyValueBytes(logicalType); + if (maxValueBytes <= 0 || remainingBytes <= 1) { + break; + } + + int valueBytes = Math.min(maxValueBytes, remainingBytes - 1); + boolean fullyDetermines = isFixedLengthNormalizedKey(logicalType) && valueBytes == maxValueBytes; + normalizedKeyFields.add(new NormalizedKeyField(sortIndex, valueBytes, fullyDetermines)); + remainingBytes -= valueBytes + 1; + if (!fullyDetermines) { + break; + } + } + return normalizedKeyFields; + } + + private int maxNormalizedKeyValueBytes(LogicalType logicalType) { + switch (logicalType.getTypeRoot()) { + case BOOLEAN: + case TINYINT: + return 1; + case SMALLINT: + return 2; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTERVAL_YEAR_MONTH: + case FLOAT: + return 4; + case BIGINT: + case INTERVAL_DAY_TIME: + case DOUBLE: + return 8; + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return VARIABLE_LENGTH_NORMALIZED_KEY_BYTES; + case DECIMAL: + return org.apache.flink.table.data.DecimalData.isCompact(((DecimalType) logicalType).getPrecision()) ? 8 : 0; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + case TIMESTAMP_WITH_TIME_ZONE: + return 12; + default: + return 0; + } + } + + private boolean isFixedLengthNormalizedKey(LogicalType logicalType) { + switch (logicalType.getTypeRoot()) { + case BOOLEAN: + case TINYINT: + case SMALLINT: + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTERVAL_YEAR_MONTH: + case FLOAT: + case BIGINT: + case INTERVAL_DAY_TIME: + case DOUBLE: + case DECIMAL: + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + case TIMESTAMP_WITH_TIME_ZONE: + return true; + default: + return false; + } + } + + private String normalizedKeyExpression(int sortIndex, int valueOffset, int valueBytes) { + LogicalType logicalType = rowType.getTypeAt(sortIndex); + String offset = "offset + " + valueOffset; + switch (logicalType.getTypeRoot()) { + case BOOLEAN: + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putBooleanNormalizedKey(" + + "rowData.getBoolean(" + sortIndex + "), target, " + offset + ", " + valueBytes + ");"; + case TINYINT: + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putByteNormalizedKey(" + + "rowData.getByte(" + sortIndex + "), target, " + offset + ", " + valueBytes + ");"; + case SMALLINT: + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putShortNormalizedKey(" + + "rowData.getShort(" + sortIndex + "), target, " + offset + ", " + valueBytes + ");"; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTERVAL_YEAR_MONTH: + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putIntNormalizedKey(" + + "rowData.getInt(" + sortIndex + "), target, " + offset + ", " + valueBytes + ");"; + case BIGINT: + case INTERVAL_DAY_TIME: + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putLongNormalizedKey(" + + "rowData.getLong(" + sortIndex + "), target, " + offset + ", " + valueBytes + ");"; + case FLOAT: + return "putFloatNormalizedKey(rowData.getFloat(" + sortIndex + "), target, " + offset + ", " + + valueBytes + ");"; + case DOUBLE: + return "putDoubleNormalizedKey(rowData.getDouble(" + sortIndex + "), target, " + offset + ", " + + valueBytes + ");"; + case CHAR: + case VARCHAR: + return "putBytesNormalizedKey(rowData.getString(" + sortIndex + ").toBytes(), target, " + offset + ", " + + valueBytes + ");"; + case BINARY: + case VARBINARY: + return "putBytesNormalizedKey(rowData.getBinary(" + sortIndex + "), target, " + offset + ", " + + valueBytes + ");"; + case DECIMAL: + DecimalType decimalType = (DecimalType) logicalType; + return "org.apache.flink.api.common.typeutils.base.NormalizedKeyUtil.putLongNormalizedKey(" + + "rowData.getDecimal(" + sortIndex + ", " + decimalType.getPrecision() + ", " + + decimalType.getScale() + ").toUnscaledLong(), target, " + offset + ", " + valueBytes + ");"; + case TIMESTAMP_WITHOUT_TIME_ZONE: + return timestampNormalizedKeyExpression(sortIndex, ((TimestampType) logicalType).getPrecision(), offset, + valueBytes); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return timestampNormalizedKeyExpression(sortIndex, ((LocalZonedTimestampType) logicalType).getPrecision(), + offset, valueBytes); + case TIMESTAMP_WITH_TIME_ZONE: + return timestampNormalizedKeyExpression(sortIndex, ((ZonedTimestampType) logicalType).getPrecision(), offset, + valueBytes); + default: + throw new IllegalArgumentException("Unsupported normalized key field type: " + logicalType); + } + } + + private String timestampNormalizedKeyExpression(int sortIndex, int precision, String offset, int valueBytes) { + return "putTimestampNormalizedKey(rowData.getTimestamp(" + sortIndex + ", " + precision + "), target, " + + offset + ", " + valueBytes + ");"; + } + + private static class NormalizedKeyField { + private final int sortIndex; + private final int valueBytes; + private final boolean fullyDetermines; + + private NormalizedKeyField(int sortIndex, int valueBytes, boolean fullyDetermines) { + this.sortIndex = sortIndex; + this.valueBytes = valueBytes; + this.fullyDetermines = fullyDetermines; + } + + private int totalBytes() { + return valueBytes + 1; + } + + private boolean fullyDetermines() { + return fullyDetermines; } - return new SortCodeGenerator(tableConfig, Thread.currentThread().getContextClassLoader(), rowType, builder.build()); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringCommitSink.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringCommitSink.java index c4c1efc21c0af..2bc668d2afa7d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringCommitSink.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringCommitSink.java @@ -175,7 +175,7 @@ private void commitIfNecessary(String instant, Collection doCommit(instant, clusteringPlan, events); } catch (Throwable throwable) { // make it fail-safe - log.error("Error while committing clustering instant: " + instant, throwable); + log.error("Error while committing clustering instant: {}", instant, throwable); } finally { // reset the status reset(instant); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java index f67be66b3c3e6..6e66957988253 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java @@ -74,7 +74,6 @@ import org.apache.flink.streaming.runtime.tasks.StreamTask; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.planner.codegen.sort.SortCodeGenerator; import org.apache.flink.table.runtime.generated.NormalizedKeyComputer; import org.apache.flink.table.runtime.generated.RecordComparator; import org.apache.flink.table.runtime.operators.TableStreamOperator; @@ -322,8 +321,9 @@ private Iterator readRecordsForGroupBaseFiles(List private BinaryExternalSorter initSorter() { ClassLoader cl = getContainingTask().getUserCodeClassLoader(); - NormalizedKeyComputer computer = createSortCodeGenerator().generateNormalizedKeyComputer("SortComputer").newInstance(cl); - RecordComparator comparator = createSortCodeGenerator().generateRecordComparator("SortComparator").newInstance(cl); + SortOperatorGen sortOperatorGen = createSortOperatorGen(); + NormalizedKeyComputer computer = sortOperatorGen.generateNormalizedKeyComputer("SortComputer").newInstance(cl); + RecordComparator comparator = sortOperatorGen.generateRecordComparator("SortComparator").newInstance(cl); MemoryManager memManager = getContainingTask().getEnvironment().getMemoryManager(); BinaryExternalSorter sorter = Utils.getBinaryExternalSorter( @@ -345,10 +345,9 @@ private BinaryExternalSorter initSorter() { return sorter; } - private SortCodeGenerator createSortCodeGenerator() { - SortOperatorGen sortOperatorGen = new SortOperatorGen(rowType, + private SortOperatorGen createSortOperatorGen() { + return new SortOperatorGen(rowType, conf.get(FlinkOptions.CLUSTERING_SORT_COLUMNS).split(",")); - return sortOperatorGen.createSortCodeGenerator(); } private String getFileIds(List clusteringOperations) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringPlanOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringPlanOperator.java index 609abbdaf862c..49340fc5f465c 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringPlanOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringPlanOperator.java @@ -117,7 +117,7 @@ public void notifyCheckpointComplete(long checkpointId) { scheduleClustering(table, checkpointId); } catch (Throwable throwable) { // make it fail-safe - log.error("Error while scheduling clustering plan for checkpoint: " + checkpointId, throwable); + log.error("Error while scheduling clustering plan for checkpoint: {}", checkpointId, throwable); } } @@ -135,7 +135,7 @@ private void scheduleClustering(HoodieFlinkTable table, long checkpointId) { if (!firstRequested.isPresent()) { // do nothing. - log.info("No clustering plan for checkpoint " + checkpointId); + log.info("No clustering plan for checkpoint {}", checkpointId); return; } @@ -158,7 +158,7 @@ private void scheduleClustering(HoodieFlinkTable table, long checkpointId) { if (clusteringPlan == null || (clusteringPlan.getInputGroups() == null) || (clusteringPlan.getInputGroups().isEmpty())) { // do nothing. - log.info("Empty clustering plan for instant " + clusteringInstantTime); + log.info("Empty clustering plan for instant {}", clusteringInstantTime); } else { // Mark instant as clustering inflight ClusteringUtils.transitionClusteringOrReplaceRequestedToInflight(clusteringInstant, Option.empty(), table.getActiveTimeline()); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/HoodieFlinkClusteringJob.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/HoodieFlinkClusteringJob.java index 9a2b07a7b81b1..af9f011715b05 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/HoodieFlinkClusteringJob.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/HoodieFlinkClusteringJob.java @@ -49,7 +49,6 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.table.planner.plan.nodes.exec.utils.ExecNodeUtil; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.RowType; import org.slf4j.Logger; @@ -60,6 +59,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import static org.apache.hudi.sink.utils.FlinkTransformationUtils.setManagedMemoryWeight; + /** * Flink hudi clustering program that can be executed manually. */ @@ -338,7 +339,7 @@ private void cluster() throws Exception { Option inflightInstantOpt = ClusteringUtils.getInflightClusteringInstant(clusteringInstant.requestedTime(), table.getActiveTimeline(), table.getInstantGenerator()); if (inflightInstantOpt.isPresent()) { - LOG.info("Rollback inflight clustering instant: [" + clusteringInstant + "]"); + LOG.info("Rollback inflight clustering instant: [{}]", clusteringInstant); table.rollbackInflightClustering(inflightInstantOpt.get(), commitToRollback -> writeClient.getTableServiceClient().getPendingRollbackInfo(table.getMetaClient(), commitToRollback, false), writeClient.getTransactionManager()); @@ -361,7 +362,7 @@ private void cluster() throws Exception { if (clusteringPlan == null || (clusteringPlan.getInputGroups() == null) || (clusteringPlan.getInputGroups().isEmpty())) { // no clustering plan, do nothing and return. - LOG.info("No clustering plan for instant " + clusteringInstant.requestedTime()); + LOG.info("No clustering plan for instant {}", clusteringInstant.requestedTime()); return; } @@ -398,7 +399,7 @@ private void cluster() throws Exception { .setParallelism(clusteringParallelism); if (OptionsResolver.sortClusteringEnabled(conf)) { - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); } @@ -417,7 +418,7 @@ private void cluster() throws Exception { * Shutdown async services like compaction/clustering as DeltaSync is shutdown. */ public void shutdownAsyncService(boolean error) { - LOG.info("Gracefully shutting down clustering job. Error ?" + error); + LOG.info("Gracefully shutting down clustering job. Error: {}", error); executor.shutdown(); writeClient.close(); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java index c7ea6d8747f63..3f4515e1b2f15 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/ConsistentBucketUpdateStrategy.java @@ -62,7 +62,7 @@ public class ConsistentBucketUpdateStrategy extends UpdateStrategy indexKeyFields) { - super(writeClient.getEngineContext(), writeClient.getHoodieTable(), Collections.emptySet()); + super(writeClient.getEngineContext(), writeClient.getHoodieTable(), Collections.emptySet(), Collections.emptySet()); this.indexKeyFields = indexKeyFields; this.partitionToIdentifier = new HashMap<>(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java index b6159f411bd29..9272653096da5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/update/strategy/FlinkConsistentBucketUpdateStrategy.java @@ -62,7 +62,7 @@ public class FlinkConsistentBucketUpdateStrategy private String lastRefreshInstant = HoodieTimeline.INIT_INSTANT_TS; public FlinkConsistentBucketUpdateStrategy(HoodieFlinkWriteClient writeClient, List indexKeyFields) { - super(writeClient.getEngineContext(), writeClient.getHoodieTable(), Collections.emptySet()); + super(writeClient.getEngineContext(), writeClient.getHoodieTable(), Collections.emptySet(), Collections.emptySet()); this.indexKeyFields = indexKeyFields; this.partitionToIdentifier = new HashMap<>(); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/HoodieFlinkCompactor.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/HoodieFlinkCompactor.java index 34ac7e801570f..2ef5d261cd1ef 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/HoodieFlinkCompactor.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/HoodieFlinkCompactor.java @@ -300,7 +300,7 @@ private void compact() throws Exception { compactionInstantTimes.forEach(timestamp -> { HoodieInstant inflightInstant = table.getInstantGenerator().getCompactionInflightInstant(timestamp); if (pendingCompactionTimeline.containsInstant(inflightInstant)) { - LOG.info("Rollback inflight compaction instant: [" + timestamp + "]"); + LOG.info("Rollback inflight compaction instant: [{}]", timestamp); table.rollbackInflightCompaction(inflightInstant, writeClient.getTransactionManager()); table.getMetaClient().reloadActiveTimeline(); } @@ -322,7 +322,7 @@ private void compact() throws Exception { if (compactionPlans.isEmpty()) { // No compaction plan, do nothing and return. - LOG.info("No compaction plan for instant " + String.join(",", compactionInstantTimes)); + LOG.info("No compaction plan for instant {}", String.join(",", compactionInstantTimes)); return; } @@ -336,7 +336,7 @@ private void compact() throws Exception { ? totalOperations : Math.min(conf.get(FlinkOptions.COMPACTION_TASKS), totalOperations); - LOG.info("Start to compaction for instant " + compactionInstantTimes); + LOG.info("Start to compaction for instant {}", compactionInstantTimes); // Mark instant as compaction inflight for (HoodieInstant instant : instants) { @@ -367,7 +367,7 @@ private void compact() throws Exception { * Shutdown async services like compaction/clustering as DeltaSync is shutdown. */ public void shutdownAsyncService(boolean error) { - LOG.info("Gracefully shutting down compactor. Error ?" + error); + LOG.info("Gracefully shutting down compactor. Error: {}", error); executor.shutdown(); writeClient.close(); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/handler/DefaultCleanHandler.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/handler/DefaultCleanHandler.java index df95b81a06a39..2df75847ddaba 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/handler/DefaultCleanHandler.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/handler/DefaultCleanHandler.java @@ -111,6 +111,6 @@ public void close() { throw new HoodieException("Failed to close executor of clean handler.", e); } } - this.writeClient.clean(); + this.writeClient.close(); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/muttley/README.md b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/muttley/README.md new file mode 100644 index 0000000000000..1f0387cfdb950 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/muttley/README.md @@ -0,0 +1,42 @@ + + +# Internal Uber Components (Optional) + +This package contains integration with internal Uber services and is **optional** for Apache Hudi users. + +## Components + +- **FlinkHudiMuttleyClient** - Abstract base class providing HTTP retry logic for Muttley RPC communication +- **AthenaIngestionGateway** - Concrete Muttley RPC client for Uber's Athena Ingestion Gateway service (extends `FlinkHudiMuttleyClient`) +- **FlinkHudiMuttleyException** - Base exception for Muttley errors +- **FlinkHudiMuttleyClientException** - Exception for client-side Muttley errors +- **FlinkHudiMuttleyServerException** - Exception for server-side Muttley errors + +## Usage + +These components are used by `FlinkCheckpointClient` to collect Kafka offset metadata and attach it to Hudi commits as part of Uber's Kafka offset tracking feature. + +**Feature flag**: The feature is controlled by `write.extra.metadata.enabled` (default: `false`). It is disabled by default and has no effect in standard Apache Hudi deployments. + +**For open-source Apache Hudi users**: You can safely ignore this package. If `write.extra.metadata.enabled` is inadvertently set to `true` outside of Uber's infrastructure, Kafka offset collection will be silently skipped and Hudi commits will proceed normally (fail-open behavior). + +## Dependencies + +- Runtime: Uber's internal Muttley RPC framework +- Runtime: Access to Athena Ingestion Gateway service +- Compile-time: Jackson (`jackson-databind`) for JSON serialization of RPC request/response payloads diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java index 7faff523e6897..d2deba8ef0303 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java @@ -172,9 +172,7 @@ protected void processIndexRecord( protected void processChangingRecord( HoodieFlinkInternalRow record, String recordKey, - Collector out, - HoodieRecordGlobalLocation prefetchedOldLoc, - boolean prefetched) throws Exception { + Collector out) throws Exception { // 1. put the record into the BucketAssigner; // 2. look up the state for location, if the record has a location, just send it out; // 3. if it is an INSERT, decide the location using the BucketAssigner then send it out. @@ -183,7 +181,7 @@ protected void processChangingRecord( // Only changing records need looking up the index for the location, // append only records are always recognized as INSERT. // Structured as Tuple(partition, fileId, instantTime). - HoodieRecordGlobalLocation oldLoc = prefetched ? prefetchedOldLoc : indexBackend.get(recordKey); + HoodieRecordGlobalLocation oldLoc = indexBackend.get(recordKey); if (oldLoc != null) { // Set up the instant time as "U" to mark the bucket as an update bucket. String partitionFromState = oldLoc.getPartitionPath(); @@ -238,7 +236,7 @@ protected void processInsertRecord( */ private Processor initRecordProcessor() { if (isChangingRecords) { - return (value, out) -> processChangingRecord(value, value.getRecordKey(), out, null, false); + return (value, out) -> processChangingRecord(value, value.getRecordKey(), out); } else { return this::processInsertRecord; } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssigner.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssigner.java index fe848d7bcc203..0544f700d15da 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssigner.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssigner.java @@ -177,7 +177,7 @@ private synchronized SmallFileAssign getSmallFileAssign(String partitionPath) { } List smallFiles = smallFilesOfThisTask(writeProfile.getSmallFiles(partitionPath)); if (smallFiles.size() > 0) { - log.info("For partitionPath : " + partitionPath + " Small Files => " + smallFiles); + log.info("For partitionPath : {} Small Files => {}", partitionPath, smallFiles); SmallFileAssignState[] states = smallFiles.stream() .map(smallFile -> new SmallFileAssignState(config.getParquetMaxFileSize(), smallFile, writeProfile.getAvgSize())) .toArray(SmallFileAssignState[]::new); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java index fb5b3fdb6f0f6..d63ebbbed86ab 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java @@ -28,6 +28,8 @@ import org.apache.flink.api.common.functions.Partitioner; import org.apache.flink.configuration.Configuration; +import java.util.List; + /** * Bucket index input partitioner. * The fields to hash can be a subset of the primary key fields. @@ -36,13 +38,15 @@ */ public class BucketIndexPartitioner implements Partitioner { - private final String indexKeyFields; + // Index key fields, pre-parsed by the caller. The per-record partition() path uses the List + // overload of getBucketId so the comma-separated config string is never re-split per record. + private final List indexKeyFieldList; private final NumBucketsFunction numBucketsFunction; private Functions.Function3 partitionIndexFunc; - public BucketIndexPartitioner(Configuration conf, String indexKeyFields) { - this.indexKeyFields = indexKeyFields; + public BucketIndexPartitioner(Configuration conf, List indexKeyFieldList) { + this.indexKeyFieldList = indexKeyFieldList; this.numBucketsFunction = new NumBucketsFunction(conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS), conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS)); } @@ -53,7 +57,7 @@ public int partition(HoodieKey key, int numPartitions) { this.partitionIndexFunc = BucketIndexUtil.getPartitionIndexFunc(numPartitions); } int numBuckets = numBucketsFunction.getNumBuckets(key.getPartitionPath()); - int curBucket = BucketIdentifier.getBucketId(key.getRecordKey(), indexKeyFields, numBuckets); + int curBucket = BucketIdentifier.getBucketId(key.getRecordKey(), indexKeyFieldList, numBuckets); return this.partitionIndexFunc.apply(numBuckets, key.getPartitionPath(), curBucket); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/GlobalRecordIndexPartitioner.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/GlobalRecordIndexPartitioner.java index 8b6f1ae22ad9e..258c3f4a8fd34 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/GlobalRecordIndexPartitioner.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/GlobalRecordIndexPartitioner.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; @@ -79,6 +80,12 @@ public int partition(HoodieKey recordKey, int numPartitions) { */ private int getNumFileGroupsForRecordIndexPartition() { HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(conf); + // For flink adaptive batch execution, writer coordinator is not started yet, so metadata table + // is not initialized for a new table. + if (!metaClient.getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX)) { + // estimate the minimum file group count used to initialize global record level index + return OptionsResolver.estimateFileGroupCountForRLI(conf); + } try (HoodieTableMetadata metadataTable = metaClient.getTableFormat().getMetadataFactory().create( HoodieFlinkEngineContext.DEFAULT, metaClient.getStorage(), diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/MinibatchBucketAssignFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/MinibatchBucketAssignFunction.java index cdf30c932eb75..3a44267c42a9a 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/MinibatchBucketAssignFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/MinibatchBucketAssignFunction.java @@ -20,7 +20,6 @@ import org.apache.hudi.adapter.ProcessFunctionAdapter; import org.apache.hudi.client.model.HoodieFlinkInternalRow; -import org.apache.hudi.common.model.HoodieRecordGlobalLocation; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.util.VisibleForTesting; import org.apache.hudi.configuration.FlinkOptions; @@ -40,7 +39,6 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; /** @@ -152,11 +150,11 @@ private Processor initRecordProcessor() { public void process(List records, Collector out) throws Exception { List recordKeys = records.stream().map(HoodieFlinkInternalRow::getRecordKey).collect(Collectors.toList()); MinibatchIndexBackend minibatchIndexBackend = (MinibatchIndexBackend) delegateFunction.getIndexBackend(); - // get record locations by minibatch - Map recordLocations = minibatchIndexBackend.get(recordKeys); + // warm up the in-memory cache for record level index + minibatchIndexBackend.get(recordKeys); for (HoodieFlinkInternalRow record: records) { String recordKey = record.getRecordKey(); - delegateFunction.processChangingRecord(record, recordKey, out, recordLocations.get(recordKey), true); + delegateFunction.processChangingRecord(record, recordKey, out); } } }; diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/RecordIndexPartitioner.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/RecordIndexPartitioner.java index a4597e00f099d..e1d2a5f203aeb 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/RecordIndexPartitioner.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/RecordIndexPartitioner.java @@ -19,12 +19,12 @@ package org.apache.hudi.sink.partitioner; import org.apache.hudi.client.common.HoodieFlinkEngineContext; -import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.Functions; import org.apache.hudi.common.util.hash.BucketIndexUtil; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; @@ -34,7 +34,6 @@ import org.apache.flink.api.common.functions.Partitioner; import org.apache.flink.configuration.Configuration; -import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -77,7 +76,7 @@ public int partition(HoodieKey recordKey, int numPartitions) { if (partitionedRLIFileGroupCounts == null) { partitionedRLIFileGroupCounts = getPartitionedRLIFileGroupCounts(); } - int fileGroupCount = getFileGroupCountForPartitionedRLI(recordKey.getPartitionPath()); + int fileGroupCount = partitionedRLIFileGroupCounts.computeIfAbsent(recordKey.getPartitionPath(), s -> OptionsResolver.estimateFileGroupCountForRLI(conf)); int fgIndex = HoodieTableMetadataUtil.mapRecordKeyToFileGroupIndex(recordKey.getRecordKey(), fileGroupCount); if (partitionIndexFunc == null) { partitionIndexFunc = BucketIndexUtil.getPartitionIndexFunc(numPartitions); @@ -91,7 +90,7 @@ public int partition(HoodieKey recordKey, int numPartitions) { private Map getPartitionedRLIFileGroupCounts() { HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(conf); if (!metaClient.getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX)) { - return Collections.emptyMap(); + return new HashMap<>(); } try (HoodieTableMetadata metadataTable = metaClient.getTableFormat().getMetadataFactory().create( HoodieFlinkEngineContext.DEFAULT, @@ -106,24 +105,4 @@ private Map getPartitionedRLIFileGroupCounts() { throw new HoodieException("Failed to get file group counts for partitioned record index.", e); } } - - /** - * Get the partitioned record index file group count for the given data partition. - */ - private int getFileGroupCountForPartitionedRLI(String partitionPath) { - int fileGroupCount = partitionedRLIFileGroupCounts.getOrDefault(partitionPath, 0); - // HoodieBackedTableMetadataWriter initializes record-index file groups for a newly seen - // data partition with RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP, so the writer-side - // partitioner should use the same count before that partition appears in the MDT view. - return fileGroupCount > 0 ? fileGroupCount : getMinFileGroupCountForPartitionedRLI(); - } - - /** - * Get the minimum file group count used to initialize newly seen partitioned record index partitions. - */ - private int getMinFileGroupCountForPartitionedRLI() { - return Integer.parseInt(conf.getString( - HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), - HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.defaultValue().toString())); - } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/GlobalRecordLevelIndexBackend.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/GlobalRecordLevelIndexBackend.java index a95e18e863a13..93a6303009f88 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/GlobalRecordLevelIndexBackend.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/GlobalRecordLevelIndexBackend.java @@ -20,6 +20,7 @@ import org.apache.hudi.client.common.HoodieFlinkEngineContext; import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.data.HoodieListPairData; import org.apache.hudi.common.data.HoodiePairData; import org.apache.hudi.common.model.HoodieRecordGlobalLocation; import org.apache.hudi.common.table.HoodieTableMetaClient; @@ -27,7 +28,7 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.metadata.HoodieBackedTableMetadata; import org.apache.hudi.sink.event.Correspondent; import org.apache.hudi.util.StreamerUtil; @@ -37,6 +38,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,7 +57,7 @@ public class GlobalRecordLevelIndexBackend implements MinibatchIndexBackend { private final RecordIndexCache recordIndexCache; private final Configuration conf; private final HoodieTableMetaClient metaClient; - private HoodieTableMetadata metadataTable; + private HoodieBackedTableMetadata tableMetadata; /** * Creates a global RLI backend with a checkpoint-aware cache. @@ -72,7 +74,9 @@ public GlobalRecordLevelIndexBackend(Configuration conf, long initCheckpointId) @Override public HoodieRecordGlobalLocation get(String recordKey) throws IOException { - throw new UnsupportedOperationException(this.getClass().getSimpleName() + " doesn't support lookup with a single key."); + // note: always fetch record location from the cache, since this backend is only used for minibatch mode, + // and the cache has been warmed up by calling `get(List recordKeys)` previously. + return recordIndexCache.get(recordKey); } @Override @@ -93,8 +97,7 @@ public Map get(List recordKeys) thro } } if (!missedKeys.isEmpty()) { - HoodiePairData recordIndexData = - metadataTable.readRecordIndexLocationsWithKeys(HoodieListData.eager(missedKeys)); + HoodiePairData recordIndexData = lookupLocationsForMissedKeys(missedKeys); recordIndexData.forEach(keyAndLocation -> { recordIndexCache.update(keyAndLocation.getKey(), keyAndLocation.getValue()); keysAndLocations.put(keyAndLocation.getKey(), keyAndLocation.getValue()); @@ -103,6 +106,15 @@ public Map get(List recordKeys) thro return keysAndLocations; } + private HoodiePairData lookupLocationsForMissedKeys(List missedKeys) { + // For flink adaptive batch execution, writer coordinator is not started yet, so metadata table + // is not initialized for a new table. + if (!tableMetadata.enabled()) { + return HoodieListPairData.eager(Collections.emptyList()); + } + return tableMetadata.readRecordIndexLocationsWithKeys(HoodieListData.eager(missedKeys)); + } + @Override public void update(List> recordKeysAndLocations) throws IOException { recordKeysAndLocations.forEach(keyAndLocation -> recordIndexCache.update(keyAndLocation.getKey(), keyAndLocation.getValue())); @@ -141,21 +153,30 @@ public void onCheckpointComplete(Correspondent correspondent, long completedChec } private void reloadMetadataTable() { - this.metadataTable = metaClient.getTableFormat().getMetadataFactory().create( - HoodieFlinkEngineContext.DEFAULT, - metaClient.getStorage(), - StreamerUtil.metadataConfig(conf), - conf.get(FlinkOptions.PATH)); + if (this.tableMetadata != null) { + this.tableMetadata.close(); + } + this.tableMetadata = + new HoodieBackedTableMetadata( + HoodieFlinkEngineContext.DEFAULT, + metaClient.getStorage(), + StreamerUtil.metadataConfig(conf), + conf.get(FlinkOptions.PATH)); + if (!tableMetadata.enabled()) { + if (metaClient.getTableConfig().isMetadataTableAvailable()) { + throw new RuntimeException("Can not initialize the table metadata"); + } + } } @Override public void close() throws IOException { this.recordIndexCache.close(); - if (this.metadataTable == null) { + if (this.tableMetadata == null) { return; } try { - this.metadataTable.close(); + this.tableMetadata.close(); } catch (Exception e) { throw new HoodieException("Exception happened during close metadata table.", e); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexRowUtils.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexRowUtils.java index 4ea6f06e75519..5e8a04c2e15c5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexRowUtils.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexRowUtils.java @@ -70,7 +70,7 @@ public static RowData createRecordIndexRow(HoodieFlinkInternalRow internalRow) { return indexRow; } - public static HoodieRecord convertToHoodieRecord(String instant, RowData indexRow, HoodieWriteConfig dataWriteConfig) { + public static HoodieRecord convertToHoodieRecord(long instantMillis, RowData indexRow, HoodieWriteConfig dataWriteConfig) { if (indexRow.getByte(INDEX_TYPE_ORD) == RLI_TYPE) { switch (indexRow.getRowKind()) { case INSERT: @@ -78,7 +78,7 @@ public static HoodieRecord convertToHoodieRecord(String instant, RowData indexRo String.valueOf(indexRow.getString(KEY_ORD)), String.valueOf(indexRow.getString(PARTITION_ORD)), String.valueOf(indexRow.getString(FILE_ID_ORD)), - instant, + instantMillis, dataWriteConfig.getWritesFileIdEncoding()); case DELETE: return HoodieMetadataPayload.createRecordIndexDelete( diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexWriteFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexWriteFunction.java index 8e64d58a97b37..066f5aa62eb6a 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexWriteFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexWriteFunction.java @@ -26,6 +26,7 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.metadata.HoodieMetadataPayload; import org.apache.hudi.sink.common.AbstractStreamWriteFunction; import org.apache.hudi.sink.event.WriteMetadataEvent; import org.apache.hudi.sink.exception.MemoryPagesExhaustedException; @@ -174,11 +175,12 @@ private Pair, Set> prepareIndexRecordsAndPartitions(B HoodieWriteConfig writeConfig = writeClient.getConfig(); // deduplicate the index records using commit time ordering. Map keyAndRecordMap = new LinkedHashMap<>(); + long currentInstantMillis = HoodieMetadataPayload.parseRecordIndexInstantTime(this.currentInstant); while (rowItr.hasNext()) { RowData indexRow = rowItr.next(); keyAndRecordMap.put( dedupKeyExtractor.apply(indexRow), - IndexRowUtils.convertToHoodieRecord(this.currentInstant, indexRow, writeConfig)); + IndexRowUtils.convertToHoodieRecord(currentInstantMillis, indexRow, writeConfig)); dataPartitions.add(IndexRowUtils.getPartition(indexRow)); } return Pair.of(new ArrayList<>(keyAndRecordMap.values()), dataPartitions); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java index f73adb37d3379..2cdee3453a467 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieBaseFile; import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.view.SyncableFileSystemView; @@ -39,6 +40,7 @@ *

    Note: assumes the index can always index log files for Flink write. */ public class DeltaWriteProfile extends WriteProfile { + public DeltaWriteProfile(HoodieWriteConfig config, HoodieFlinkEngineContext context) { super(config, context); } @@ -86,12 +88,33 @@ protected List smallFilesProfile(String partitionPath) { return smallFileLocations; } + @Override + protected long averageBytesPerRecord() { + long avgSize = this.avgSize > 0 ? this.avgSize : config.getCopyOnWriteRecordSizeEstimate(); + HoodieTimeline commitTimeline = metaClient.getCommitTimeline().filterCompletedInstants(); + if (!commitTimeline.empty()) { + long sizeFromCommitMetadata = calculateRecordSizeThroughCommitMetadata(commitTimeline, 1.0D); + if (sizeFromCommitMetadata > 0) { + avgSize = sizeFromCommitMetadata; + } + } else { + HoodieTimeline deltaCommitTimeline = metaClient.getActiveTimeline().getDeltaCommitTimeline().filterCompletedInstants(); + if (!deltaCommitTimeline.empty()) { + long sizeFromCommitMetadata = calculateRecordSizeThroughCommitMetadata(deltaCommitTimeline, logFileToParquetCompressionRatio()); + if (sizeFromCommitMetadata > 0) { + avgSize = sizeFromCommitMetadata; + } + } + } + return avgSize; + } + protected SyncableFileSystemView getFileSystemView() { return (SyncableFileSystemView) getTable().getSliceView(); } private long getTotalFileSize(FileSlice fileSlice) { - return fileSlice.getTotalFileSizeAsParquetFormat(config.getLogFileToParquetCompressionRatio()); + return fileSlice.getTotalFileSizeAsParquetFormat(logFileToParquetCompressionRatio()); } private boolean isSmallFile(FileSlice fileSlice) { @@ -99,4 +122,11 @@ private boolean isSmallFile(FileSlice fileSlice) { return totalSize < config.getParquetMaxFileSize(); } + private double logFileToParquetCompressionRatio() { + if (config.getLogDataBlockFormat().isPresent() + && config.getLogDataBlockFormat().get() == HoodieLogBlock.HoodieLogBlockType.PARQUET_DATA_BLOCK) { + return 1D; + } + return config.getLogFileToParquetCompressionRatio(); + } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/WriteProfile.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/WriteProfile.java index cbcc71cedb6ec..304d8f5d7452e 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/WriteProfile.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/WriteProfile.java @@ -78,7 +78,7 @@ public class WriteProfile { * The average record size. */ @Getter - private long avgSize = -1L; + protected long avgSize = -1L; /** * Total records to write for each bucket based on @@ -117,7 +117,6 @@ public WriteProfile(HoodieWriteConfig config, HoodieFlinkEngineContext context) this.context = context; this.basePath = new Path(config.getBasePath()); this.smallFilesMap = new HashMap<>(); - this.recordsPerBucket = config.getCopyOnWriteInsertSplitSize(); this.metaClient = StreamerUtil.createMetaClient( config.getBasePath(), context.getStorageConf().unwrapAs(Configuration.class)); this.metadataCache = new HashMap<>(); @@ -134,35 +133,45 @@ public WriteProfile(HoodieWriteConfig config, HoodieFlinkEngineContext context) * Obtains the average record size based on records written during previous commits. Used for estimating how many * records pack into one file. */ - private long averageBytesPerRecord() { - long avgSize = config.getCopyOnWriteRecordSizeEstimate(); - long fileSizeThreshold = (long) (config.getRecordSizeEstimationThreshold() * config.getParquetSmallFileLimit()); - HoodieTimeline commitTimeline = metaClient.getCommitsTimeline().filterCompletedInstants(); + protected long averageBytesPerRecord() { + long avgSize = this.avgSize > 0 ? this.avgSize : config.getCopyOnWriteRecordSizeEstimate(); + HoodieTimeline commitTimeline = metaClient.getCommitTimeline().filterCompletedInstants(); if (!commitTimeline.empty()) { - // Go over the reverse ordered commits to get a more recent estimate of average record size. - Iterator instants = commitTimeline.getReverseOrderedInstants().iterator(); - while (instants.hasNext()) { - HoodieInstant instant = instants.next(); - final HoodieCommitMetadata commitMetadata = - this.metadataCache.computeIfAbsent( - instant.requestedTime(), - k -> WriteProfiles.getCommitMetadataSafely(config.getTableName(), basePath, instant, commitTimeline) - .orElse(null)); - if (commitMetadata == null) { - continue; - } - long totalBytesWritten = commitMetadata.fetchTotalBytesWritten(); - long totalRecordsWritten = commitMetadata.fetchTotalRecordsWritten(); - if (totalBytesWritten > fileSizeThreshold && totalRecordsWritten > 0) { - avgSize = (long) Math.ceil((1.0 * totalBytesWritten) / totalRecordsWritten); - break; - } + long sizeFromCommitMetadata = calculateRecordSizeThroughCommitMetadata(commitTimeline, 1.0D); + if (sizeFromCommitMetadata > 0) { + avgSize = sizeFromCommitMetadata; } } - log.info("Refresh average bytes per record => " + avgSize); return avgSize; } + protected long calculateRecordSizeThroughCommitMetadata(HoodieTimeline commitTimeline, double fileSizeCalibrationRatio) { + long fileSizeThreshold = recordSizeEstimationFileSizeThreshold(); + // Go over the reverse ordered commits to get a more recent estimate of average record size. + Iterator instants = commitTimeline.getReverseOrderedInstants().iterator(); + while (instants.hasNext()) { + HoodieInstant instant = instants.next(); + final HoodieCommitMetadata commitMetadata = + this.metadataCache.computeIfAbsent( + instant.requestedTime(), + k -> WriteProfiles.getCommitMetadataSafely(config.getTableName(), basePath, instant, commitTimeline) + .orElse(null)); + if (commitMetadata == null) { + continue; + } + long totalBytesWritten = commitMetadata.fetchTotalBytesWritten(); + long totalRecordsWritten = commitMetadata.fetchTotalRecordsWritten(); + if (totalBytesWritten > fileSizeThreshold && totalRecordsWritten > 0) { + return (long) Math.ceil((fileSizeCalibrationRatio * totalBytesWritten) / totalRecordsWritten); + } + } + return -1L; + } + + private long recordSizeEstimationFileSizeThreshold() { + return (long) (0.1D * config.getParquetSmallFileLimit()); + } + /** * Returns a list of small files in the given partition path. * @@ -228,10 +237,9 @@ private void cleanMetadataCache(Stream instants) { private void recordProfile() { this.avgSize = averageBytesPerRecord(); - if (config.shouldAllowMultiWriteOnSameInstant()) { - this.recordsPerBucket = config.getParquetMaxFileSize() / avgSize; - log.info("Refresh insert records per bucket => " + recordsPerBucket); - } + log.info("Refresh average bytes per record => {}", avgSize); + this.recordsPerBucket = config.getParquetMaxFileSize() / avgSize; + log.info("Refresh insert records per bucket => {}", recordsPerBucket); } /** diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java index 87e1010e2227a..10f89637e342c 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java @@ -171,7 +171,7 @@ public String getPendingInstants() { */ public Map> getAllCompletedEvents() { return this.eventBuffers.entrySet().stream() - .filter(entry -> entry.getValue().getRight().allEventsCompleted()) + .filter(entry -> entry.getValue().getRight().allEventsReceived()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } @@ -241,14 +241,6 @@ public void resetBuffer(WriteMetadataEvent event) { } } - /** - * Return true if there is no event sent by eager flushing from writers. - */ - public boolean allEventsCompleted() { - return Stream.concat(Arrays.stream(dataWriteEventBuffer), Arrays.stream(indexWriteEventBuffer)) - .allMatch(event -> event == null || event.isLastBatch()); - } - /** * Return true if all the events in the data write buffer are null. */ diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkTransformationUtils.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkTransformationUtils.java new file mode 100644 index 0000000000000..c19a9220f71b0 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/FlinkTransformationUtils.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.hudi.sink.utils; + +import org.apache.flink.api.dag.Transformation; +import org.apache.flink.core.memory.ManagedMemoryUseCase; + +/** + * Utilities for Flink transformations. + */ +public final class FlinkTransformationUtils { + private FlinkTransformationUtils() { + } + + public static void setManagedMemoryWeight(Transformation transformation, long memoryBytes) { + if (memoryBytes <= 0) { + return; + } + int weight = Math.max(1, (int) (memoryBytes >> 20)); // bytes to MiB + transformation.declareManagedMemoryUseCaseAtOperatorScope(ManagedMemoryUseCase.OPERATOR, weight) + .ifPresent(previousWeight -> { + throw new IllegalStateException("Managed memory weight has been set, this should not happen."); + }); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java index 05b8878c01579..349084e128d99 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java @@ -36,6 +36,7 @@ import java.util.Properties; +import static org.apache.hudi.common.config.HoodieCommonConfig.BASE_PATH; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_AUTO_CREATE_DATABASE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_IGNORE_EXCEPTIONS; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_PASS; @@ -101,6 +102,7 @@ public static HiveSyncContext create(Configuration conf, StorageConfiguration bulkInsert(Configuration conf, RowType rowType throw new HoodieException( "Consistent hashing bucket index does not work with bulk insert using FLINK engine. Use simple bucket index or Spark engine."); } - String indexKeys = OptionsResolver.getIndexKeyField(conf); - BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(conf, indexKeys); + List indexKeyFieldList = OptionsResolver.getIndexKeyFields(conf); + // built once and captured by the per-record map closure (NumBucketsFunction is Serializable), + // avoiding a per-record rebuild from conf inside BucketBulkInsertWriterHelper + NumBucketsFunction numBucketsFunction = new NumBucketsFunction(conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS), + conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS)); + BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(conf, indexKeyFieldList); RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType); RowType rowTypeWithFileId = BucketBulkInsertWriterHelper.rowTypeWithFileId(rowType); InternalTypeInfo typeInfo = InternalTypeInfo.of(rowTypeWithFileId); @@ -148,13 +153,13 @@ public static DataStream bulkInsert(Configuration conf, RowType rowType Map bucketIdToFileId = new HashMap<>(); dataStream = dataStream.partitionCustom(partitioner, keyGen::getHoodieKey) - .map(record -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, record, indexKeys, conf, needFixedFileIdSuffix), typeInfo) + .map(record -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, record, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix), typeInfo) .setParallelism(PARALLELISM_VALUE); if (conf.get(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) { SortOperatorGen sortOperatorGen = BucketBulkInsertWriterHelper.getFileIdSorterGen(rowTypeWithFileId); dataStream = dataStream.transform("file_sorter", typeInfo, sortOperatorGen.createSortOperator(conf)) .setParallelism(PARALLELISM_VALUE); - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + FlinkTransformationUtils.setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); } } else if (!FlinkOptions.isDefaultValueDefined(conf, FlinkOptions.PARTITION_PATH_FIELD)) { @@ -184,7 +189,7 @@ public static DataStream bulkInsert(Configuration conf, RowType rowType .transform(isNeededSortInput ? "sorter:(partition_key, record_key)" : "sorter:(partition_key)", InternalTypeInfo.of(rowType), sortOperatorGen.createSortOperator(conf)) .setParallelism(PARALLELISM_VALUE); - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + FlinkTransformationUtils.setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); } } @@ -291,13 +296,13 @@ private static DataStream streamBootstrap( boolean bounded) { DataStream dataStream1 = rowDataToHoodieRecord(conf, rowType, dataStream); - if (conf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED) || bounded) { - boolean isRliBootstrap = OptionsResolver.isGlobalRecordLevelIndex(conf); + boolean isGlobalRLI = OptionsResolver.isGlobalRecordLevelIndex(conf); + if (conf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED) || (bounded && !isGlobalRLI)) { dataStream1 = dataStream1 .transform( "index_bootstrap", new HoodieFlinkInternalRowTypeInfo(rowType), - isRliBootstrap ? new RLIBootstrapOperator(conf) : new BootstrapOperator(conf)) + isGlobalRLI ? new RLIBootstrapOperator(conf) : new BootstrapOperator(conf)) .setParallelism(conf.getOptional(FlinkOptions.INDEX_BOOTSTRAP_TASKS).orElse(dataStream1.getParallelism())) .uid(opUID("index_bootstrap", conf)); ((OneInputTransformation) dataStream1.getTransformation()).setChainingStrategy(ChainingStrategy.ALWAYS); @@ -370,7 +375,7 @@ public static DataStream hoodieStreamWrite(Configuration conf, HoodieIndex.BucketIndexEngineType bucketIndexEngineType = OptionsResolver.getBucketEngineType(conf); switch (bucketIndexEngineType) { case SIMPLE: - String indexKeyFields = OptionsResolver.getIndexKeyField(conf); + List indexKeyFields = OptionsResolver.getIndexKeyFields(conf); // [HUDI-9036] BucketIndexPartitioner is also used in bulk insert mode, // keep use of HoodieKey here in partitionCustom for now BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(conf, indexKeyFields); @@ -563,7 +568,7 @@ public static DataStreamSink cluster(Configuration conf, new ClusteringOperator(conf, rowType)) .setParallelism(conf.get(FlinkOptions.CLUSTERING_TASKS)); if (OptionsResolver.sortClusteringEnabled(conf)) { - ExecNodeUtil.setManagedMemoryWeight(clusteringStream.getTransformation(), + FlinkTransformationUtils.setManagedMemoryWeight(clusteringStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); } DataStreamSink clusteringCommitEventDataStream = clusteringStream.addSink(new ClusteringCommitSink(conf)) @@ -606,7 +611,7 @@ public static String getTablePath(Configuration conf) { public static void declareManagedMemoryIfNecessary(Configuration conf, DataStream dataStream, Supplier bufferSizeSupplier) { if (OptionsResolver.isManagedMemoryBufferEnabled(conf)) { - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), bufferSizeSupplier.get()); + FlinkTransformationUtils.setManagedMemoryWeight(dataStream.getTransformation(), bufferSizeSupplier.get()); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/clustering/ClusteringCommitSinkV2.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/clustering/ClusteringCommitSinkV2.java index b6b087c62a1f1..4f31ee877f7ac 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/clustering/ClusteringCommitSinkV2.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/clustering/ClusteringCommitSinkV2.java @@ -185,7 +185,7 @@ private void commitIfNecessary(String instant, Collection doCommit(instant, clusteringPlan, events); } catch (Throwable throwable) { // make it fail-safe - log.error("Error while committing clustering instant: " + instant, throwable); + log.error("Error while committing clustering instant: {}", instant, throwable); } finally { // reset the status reset(instant); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/compact/CompactionCommitSinkV2.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/compact/CompactionCommitSinkV2.java index 848cb10d0c774..7e81112bb0b63 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/compact/CompactionCommitSinkV2.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/compact/CompactionCommitSinkV2.java @@ -175,7 +175,7 @@ private void commitIfNecessary(String instant, Collection doCommit(instant, events); } catch (Throwable throwable) { // make it fail-safe - log.error("Error while committing compaction instant: " + instant, throwable); + log.error("Error while committing compaction instant: {}", instant, throwable); this.compactionMetrics.markCompactionRolledBack(); } finally { // reset the status diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/utils/PipelinesV2.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/utils/PipelinesV2.java index 6e93c02bc137e..c1b76db677ab6 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/utils/PipelinesV2.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/v2/utils/PipelinesV2.java @@ -44,9 +44,9 @@ import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.streaming.api.operators.ProcessOperator; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.planner.plan.nodes.exec.utils.ExecNodeUtil; import org.apache.flink.table.types.logical.RowType; +import static org.apache.hudi.sink.utils.FlinkTransformationUtils.setManagedMemoryWeight; import static org.apache.hudi.sink.utils.Pipelines.opUID; /** @@ -119,7 +119,7 @@ public static DataStream composePipeline( DataStream pipeline = Pipelines.append(conf, rowType, dataStream); if (OptionsResolver.needsAsyncClustering(conf)) { return clusterV2(conf, rowType, pipeline); - } else if (OptionsResolver.isLazyFailedWritesCleanPolicy(conf)) { + } else if (OptionsResolver.isLazyFailedWritesCleaning(conf)) { // add clean function to rollback failed writes for lazy failed writes cleaning policy return cleanV2(conf, pipeline); } else { @@ -138,8 +138,10 @@ public static DataStream composePipeline( conf.set(FlinkOptions.COMPACTION_OPERATION_EXECUTE_ASYNC_ENABLED, false); } return compactV2(conf, pipeline); - } else { + } else if (OptionsResolver.needsAsyncCleaning(conf)) { return cleanV2(conf, pipeline); + } else { + return pipeline; } } @@ -156,7 +158,7 @@ private static int getParallelismForSinkV2(Configuration conf) { if (OptionsResolver.isBulkInsertOperation(conf)) { return conf.get(FlinkOptions.WRITE_TASKS); } else if (OptionsResolver.isAppendMode(conf)) { - return OptionsResolver.needsAsyncClustering(conf) || OptionsResolver.isLazyFailedWritesCleanPolicy(conf) + return OptionsResolver.needsAsyncClustering(conf) || OptionsResolver.isLazyFailedWritesCleaning(conf) ? 1 : conf.get(FlinkOptions.WRITE_TASKS); } else { return 1; @@ -225,7 +227,7 @@ public static DataStream clusterV2(Configuration conf, RowType rowType, new ClusteringOperator(conf, rowType)) .setParallelism(conf.get(FlinkOptions.CLUSTERING_TASKS)); if (OptionsResolver.sortClusteringEnabled(conf)) { - ExecNodeUtil.setManagedMemoryWeight(clusteringStream.getTransformation(), + setManagedMemoryWeight(clusteringStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); } return clusteringStream.transform( diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java index 24357fbe21e7a..5cddd607549d1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java @@ -21,6 +21,7 @@ import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.configuration.Configuration; import org.apache.flink.core.fs.Path; +import org.apache.hudi.common.function.SerializableSupplier; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.Option; @@ -38,6 +39,7 @@ import org.apache.hudi.source.reader.function.SplitReaderFunction; import org.apache.hudi.source.split.DefaultHoodieSplitDiscover; import org.apache.hudi.source.split.DefaultHoodieSplitProvider; +import org.apache.hudi.source.split.GlobalHoodieSplitProvider; import org.apache.hudi.source.split.HoodieContinuousSplitDiscover; import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.source.split.HoodieSourceSplitSerializer; @@ -75,7 +77,7 @@ public class HoodieSource extends FileIndexReader implements Source readerFunction; + private final SerializableSupplier> readerFunctionSupplier; private final SerializableComparator splitComparator; private final HoodieTableMetaClient metaClient; private final HoodieRecordEmitter recordEmitter; @@ -83,18 +85,18 @@ public class HoodieSource extends FileIndexReader implements Source readerFunction, + SerializableSupplier> readerFunctionSupplier, SerializableComparator splitComparator, HoodieTableMetaClient metaClient, HoodieRecordEmitter recordEmitter) { ValidationUtils.checkArgument(scanContext != null, "scanContext can't be null."); - ValidationUtils.checkArgument(readerFunction != null, "readerFunction can't be null."); + ValidationUtils.checkArgument(readerFunctionSupplier != null, "readerFunctionSupplier can't be null."); ValidationUtils.checkArgument(splitComparator != null, "splitComparator can't be null."); ValidationUtils.checkArgument(metaClient != null, "metaClient can't be null."); ValidationUtils.checkArgument(recordEmitter != null, "recordEmitter can't be null."); this.scanContext = scanContext; - this.readerFunction = readerFunction; + this.readerFunctionSupplier = readerFunctionSupplier; this.splitComparator = splitComparator; this.metaClient = metaClient; this.recordEmitter = recordEmitter; @@ -129,29 +131,40 @@ public SimpleVersionedSerializer getEnumeratorCheckp @Override public SourceReader createReader(SourceReaderContext readerContext) throws Exception { - return new HoodieSourceReader(tableName, recordEmitter, scanContext, readerContext, readerFunction, splitComparator); + return new HoodieSourceReader( + tableName, recordEmitter, scanContext, readerContext, readerFunctionSupplier, splitComparator); } private SplitEnumerator createEnumerator( SplitEnumeratorContext enumContext, @Nullable HoodieSplitEnumeratorState enumeratorState) { + final boolean streaming = scanContext.isStreaming(); + + // Streaming keeps per-subtask assignment (DefaultHoodieSplitProvider) so that a file id's + // successive incremental splits stay affine to one reader. Bounded reads instead use a shared + // work-stealing pool: the full split set is known up front and each split is independent and + // order-free (one split per file group, no cross-commit continuation), so any reader can read + // any split. Serving from one pool keeps every reader busy until it is drained, which removes + // the straggler tail that count-balanced, non-stealing assignment produces. HoodieSplitProvider splitProvider; - HoodieSplitAssigner splitAssigner = HoodieSplitAssigners.createHoodieSplitAssigner( - scanContext.getConf(), enumContext.currentParallelism()); - - if (enumeratorState == null) { + if (streaming) { + HoodieSplitAssigner splitAssigner = HoodieSplitAssigners.createHoodieSplitAssigner( + scanContext.getConf(), enumContext.currentParallelism()); splitProvider = new DefaultHoodieSplitProvider(splitAssigner); } else { + splitProvider = new GlobalHoodieSplitProvider(); + } + + if (enumeratorState != null) { LOG.info( "Hoodie source restored {} splits from state for table {}", enumeratorState.getPendingSplitStates().size(), tableName); List pendingSplits = enumeratorState.getPendingSplitStates().stream().map(HoodieSourceSplitState::getSplit).collect(Collectors.toList()); - splitProvider = new DefaultHoodieSplitProvider(splitAssigner); splitProvider.onDiscoveredSplits(pendingSplits); } - if (scanContext.isStreaming()) { + if (streaming) { HoodieContinuousSplitDiscover discover = new DefaultHoodieSplitDiscover( scanContext); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java index bc6ebd29da21a..68e81271d6c86 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java @@ -147,7 +147,7 @@ public Result inputSplits( IncrementalQueryAnalyzer.QueryContext analyzingResult = analyzer.analyze(); if (analyzingResult.isEmpty()) { - log.info("No new instant found for the table under path " + path + ", skip reading"); + log.info("No new instant found for the table under path {}, skip reading", path); return Result.EMPTY; } final HoodieTimeline commitTimeline = analyzingResult.getActiveTimeline(); @@ -184,7 +184,12 @@ public Result inputSplits( return Result.EMPTY; } fileInfoList = fileIndex.getFilesInPartitions(); - List allFileSlices = getFileSlices(metaClient, commitTimeline, readPartitions, fileInfoList, analyzingResult.getMaxCompletionTime(), false); + // Use the full commits-and-compaction timeline rather than the (possibly compaction-filtered) + // activeTimeline carried by the QueryContext. Otherwise, on a MOR table with + // 'read.streaming.skip_compaction = true', file slice boundaries would be wrongly + // computed and log files could be missed, causing data loss. + List allFileSlices = getFileSlices(metaClient, getFullCommitsTimeline(metaClient), + readPartitions, fileInfoList, analyzingResult.getMaxCompletionTime(), false); fileSlices = fileIndex.filterFileSlices(allFileSlices); } else { if (cdcEnabled) { @@ -217,7 +222,11 @@ public Result inputSplits( return Result.EMPTY; } fileInfoList = fileIndex.getFilesInPartitions(); - List allFileSlices = getFileSlices(metaClient, commitTimeline, readPartitions, fileInfoList, analyzingResult.getMaxCompletionTime(), false); + // Same reason as the full-table-scan branch above: build the FileSystemView with the + // complete commits-and-compaction timeline to avoid losing data when 'skip_compaction' + // is enabled. + List allFileSlices = getFileSlices(metaClient, getFullCommitsTimeline(metaClient), + readPartitions, fileInfoList, analyzingResult.getMaxCompletionTime(), false); fileSlices = fileIndex.filterFileSlices(allFileSlices); } else { fileSlices = getFileSlices(metaClient, commitTimeline, readPartitions, files, analyzingResult.getMaxCompletionTime(), false); @@ -267,7 +276,7 @@ public Result inputSplits( IncrementalQueryAnalyzer.QueryContext queryContext = analyzer.analyze(); if (queryContext.isEmpty()) { - log.info("No new instant found for the table under path " + path + ", skip reading"); + log.info("No new instant found for the table under path {}, skip reading", path); return Result.EMPTY; } @@ -295,7 +304,10 @@ public Result inputSplits( log.warn("No files found for reading under path: {}", path); return Result.EMPTY; } - List allFileSlices = getFileSlices(metaClient, commitTimeline, readPartitions, pathInfoList, offsetToIssue, false); + // Same reason as the batch full-table-scan branch: + // see getFullCommitsTimeline() for why a compaction-filtered timeline must not be used here. + List allFileSlices = getFileSlices(metaClient, getFullCommitsTimeline(metaClient), + readPartitions, pathInfoList, offsetToIssue, false); List fileSlices = fileIndex.filterFileSlices(allFileSlices); List inputSplits = getInputSplits(fileSlices, metaClient, endInstant, null); @@ -391,6 +403,23 @@ private List getIncInputSplits( return getInputSplits(fileSlices, metaClient, endInstant, instantRange); } + /** + * Returns the full commit timeline (including completed compaction instants) for building + * a {@link HoodieTableFileSystemView} during full table scan. + * + *

    NOTE: when streaming/batch read enables {@code skip_compaction}, the {@code activeTimeline} + * carried by {@link IncrementalQueryAnalyzer.QueryContext} has already filtered out the + * compaction instants. Using such a partial timeline to construct a {@link HoodieTableFileSystemView} + * would mis-classify the file slice boundaries on a MOR table (since file slice boundaries + * are derived from compaction instants), leading to data loss when reading from the earliest + * or after start commit got archived. For full table scan we should always rely on the + * complete commits-and-compaction timeline; the {@code skip_compaction} semantics is preserved + * by the instant range filtering applied later on the generated input splits. + */ + private static HoodieTimeline getFullCommitsTimeline(HoodieTableMetaClient metaClient) { + return metaClient.getCommitsAndCompactionTimeline().filterCompletedAndCompactionInstants(); + } + private List getFileSlices( HoodieTableMetaClient metaClient, HoodieTimeline commitTimeline, @@ -472,8 +501,7 @@ private Set getReadPartitions(List metadataList) { double total = partitions.size(); double selectedNum = selectedPartitions.size(); double percentPruned = total == 0 ? 0 : (1 - selectedNum / total) * 100; - log.info("Selected " + selectedNum + " partitions out of " + total - + ", pruned " + percentPruned + "% partitions."); + log.info("Selected {} partitions out of {}, pruned {}% partitions.", selectedNum, total, percentPruned); return selectedPartitions; } return partitions; diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java index 99e01e45aabf1..ea0fce5644ded 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java @@ -20,46 +20,54 @@ import org.apache.flink.connector.base.source.reader.RecordsBySplits; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.common.util.collection.ClosableIterator; import java.util.Collections; +import java.util.List; import java.util.Set; import javax.annotation.Nullable; import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; /** - * Implementation of RecordsWithSplitIds with a list record inside. + * Implementation of {@link RecordsWithSplitIds} backed by a materialized, bounded minibatch of + * records for a single split. * - * Type parameters: – record type + *

    The records are already drained (and copied) on the split-fetcher thread before this batch is + * enqueued, so {@link #nextRecordFromSplit()} only walks an in-memory list and holds no live I/O + * resource. Record reading and resource teardown therefore stay on the same (split-fetcher) thread, + * which removes the cross-thread teardown race a live-iterator batch would be exposed to. The + * underlying iterator and its I/O resources are owned and closed by the reader function (see + * {@code AbstractSplitReaderFunction#closeCurrentSplit}). + * + * @param record type */ public class BatchRecords implements RecordsWithSplitIds> { private String splitId; private String nextSplitId; - private final ClosableIterator recordIterator; + private final List records; private final Set finishedSplits; private final HoodieRecordWithPosition recordAndPosition; - // point to current read position within the records list - private int position; + // points to the current read position within the records list + private int index; BatchRecords( String splitId, - ClosableIterator recordIterator, + List records, int fileOffset, long startingRecordOffset, Set finishedSplits) { ValidationUtils.checkArgument( finishedSplits != null, "finishedSplits can be empty but not null"); ValidationUtils.checkArgument( - recordIterator != null, "recordIterator can be empty but not null"); + records != null, "records can be empty but not null"); this.splitId = splitId; this.nextSplitId = splitId; - this.recordIterator = recordIterator; + this.records = records; this.finishedSplits = finishedSplits; this.recordAndPosition = new HoodieRecordWithPosition<>(); this.recordAndPosition.set(null, fileOffset, startingRecordOffset); - this.position = 0; + this.index = 0; } @Nullable @@ -67,7 +75,7 @@ public class BatchRecords implements RecordsWithSplitIds nextRecordFromSplit() { - if (recordIterator.hasNext()) { - recordAndPosition.record(recordIterator.next()); - position = position + 1; + if (index < records.size()) { + recordAndPosition.record(records.get(index)); + index++; return recordAndPosition; } else { - recordIterator.close(); return null; } } @@ -95,29 +102,14 @@ public Set finishedSplits() { @Override public void recycle() { - if (recordIterator != null) { - recordIterator.close(); - } - } - - public void seek(long startingRecordOffset) { - for (long i = 0; i < startingRecordOffset; ++i) { - if (recordIterator.hasNext()) { - position = position + 1; - recordIterator.next(); - } else { - throw new IllegalStateException( - String.format( - "Invalid starting record offset %d for split %s", - startingRecordOffset, - splitId)); - } - } + // No-op: the minibatch is fully materialized, so there is no live iterator or I/O resource to + // release here. The underlying reader is owned and closed by the reader function on the + // split-fetcher thread (AbstractSplitReaderFunction#closeCurrentSplit). } public static BatchRecords forRecords( - String splitId, ClosableIterator recordIterator, int fileOffset, long startingRecordOffset) { - return new BatchRecords<>(splitId, recordIterator, fileOffset, startingRecordOffset, Set.of()); + String splitId, List records, int fileOffset, long startingRecordOffset) { + return new BatchRecords<>(splitId, records, fileOffset, startingRecordOffset, Set.of()); } public static RecordsWithSplitIds> lastBatchRecords(String splitId) { @@ -128,4 +120,4 @@ public static RecordsWithSplitIds> lastBatchReco // in SourceReaderBase for bounded (batch) reads. return new RecordsBySplits<>(Collections.emptyMap(), Set.of(splitId)); } -} \ No newline at end of file +} diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceReader.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceReader.java index d1252823aeda7..70e232295c1d1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceReader.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceReader.java @@ -22,6 +22,7 @@ import org.apache.flink.connector.base.source.reader.RecordEmitter; import org.apache.flink.connector.base.source.reader.SingleThreadMultiplexSourceReaderBase; +import org.apache.hudi.common.function.SerializableSupplier; import org.apache.hudi.common.util.Option; import org.apache.hudi.source.HoodieScanContext; import org.apache.hudi.source.reader.function.SplitReaderFunction; @@ -45,9 +46,9 @@ public HoodieSourceReader( RecordEmitter, T, HoodieSourceSplit> recordEmitter, HoodieScanContext scanContext, SourceReaderContext context, - SplitReaderFunction readerFunction, + SerializableSupplier> readerFunctionSupplier, SerializableComparator splitComparator) { - super(() -> new HoodieSourceSplitReader<>(tableName, context, readerFunction, splitComparator, + super(() -> new HoodieSourceSplitReader<>(tableName, context, readerFunctionSupplier, splitComparator, scanContext.getLimit() == RecordLimiter.NO_LIMIT ? Option.empty() : Option.of(new RecordLimiter(scanContext.getLimit()))), recordEmitter, scanContext.getConf(), context); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java index c916dc21dadb6..47379789af813 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java @@ -18,6 +18,7 @@ package org.apache.hudi.source.reader; +import org.apache.hudi.common.function.SerializableSupplier; import org.apache.flink.api.connector.source.SourceReaderContext; import org.apache.flink.connector.base.source.reader.RecordsBySplits; import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; @@ -46,32 +47,44 @@ /** * The split reader of Hoodie source. * - *

    Each call to {@link #fetch()} reads one split and returns it as a single - * {@link RecordsWithSplitIds} batch. Flink's {@code SourceReaderBase} is responsible for - * draining all records from the batch (via {@code nextRecordFromSplit()}) and marking - * the split finished (via {@code finishedSplits()}) before calling {@link #fetch()} again. + *

    Each call to {@link #fetch()} returns one bounded minibatch of the currently open split, so a + * single split spans multiple {@code fetch()} calls. When the open split is exhausted its resources + * are closed on this (split-fetcher) thread and a finish signal ({@code finishedSplits()}) is + * returned so Flink's {@code SourceReaderBase} can advance / reach end-of-input. All record reading + * and resource teardown for a split therefore happen on the same thread, which removes the + * cross-thread teardown race a live-iterator batch would be exposed to. * * @param record type */ public class HoodieSourceSplitReader implements SplitReader, HoodieSourceSplit> { private static final Logger LOG = LoggerFactory.getLogger(HoodieSourceSplitReader.class); + // Upper bound on the number of records materialized per fetch() call (one minibatch). Kept as a + // fixed constant for now (mirrors RecordIterators.DEFAULT_BATCH_SIZE); it can be promoted to a + // Flink option later if a tunable per-fetch bound is ever needed. + private static final int DEFAULT_MINI_BATCH_SIZE = 2048; + private final SerializableComparator splitComparator; private final Queue splits; private final FlinkStreamReadMetrics readerMetrics; private final SplitReaderFunction readerFunction; private final Option recordLimiter; private transient HoodieSourceSplit currentSplit; + // Set by wakeUp() (possibly from another thread) to stop the in-flight minibatch drain promptly. + // Reset at the start of every fetch(), so it only ever means "a wakeUp() landed during THIS fetch". + private volatile boolean wokenUp; public HoodieSourceSplitReader( String tableName, SourceReaderContext context, - SplitReaderFunction readerFunction, + SerializableSupplier> readerFunctionSupplier, SerializableComparator splitComparator, Option recordLimiter) { this.splitComparator = splitComparator; this.splits = new ArrayDeque<>(); - this.readerFunction = readerFunction; + // Flink can start a new fetcher before the previous idle fetcher finishes closing. Supply a + // fresh stateful cursor for each split reader so the old fetcher's close cannot affect the new one. + this.readerFunction = readerFunctionSupplier.get(); this.recordLimiter = recordLimiter; this.readerMetrics = new FlinkStreamReadMetrics(context.metricGroup(), tableName); this.readerMetrics.registerMetrics(); @@ -79,27 +92,47 @@ public HoodieSourceSplitReader( @Override public RecordsWithSplitIds> fetch() throws IOException { - // finish current split. - if (currentSplit != null) { - return finishSplit(); + // A wakeUp() only needs to unblock an in-progress fetch(); Flink's SplitFetcher drives shutdown + // off its own 'closed' flag (set before wakeUp() and checked before the next fetch()), not off a + // lasting wakeUp effect. Start each cycle from a clean flag so the drain below reacts only to a + // wakeUp that lands during THIS fetch. + wokenUp = false; + if (currentSplit == null) { + // Limit already satisfied: drain any remaining locally-queued splits as immediately finished + // so that Flink's SourceReaderBase can reach end-of-input cleanly. + if (recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) { + return drainRemainingAsSplitsFinished(); + } + HoodieSourceSplit nextSplit = splits.poll(); + if (nextSplit == null) { + // return an empty result, which will lead to split fetch to be idle. + // SplitFetcherManager will then close idle fetcher. + return new RecordsBySplits<>(Collections.emptyMap(), Collections.emptySet()); + } + currentSplit = nextSplit; + readerFunction.open(currentSplit); } - // Limit already satisfied: drain any remaining locally-queued splits as immediately finished - // so that Flink's SourceReaderBase can reach end-of-input cleanly. - if (recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) { - return drainRemainingAsSplitsFinished(); + // Read the next bounded minibatch of the open split, unless the global limit is already reached. + if (!recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) { + BatchRecords batch = readerFunction.readBatch(currentSplit, DEFAULT_MINI_BATCH_SIZE, () -> wokenUp); + if (batch != null) { + // Partial (woken) or full minibatch; the split is not finished either way. + return recordLimiter.map(rl -> rl.wrap(batch)).orElse(batch); + } + if (wokenUp) { + // Woken before any record was buffered: return promptly WITHOUT finishing or closing the + // split, so it resumes on the next fetch(), or the fetcher observes shutdown and closes it + // on this (split-fetcher) thread. This branch must stay inside the !isLimitReached block: + // a wakeUp coinciding with the limit-reached path below must still finish the split. + return new RecordsBySplits<>(Collections.emptyMap(), Collections.emptySet()); + } } - HoodieSourceSplit nextSplit = splits.poll(); - if (nextSplit != null) { - currentSplit = nextSplit; - RecordsWithSplitIds> records = readerFunction.read(nextSplit); - return recordLimiter.map(rl -> rl.wrap(records)).orElse(records); - } else { - // return an empty result, which will lead to split fetch to be idle. - // SplitFetcherManager will then close idle fetcher. - return new RecordsBySplits<>(Collections.emptyMap(), Collections.emptySet()); - } + // Split exhausted (or the limit was reached mid-split): close its resources on this + // (split-fetcher) thread first, then emit the finish signal so SourceReaderBase can advance. + readerFunction.closeCurrentSplit(); + return finishSplit(); } @Override @@ -122,13 +155,17 @@ public void handleSplitsChanges(SplitsChange splitsChange) { @Override public void wakeUp() { - // Nothing to do + // Flink calls this (while holding SplitFetcher.lock) to unblock a fetch() that is draining a + // minibatch, e.g. on shutdown. Keep it a non-blocking plain volatile write; the drain loop in + // readBatch polls the flag between records and returns promptly. The actual resource teardown + // still happens on the split-fetcher thread via close()/closeCurrentSplit(). + wokenUp = true; } /** * SourceSplitReader only reads splits sequentially. When waiting for watermark alignment * the SourceOperator will stop processing and recycling the fetched batches. Based on this the - * {@code pauseOrResumeSplits} and the {@code wakeUp} are left empty. + * {@code pauseOrResumeSplits} is left empty. * @param splitsToPause splits to pause * @param splitsToResume splits to resume */ diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java index 6bef94ff0709d..f3ca8f5c98af1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java @@ -20,16 +20,37 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.RowData; +import org.apache.flink.table.runtime.typeutils.RowDataSerializer; +import org.apache.flink.table.types.logical.RowType; +import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.source.ExpressionPredicates; +import org.apache.hudi.source.reader.BatchRecords; +import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.table.format.InternalSchemaManager; import org.apache.hudi.util.FlinkWriteClients; +import java.util.ArrayList; import java.util.List; +import java.util.function.BooleanSupplier; /** - * Abstract implementation of SplitReaderFunction. + * Abstract implementation of {@link SplitReaderFunction} that provides the per-split cursor + * machinery shared by all reader functions. + * + *

    A subclass only supplies how to create the record iterator for a split + * ({@link #createRecordIterator(HoodieSourceSplit)}) and the {@link RowType} of the records it + * produces ({@link #producedRowType()}); this base drives {@link #open(HoodieSourceSplit)}, + * {@link #readBatch(HoodieSourceSplit, int, java.util.function.BooleanSupplier)}, {@link #closeCurrentSplit()} and {@link #close()}, + * all on the single split-fetcher thread. + * + *

    Because Flink's columnar readers (and the CDC/MOR row projections) return the same reused + * {@link RowData} object on every {@code next()}, {@link #readBatch} copies each record before + * buffering it - materializing raw references would make every entry in a minibatch alias the last + * row. {@link RowDataSerializer#copy(RowData)} preserves the record's {@code RowKind}, so no + * re-apply is needed. */ public abstract class AbstractSplitReaderFunction implements SplitReaderFunction { @@ -39,6 +60,11 @@ public abstract class AbstractSplitReaderFunction implements SplitReaderFunction protected final boolean emitDelete; private transient HoodieWriteConfig writeConfig; private transient org.apache.hadoop.conf.Configuration hadoopConf; + private transient RowDataSerializer copySerializer; + + // Per-split cursor state (split-fetcher thread only). + private transient ClosableIterator currentIterator; + private transient long nextRecordOffset; public AbstractSplitReaderFunction( Configuration conf, @@ -51,6 +77,85 @@ public AbstractSplitReaderFunction( this.emitDelete = emitDelete; } + /** + * Creates the record iterator (and its underlying I/O resources) for {@code split}. Closing the + * returned iterator must release all of those resources. + */ + protected abstract ClosableIterator createRecordIterator(HoodieSourceSplit split); + + /** The {@link RowType} of the records produced by {@link #createRecordIterator}. */ + protected abstract RowType producedRowType(); + + @Override + public void open(HoodieSourceSplit split) { + this.currentIterator = createRecordIterator(split); + try { + // Skip the records already emitted before the last checkpoint so a recovered split resumes at + // the right position; matches the validation the old BatchRecords#seek performed. + long consumed = split.getConsumed(); + for (long i = 0; i < consumed; i++) { + if (currentIterator.hasNext()) { + currentIterator.next(); + } else { + throw new IllegalStateException( + String.format("Invalid starting record offset %d for split %s", consumed, split.splitId())); + } + } + this.nextRecordOffset = consumed; + } catch (RuntimeException | Error e) { + // Close on failure so the split's I/O resources are released even if the resume-skip fails. + closeCurrentSplit(); + throw e; + } + } + + @Override + public BatchRecords readBatch(HoodieSourceSplit split, int batchSize, BooleanSupplier wakeupSignal) { + ValidationUtils.checkState(currentIterator != null, + "readBatch called before open for split " + split.splitId()); + RowDataSerializer serializer = getCopySerializer(); + List buffer = new ArrayList<>(); + try { + // Poll wakeupSignal between records so a wakeUp() lands promptly: materialization stops early + // and whatever is buffered so far is returned as a partial minibatch (null if nothing yet). + while (buffer.size() < batchSize && !wakeupSignal.getAsBoolean() && currentIterator.hasNext()) { + RowData next = currentIterator.next(); + buffer.add(serializer.copy(next)); + } + } catch (RuntimeException | Error e) { + // Close on failure so the split's I/O resources are released even if a mid-drain read fails. + closeCurrentSplit(); + throw e; + } + if (buffer.isEmpty()) { + return null; + } + long startingRecordOffset = nextRecordOffset; + nextRecordOffset += buffer.size(); + return BatchRecords.forRecords(split.splitId(), buffer, split.getFileOffset(), startingRecordOffset); + } + + @Override + public void closeCurrentSplit() { + if (currentIterator != null) { + currentIterator.close(); + currentIterator = null; + } + nextRecordOffset = 0; + } + + @Override + public void close() throws Exception { + closeCurrentSplit(); + } + + private RowDataSerializer getCopySerializer() { + if (copySerializer == null) { + copySerializer = new RowDataSerializer(producedRowType()); + } + return copySerializer; + } + protected HoodieWriteConfig getWriteConfig() { if (writeConfig == null) { writeConfig = FlinkWriteClients.getHoodieClientConfig(conf); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java index 0b0d4f6191bc0..b69bd49570ca5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java @@ -39,8 +39,6 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.source.ExpressionPredicates; -import org.apache.hudi.source.reader.BatchRecords; -import org.apache.hudi.source.reader.HoodieRecordWithPosition; import org.apache.hudi.source.split.HoodieCdcSourceSplit; import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.table.format.FilePathUtils; @@ -55,9 +53,9 @@ import org.apache.hudi.util.StreamerUtil; import lombok.extern.slf4j.Slf4j; -import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.logical.RowType; import org.apache.hadoop.fs.Path; import java.io.IOException; @@ -67,6 +65,8 @@ import java.util.function.Function; import java.util.stream.Collectors; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; + /** * CDC reader function for source V2. Reads CDC splits ({@link HoodieCdcSourceSplit}) and * emits change-log {@link RowData} records tagged with the appropriate {@link org.apache.flink.types.RowKind}. @@ -80,7 +80,6 @@ public class HoodieCdcSplitReaderFunction extends AbstractSplitReaderFunction { private final List fieldTypes; private final MergeOnReadTableState tableState; private transient HoodieTableMetaClient metaClient; - private transient ClosableIterator currentIterator; // Fallback reader for non-CDC splits (e.g. snapshot reads when read.start-commit='earliest') private transient HoodieSplitReaderFunction fallbackReaderFunction; @@ -109,12 +108,12 @@ public HoodieCdcSplitReaderFunction( } @Override - public RecordsWithSplitIds> read(HoodieSourceSplit split) { + protected ClosableIterator createRecordIterator(HoodieSourceSplit split) { if (!(split instanceof HoodieCdcSourceSplit)) { // Non-CDC splits arrive when reading from 'earliest' with no prior CDC history // (i.e. instantRange is empty → snapshot path). Fall back to the standard MOR reader // which emits all records as INSERT rows, matching the expected snapshot behaviour. - return getFallbackReaderFunction().read(split); + return getFallbackReaderFunction().createRecordIterator(split); } HoodieCdcSourceSplit cdcSplit = (HoodieCdcSourceSplit) split; @@ -131,21 +130,15 @@ public RecordsWithSplitIds> read(HoodieSourceS mode, imageManager); - currentIterator = new CdcIterators.CdcFileSplitsIterator(cdcSplit.getChanges(), imageManager, recordIteratorFunc); - BatchRecords records = BatchRecords.forRecords( - split.splitId(), currentIterator, split.getFileOffset(), split.getConsumed()); - records.seek(split.getConsumed()); - return records; + // The CdcFileSplitsIterator owns the imageManager and its per-split record iterators; closing it + // (via the base class closeCurrentSplit) releases them. The base class handles the consumed-offset + // skip and the minibatch materialization uniformly with the MOR/COW path. + return new CdcIterators.CdcFileSplitsIterator(cdcSplit.getChanges(), imageManager, recordIteratorFunc); } @Override - public void close() throws Exception { - if (currentIterator != null) { - currentIterator.close(); - } - if (fallbackReaderFunction != null) { - fallbackReaderFunction.close(); - } + protected RowType producedRowType() { + return tableState.getRequiredRowType(); } // ------------------------------------------------------------------------- @@ -231,10 +224,15 @@ private ClosableIterator createRecordIterator( String logFilePath = new Path(tablePath, fileSplit.getCdcFiles().get(0)).toString(); MergeOnReadInputSplit split = CdcIterators.singleLogFile2Split(tablePath, logFilePath, maxCompactionMemoryInBytes); ClosableIterator> recordIterator = getFileSliceHoodieRecordIterator(split); - return new CdcIterators.DataLogFileIterator( - maxCompactionMemoryInBytes, imageManager, fileSplit, tableSchema, - tableState.getRequiredRowType(), tableState.getRequiredPositions(), - recordIterator, getMetaClient(), getWriteConfig()); + try { + return new CdcIterators.DataLogFileIterator( + maxCompactionMemoryInBytes, imageManager, fileSplit, tableSchema, + tableState.getRequiredRowType(), tableState.getRequiredPositions(), + recordIterator, getMetaClient(), getWriteConfig()); + } catch (IOException | RuntimeException | Error e) { + closeSuppressing(recordIterator, e); + throw e; + } } case REPLACE_COMMIT: { return new CdcIterators.ReplaceCommitIterator( diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java index ac7827bfef7de..7c73a8f01de9c 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java @@ -30,14 +30,13 @@ import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.source.ExpressionPredicates; -import org.apache.hudi.source.reader.BatchRecords; -import org.apache.hudi.source.reader.HoodieRecordWithPosition; import org.apache.hudi.source.split.HoodieSourceSplit; -import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; import org.apache.hudi.table.format.FormatUtils; import org.apache.hudi.table.format.InternalSchemaManager; +import org.apache.hudi.util.HoodieSchemaConverter; import org.apache.hudi.util.StreamerUtil; import java.io.IOException; @@ -45,6 +44,8 @@ import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; + /** * Default reader function implementation for both MOR and COW tables. */ @@ -52,7 +53,6 @@ public class HoodieSplitReaderFunction extends AbstractSplitReaderFunction { private final HoodieSchema tableSchema; private final HoodieSchema requiredSchema; private final String mergeType; - private transient HoodieFileGroupReader fileGroupReader; public HoodieSplitReaderFunction( Configuration configuration, @@ -72,26 +72,28 @@ public HoodieSplitReaderFunction( } @Override - public RecordsWithSplitIds> read(HoodieSourceSplit split) { - final String splitId = split.splitId(); + protected ClosableIterator createRecordIterator(HoodieSourceSplit split) { HoodieTableMetaClient metaClient = StreamerUtil.metaClientForReader(conf, getHadoopConf()); - + // Closing the returned iterator cascade-closes the whole HoodieFileGroupReader, so the base + // class only has to close the iterator in closeCurrentSplit(). But getClosableIterator() runs + // initRecordIterators(), which opens the reader's base-file iterator / record buffer before the + // wrapping iterator is returned; if it throws, the reader is only a local here and nothing else + // would close it. Keep it in a local and close it in the failure path. + HoodieFileGroupReader fileGroupReader = createFileGroupReader(split, metaClient); try { - this.fileGroupReader = createFileGroupReader(split, metaClient); - final ClosableIterator recordIterator = fileGroupReader.getClosableIterator(); - BatchRecords records = BatchRecords.forRecords(splitId, recordIterator, split.getFileOffset(), split.getConsumed()); - records.seek(split.getConsumed()); - return records; + return fileGroupReader.getClosableIterator(); } catch (IOException e) { + closeSuppressing(fileGroupReader, e); throw new HoodieIOException("Failed to read from file group: " + split.getFileId(), e); + } catch (RuntimeException | Error e) { + closeSuppressing(fileGroupReader, e); + throw e; } } @Override - public void close() throws Exception { - if (fileGroupReader != null) { - fileGroupReader.close(); - } + protected RowType producedRowType() { + return HoodieSchemaConverter.convertToRowType(requiredSchema); } /** @@ -101,7 +103,7 @@ public void close() throws Exception { * @param metaClient The table meta client for schema and config resolution * @return A {@link HoodieFileGroupReader} instance */ - private HoodieFileGroupReader createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient metaClient) { + protected HoodieFileGroupReader createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient metaClient) { // Create FileSlice from split information FileSlice fileSlice = new FileSlice( new HoodieFileGroupId(split.getPartitionPath(), split.getFileId()), diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java index 6f7bf0f18ebe2..46fb343aa3d4d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java @@ -18,19 +18,55 @@ package org.apache.hudi.source.reader.function; -import org.apache.hudi.source.reader.HoodieRecordWithPosition; +import org.apache.hudi.source.reader.BatchRecords; import org.apache.hudi.source.split.HoodieSourceSplit; -import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; - import java.io.Serializable; +import java.util.function.BooleanSupplier; /** - * Interface for split read function. + * Interface for a split read function. + * + *

    A reader function is a stateful, per-split cursor driven entirely on the Flink split-fetcher + * thread by {@link org.apache.hudi.source.reader.HoodieSourceSplitReader#fetch()}: + * {@link #open(HoodieSourceSplit)} creates the record iterator and its underlying I/O resources for + * a split, {@link #readBatch(HoodieSourceSplit, int, java.util.function.BooleanSupplier)} drains the next bounded minibatch, and + * {@link #closeCurrentSplit()} releases the split's resources once it is exhausted. Because open, + * read and close all run on the same thread, no record or I/O resource is ever touched concurrently. + * + * @param record type */ public interface SplitReaderFunction extends Serializable { - RecordsWithSplitIds> read(HoodieSourceSplit split); + /** + * Opens {@code split} for reading: creates the record iterator and its underlying I/O resources, + * and skips the records already consumed ({@link HoodieSourceSplit#getConsumed()}) so a recovered + * split resumes at the right position. + */ + void open(HoodieSourceSplit split); + + /** + * Drains up to {@code batchSize} records from the currently open split into a materialized + * {@link BatchRecords} minibatch. Returns {@code null} once the split is exhausted. + * + *

    {@code wakeupSignal} is polled between records: once it returns {@code true} materialization + * stops early and the records buffered so far are returned as a (non-finishing) partial minibatch; + * if nothing has been buffered yet {@code null} is returned. This lets a blocking {@code fetch()} + * unblock promptly on {@link org.apache.hudi.source.reader.HoodieSourceSplitReader#wakeUp()} + * without touching any resource off the split-fetcher thread. + */ + BatchRecords readBatch(HoodieSourceSplit split, int batchSize, BooleanSupplier wakeupSignal); + + /** + * Closes the currently open split's iterator and I/O resources. Called when the split is + * exhausted, a read fails, or the read is stopped early. Safe to call when no split is open. + */ + void closeCurrentSplit(); + /** + * Closes the reader function entirely (idempotent). Invoked by + * {@link org.apache.hudi.source.reader.HoodieSourceSplitReader#close()} on the split-fetcher + * thread. + */ void close() throws Exception; } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.java new file mode 100644 index 0000000000000..a3a582ece4cae --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.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.hudi.source.split; + +import org.apache.hudi.common.util.Option; + +import javax.annotation.Nullable; + +import java.util.Collection; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.stream.Collectors; + +/** + * Split provider that serves splits from a single shared pool, ignoring the requesting subtask id + * (work stealing): whichever reader asks next gets the next pending split, so all readers stay busy + * until the pool is fully drained. + * + *

    Intended for BOUNDED (batch) reads driven by + * {@link org.apache.hudi.source.enumerator.HoodieStaticSplitEnumerator}. There the complete split + * set is known up front and every split is independent: exactly one split per file group, no + * cross-commit continuation and no ordering relationship between splits. That holds for all bounded + * query modes, including the CDC one, where a file group's changes are sorted inside a single split + * rather than spread over several. Any reader can therefore safely read any split. + * + *

    Contrast with {@link DefaultHoodieSplitProvider}, which pins each split to one subtask (by + * hashing the file id, or round-robin on the split number) and never rebalances: a subtask that + * drew a heavier share runs long while its peers sit idle. Because that assignment balances split + * count rather than bytes or records, and cannot steal, even small per-subtask skew is + * unrecoverable and shows up as a declining tail at the end of a bounded read. + * + *

    NOT used for streaming reads: the continuous enumerator keeps per-subtask assignment (via + * {@link DefaultHoodieSplitProvider}) so that a file id's successive incremental splits stay affine + * to one reader, and so bucket id to subtask alignment is preserved for bucket index tables. + * + *

    Splits are served oldest-commit-first via {@link HoodieSourceSplitComparator}, the same + * ordering the per-subtask queues use. Thread safe: a {@link PriorityBlockingQueue} backs the pool, + * so {@link #pendingSplitCount()} can be read from the I/O threads for the unassigned splits gauge + * while the coordinator thread assigns. + */ +public class GlobalHoodieSplitProvider implements HoodieSplitProvider { + public static final int INITIAL_POOL_CAPACITY = 20; + + // Shared pool of unassigned splits, ordered by commit time (oldest first). + private final Queue pendingSplits; + private CompletableFuture availableFuture; + + public GlobalHoodieSplitProvider() { + this.pendingSplits = + new PriorityBlockingQueue<>(INITIAL_POOL_CAPACITY, new HoodieSourceSplitComparator()); + } + + @Override + public Option getNext(int taskId, @Nullable String hostname) { + // Work stealing: the subtask id and hostname are intentionally ignored, so any requesting + // reader gets the next split from the shared pool. Empty means the pool is globally drained; + // for the static enumerator (shouldWaitForMoreSplits() == false) that correctly triggers + // signalNoMoreSplits for the requesting reader. + HoodieSourceSplit next = pendingSplits.poll(); + return next == null ? Option.empty() : Option.of(next); + } + + @Override + public void onDiscoveredSplits(Collection splits) { + addSplits(splits); + } + + @Override + public void onUnassignedSplits(Collection splits) { + // Splits handed back by a failed reader (addSplitsBack) return to the shared pool and are + // picked up by whichever reader asks next, which need not be the failed subtask. Readers that + // already received no-more-splits are done, but any reader still asking can claim them. + addSplits(splits); + } + + private void addSplits(Collection splits) { + if (splits.isEmpty()) { + return; + } + pendingSplits.addAll(splits); + completeAvailableFuturesIfNeeded(); + } + + @Override + public Collection state() { + return pendingSplits.stream() + .map(split -> new HoodieSourceSplitState(split, HoodieSourceSplitStatus.UNASSIGNED)) + .collect(Collectors.toList()); + } + + @Override + public synchronized CompletableFuture isAvailable() { + if (availableFuture == null) { + availableFuture = new CompletableFuture<>(); + } + return availableFuture; + } + + @Override + public int pendingSplitCount() { + return pendingSplits.size(); + } + + @Override + public long pendingRecords() { + throw new UnsupportedOperationException( + "Pending records is not supported in GlobalHoodieSplitProvider."); + } + + private synchronized void completeAvailableFuturesIfNeeded() { + if (availableFuture != null && !pendingSplits.isEmpty()) { + availableFuture.complete(null); + // Cleared only once completed, so a waiter never loses the future it is blocked on. + availableFuture = null; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/HoodieSourceSplitSerializer.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/HoodieSourceSplitSerializer.java index 7f6771ba39408..57b0a30c4cb1b 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/HoodieSourceSplitSerializer.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/HoodieSourceSplitSerializer.java @@ -103,13 +103,13 @@ public byte[] serialize(HoodieSourceSplit obj) throws IOException { out.writeBoolean(false); } - out.writeBoolean(instantRange.getStartInstant().isPresent()); - if (instantRange.getStartInstant().isPresent()) { - out.writeUTF(instantRange.getStartInstant().get()); + out.writeBoolean(instantRange.getStartInstantOpt().isPresent()); + if (instantRange.getStartInstantOpt().isPresent()) { + out.writeUTF(instantRange.getStartInstantOpt().get()); } - out.writeBoolean(instantRange.getEndInstant().isPresent()); - if (instantRange.getEndInstant().isPresent()) { - out.writeUTF(instantRange.getEndInstant().get()); + out.writeBoolean(instantRange.getEndInstantOpt().isPresent()); + if (instantRange.getEndInstantOpt().isPresent()) { + out.writeUTF(instantRange.getEndInstantOpt().get()); } if (instantRange.getRangeType().equals(InstantRange.RangeType.EXACT_MATCH)) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/FlinkStreamerConfig.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/FlinkStreamerConfig.java index 278daacb081e3..52b8b9b6ca9ea 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/FlinkStreamerConfig.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/FlinkStreamerConfig.java @@ -42,6 +42,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.apache.hudi.common.util.PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH; import static org.apache.hudi.configuration.FlinkOptions.PARTITION_FORMAT_DAY; @@ -433,7 +434,8 @@ public static org.apache.flink.configuration.Configuration toFlinkConfig(FlinkSt conf.set(FlinkOptions.RECORD_MERGER_STRATEGY_ID, config.recordMergerStrategy); conf.set(FlinkOptions.PRE_COMBINE, config.preCombine); conf.set(FlinkOptions.RETRY_TIMES, Integer.parseInt(config.instantRetryTimes)); - conf.set(FlinkOptions.RETRY_INTERVAL_MS, Long.parseLong(config.instantRetryInterval)); + conf.set(FlinkOptions.RETRY_INTERVAL_MS, + TimeUnit.SECONDS.toMillis(Long.parseLong(config.instantRetryInterval))); conf.set(FlinkOptions.IGNORE_FAILED, config.commitOnErrors); conf.set(FlinkOptions.RECORD_KEY_FIELD, config.recordKeyField); conf.set(FlinkOptions.PARTITION_PATH_FIELD, config.partitionPathField); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/HoodieFlinkStreamer.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/HoodieFlinkStreamer.java index 721b3f94e470b..58be515ca9015 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/HoodieFlinkStreamer.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/streamer/HoodieFlinkStreamer.java @@ -97,7 +97,7 @@ public static void main(String[] args) throws Exception { pipeline = Pipelines.append(conf, rowType, dataStream); if (OptionsResolver.needsAsyncClustering(conf)) { Pipelines.cluster(conf, rowType, pipeline); - } else if (OptionsResolver.isLazyFailedWritesCleanPolicy(conf)) { + } else if (OptionsResolver.isLazyFailedWritesCleaning(conf)) { // add clean function to rollback failed writes for lazy failed writes cleaning policy Pipelines.clean(conf, pipeline); } else { @@ -108,8 +108,10 @@ public static void main(String[] args) throws Exception { pipeline = Pipelines.hoodieStreamWrite(conf, rowType, hoodieRecordDataStream); if (OptionsResolver.needsAsyncCompaction(conf)) { Pipelines.compact(conf, pipeline); - } else { + } else if (OptionsResolver.needsAsyncCleaning(conf)) { Pipelines.clean(conf, pipeline); + } else { + Pipelines.dummySink(pipeline); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java index 2f2ada47731a0..43d18e849b3d5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableFactory.java @@ -18,6 +18,7 @@ package org.apache.hudi.table; +import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.model.HoodieTableType; @@ -86,9 +87,9 @@ public DynamicTableSource createDynamicTableSource(Context context) { StoragePath path = new StoragePath(conf.getOptional(FlinkOptions.PATH).orElseThrow(() -> new ValidationException("Option [path] should not be empty."))); setupTableOptions(conf.get(FlinkOptions.PATH), conf); - checkBaseFileFormat(conf); ResolvedSchema schema = context.getCatalogTable().getResolvedSchema(); setupConfOptions(conf, context.getObjectIdentifier(), context.getCatalogTable(), schema); + checkBaseFileFormatForRead(conf, schema); return new HoodieTableSource( SerializableSchema.create(schema), path, @@ -116,11 +117,6 @@ public DynamicTableSink createDynamicTableSink(Context context) { private void setupTableOptions(String basePath, Configuration conf) { StreamerUtil.getTableConfig(basePath, HadoopConfigurations.getHadoopConf(conf)) .ifPresent(tableConfig -> { - // Guard: reject Lance from existing table config (hoodie.properties); checkBaseFileFormat() handles user-supplied config separately - if (tableConfig.contains(HoodieTableConfig.BASE_FILE_FORMAT) - && HoodieFileFormat.LANCE.name().equalsIgnoreCase(tableConfig.getString(HoodieTableConfig.BASE_FILE_FORMAT))) { - throw new HoodieValidationException(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG); - } if (tableConfig.contains(HoodieTableConfig.RECORDKEY_FIELDS) && !conf.contains(FlinkOptions.RECORD_KEY_FIELD)) { conf.set(FlinkOptions.RECORD_KEY_FIELD, tableConfig.getString(HoodieTableConfig.RECORDKEY_FIELDS)); @@ -177,8 +173,8 @@ public Set> optionalOptions() { * @param schema The table schema */ private void sanityCheck(Configuration conf, ResolvedSchema schema) { - checkBaseFileFormat(conf); checkTableType(conf); + checkBaseFileFormatForWrite(conf, schema); checkIndexType(conf); if (!OptionsResolver.isAppendMode(conf)) { @@ -220,15 +216,41 @@ private void checkIndexType(Configuration conf) { } /** - * Validate the base file format. Lance is only supported with the Spark engine. + * Validate the base file format. Flink Lance support is scoped to append-only COW tables. */ - private void checkBaseFileFormat(Configuration conf) { - String baseFileFormat = conf.getString(HoodieTableConfig.BASE_FILE_FORMAT.key(), null); - if (baseFileFormat != null && HoodieFileFormat.LANCE.name().equalsIgnoreCase(baseFileFormat)) { - throw new HoodieValidationException(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG); + private void checkBaseFileFormatForRead(Configuration conf, ResolvedSchema schema) { + checkLanceBaseFileFormat(conf, schema); + } + + private void checkBaseFileFormatForWrite(Configuration conf, ResolvedSchema schema) { + checkLanceBaseFileFormat(conf, schema); + if (isLanceBaseFileFormat(conf) && !OptionsResolver.isAppendMode(conf)) { + throw new HoodieValidationException("Flink Lance base-file writes require append-only INSERT mode. Set '" + + FlinkOptions.OPERATION.key() + "' = 'insert'."); } } + private void checkLanceBaseFileFormat(Configuration conf, ResolvedSchema schema) { + if (!isLanceBaseFileFormat(conf)) { + return; + } + if (conf.containsKey(FlinkOptions.RECORD_KEY_FIELD.key()) || schema.getPrimaryKey().isPresent()) { + throw new HoodieValidationException("Flink Lance base-file support is only available for append-only tables without primary keys."); + } + if (OptionsResolver.isMorTable(conf)) { + throw new HoodieValidationException("Flink Lance base-file support is only available for COPY_ON_WRITE append-only tables."); + } + if (OptionsResolver.isSchemaEvolutionEnabled(conf)) { + throw new HoodieValidationException("Flink Lance base-file support does not support schema evolution. Set '" + + HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key() + "' = 'false'."); + } + } + + private boolean isLanceBaseFileFormat(Configuration conf) { + String baseFileFormat = conf.getString(HoodieTableConfig.BASE_FILE_FORMAT.key(), null); + return baseFileFormat != null && HoodieFileFormat.LANCE.name().equalsIgnoreCase(baseFileFormat); + } + /** * Validate the table type. */ @@ -453,10 +475,6 @@ private static void setupWriteOptions(Configuration conf) { conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key(), "true"); conf.set(FlinkOptions.INDEX_GLOBAL_ENABLED, true); conf.setString(HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key(), "true"); - // set index bootstrap as true if not specified by user explicitly. - if (!conf.contains(FlinkOptions.INDEX_BOOTSTRAP_ENABLED)) { - conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); - } // generally size of index data is much smaller than data record, so set the buffer size of // the index writer as 1/4 of that for data writer if it's not set by user explicitly. if (!conf.contains(FlinkOptions.INDEX_RLI_WRITE_BUFFER_SIZE)) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSink.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSink.java index f46e2e672222c..45bfde998af64 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSink.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSink.java @@ -112,7 +112,7 @@ public SinkRuntimeProvider getSinkRuntimeProvider(Context context) { DataStream pipeline = Pipelines.append(conf, rowType, dataStream); if (OptionsResolver.needsAsyncClustering(conf)) { return Pipelines.cluster(conf, rowType, pipeline); - } else if (OptionsResolver.isLazyFailedWritesCleanPolicy(conf)) { + } else if (OptionsResolver.isLazyFailedWritesCleaning(conf)) { // add clean function to rollback failed writes for lazy failed writes cleaning policy return Pipelines.clean(conf, pipeline); } else { @@ -131,8 +131,10 @@ public SinkRuntimeProvider getSinkRuntimeProvider(Context context) { conf.set(FlinkOptions.COMPACTION_OPERATION_EXECUTE_ASYNC_ENABLED, false); } return Pipelines.compact(conf, pipeline); - } else { + } else if (OptionsResolver.needsAsyncCleaning(conf)) { return Pipelines.clean(conf, pipeline); + } else { + return Pipelines.dummySink(pipeline); } }; } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java index d1b13ff7e3754..30b41e34f8ac3 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java @@ -20,6 +20,7 @@ import org.apache.hudi.adapter.DataStreamScanProviderAdapter; import org.apache.hudi.adapter.InputFormatSourceFunctionAdapter; +import org.apache.hudi.common.function.SerializableSupplier; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieTableType; @@ -303,7 +304,7 @@ private HoodieSource createHoodieSource() { HoodieScanContext context = createHoodieScanContext(rowType); final HoodieTableType tableType = HoodieTableType.valueOf(this.conf.get(FlinkOptions.TABLE_TYPE)); - final SplitReaderFunction splitReaderFunction; + final SerializableSupplier> splitReaderFunctionSupplier; final MergeOnReadTableState hoodieTableState = new MergeOnReadTableState<>( rowType, requiredRowType, @@ -313,24 +314,16 @@ private HoodieSource createHoodieSource() { boolean emitDelete = tableType == HoodieTableType.MERGE_ON_READ && context.isStreaming(); if (conf.get(FlinkOptions.CDC_ENABLED)) { List fieldTypes = rowDataType.getChildren(); - splitReaderFunction = new HoodieCdcSplitReaderFunction( - conf, - hoodieTableState, - internalSchemaManager, - fieldTypes, - predicates, - emitDelete); + splitReaderFunctionSupplier = () -> new HoodieCdcSplitReaderFunction( + conf, hoodieTableState, internalSchemaManager, fieldTypes, predicates, emitDelete); } else { - splitReaderFunction = new HoodieSplitReaderFunction( - conf, - tableSchema, - HoodieSchemaConverter.convertToSchema(requiredRowType), - internalSchemaManager, - conf.get(FlinkOptions.MERGE_TYPE), - predicates, - emitDelete); + splitReaderFunctionSupplier = () -> new HoodieSplitReaderFunction( + conf, tableSchema, HoodieSchemaConverter.convertToSchema(requiredRowType), internalSchemaManager, + conf.get(FlinkOptions.MERGE_TYPE), predicates, emitDelete); } - return new HoodieSource<>(context, splitReaderFunction, new HoodieSourceSplitComparator(), metaClient, new HoodieRecordEmitter<>()); + return new HoodieSource<>( + context, splitReaderFunctionSupplier, new HoodieSourceSplitComparator(), metaClient, + new HoodieRecordEmitter<>()); } /** @@ -467,7 +460,7 @@ private PartitionPruners.PartitionPruner createPartitionPruner(List joiner.add(f.asSummaryString())); - log.info("Partition pruner for hoodie source, condition is:\n" + joiner); + log.info("Partition pruner for hoodie source, condition is:\n{}", joiner); List evaluators = ExpressionEvaluators.fromExpression(partitionFilters); List partitionTypes = this.partitionKeys.stream().map(name -> this.schema.getColumn(name).orElseThrow(() -> new HoodieValidationException("Field " + name + " does not exist"))) @@ -494,7 +487,7 @@ private Option> getDataBucketFunc(List indexKeyFields = Arrays.stream(OptionsResolver.getIndexKeyField(conf).split(",")).collect(Collectors.toSet()); + Set indexKeyFields = Arrays.stream(OptionsResolver.getBucketIndexKeys(conf)).collect(Collectors.toSet()); List indexKeyFilters = dataFilters.stream().filter(expr -> ExpressionUtils.isEqualsLitExpr(expr, indexKeyFields)).collect(Collectors.toList()); if (!ExpressionUtils.isFilteringByAllFields(indexKeyFilters, indexKeyFields)) { return Option.empty(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FlinkRowDataReaderContext.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FlinkRowDataReaderContext.java index 2015244e24ceb..130f94e59bf14 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FlinkRowDataReaderContext.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FlinkRowDataReaderContext.java @@ -39,6 +39,7 @@ import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieValidationException; import org.apache.hudi.io.storage.HoodieIOFactory; import org.apache.hudi.source.ExpressionPredicates; @@ -98,18 +99,30 @@ public ClosableIterator getFileRecordIterator( HoodieSchema dataSchema, HoodieSchema requiredSchema, HoodieStorage storage) throws IOException { - if (filePath.toString().endsWith(HoodieFileFormat.LANCE.getFileExtension())) { - throw new UnsupportedOperationException(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG); - } boolean isLogFile = FSUtils.isLogFile(filePath); // disable schema evolution in fileReader if it's log file, since schema evolution for log file is handled in `FileGroupRecordBuffer` InternalSchemaManager schemaManager = isLogFile ? InternalSchemaManager.DISABLED : internalSchemaManager.get(); + if (filePath.getName().endsWith(HoodieFileFormat.LANCE.getFileExtension())) { + if (schemaManager != InternalSchemaManager.DISABLED) { + throw new HoodieValidationException("Flink Lance base-file support does not support schema evolution."); + } + HoodieRowDataLanceReader rowDataLanceReader = + (HoodieRowDataLanceReader) HoodieIOFactory.getIOFactory(storage) + .getReaderFactory(HoodieRecord.HoodieRecordType.FLINK) + .getFileReader(tableConfig, filePath, HoodieFileFormat.LANCE, Option.empty()); + try { + return rowDataLanceReader.getRowDataIterator(RowDataQueryContexts.fromSchema(requiredSchema).getRowType(), requiredSchema); + } catch (RuntimeException e) { + rowDataLanceReader.close(); + throw new HoodieException("Failed to get iterator from lance reader", e); + } + } + DataType rowType = RowDataQueryContexts.fromSchema(dataSchema).getRowType(); HoodieRowDataParquetReader rowDataParquetReader = (HoodieRowDataParquetReader) HoodieIOFactory.getIOFactory(storage) .getReaderFactory(HoodieRecord.HoodieRecordType.FLINK) .getFileReader(tableConfig, filePath, HoodieFileFormat.PARQUET, Option.empty()); - DataType rowType = RowDataQueryContexts.fromSchema(dataSchema).getRowType(); return rowDataParquetReader.getRowDataIterator(schemaManager, rowType, requiredSchema, getSafePredicates(requiredSchema)); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java index d62763ef64af7..0bd7f53b611f1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java @@ -132,14 +132,16 @@ public static HoodieFileGroupReader createFileGroupReader( final TypedProperties typedProps = FlinkClientUtil.getReadProps(metaClient.getTableConfig(), writeConfig); typedProps.put(HoodieReaderConfig.MERGE_TYPE.key(), mergeType); - return HoodieFileGroupReader.newBuilder() + return HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(latestInstant) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(tableSchema) .withRequestedSchema(requiredSchema) - .withInternalSchema(Option.ofNullable(internalSchemaManager.getQuerySchema())) + .withInternalSchemaOpt(Option.ofNullable(internalSchemaManager.getQuerySchema())) .withProps(typedProps) .withShouldUseRecordPosition(false) .withEmitDelete(emitDelete) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataFileReaderFactory.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataFileReaderFactory.java index 2e7401d4caa0d..3ad64bd3972a5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataFileReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataFileReaderFactory.java @@ -18,6 +18,7 @@ package org.apache.hudi.table.format; +import org.apache.hudi.common.config.HoodieConfig; import org.apache.hudi.io.storage.HoodieFileReader; import org.apache.hudi.io.storage.HoodieFileReaderFactory; import org.apache.hudi.storage.HoodieStorage; @@ -35,4 +36,9 @@ public HoodieRowDataFileReaderFactory(HoodieStorage storage) { protected HoodieFileReader newParquetFileReader(StoragePath path) { return new HoodieRowDataParquetReader(storage, path); } + + @Override + protected HoodieFileReader newLanceFileReader(HoodieConfig hoodieConfig, StoragePath path) { + return new HoodieRowDataLanceReader(path, hoodieConfig); + } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataLanceReader.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataLanceReader.java new file mode 100644 index 0000000000000..8c7564af67303 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/HoodieRowDataLanceReader.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.hudi.table.format; + +import org.apache.hudi.client.model.HoodieFlinkRecord; +import org.apache.hudi.common.bloom.BloomFilter; +import org.apache.hudi.common.bloom.HoodieDynamicBoundedBloomFilter; +import org.apache.hudi.common.bloom.SimpleBloomFilter; +import org.apache.hudi.common.config.HoodieConfig; +import org.apache.hudi.common.config.HoodieStorageConfig; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaUtils; +import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.CloseableMappingIterator; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.io.memory.HoodieArrowAllocator; +import org.apache.hudi.io.storage.HoodieFileReader; +import org.apache.hudi.io.storage.row.HoodieFlinkLanceArrowUtils; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.util.HoodieSchemaConverter; +import org.apache.hudi.util.RowDataQueryContexts; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.logical.RowType; +import org.lance.file.LanceFileReader; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.apache.hudi.avro.HoodieBloomFilterWriteSupport.HOODIE_AVRO_BLOOM_FILTER_METADATA_KEY; +import static org.apache.hudi.avro.HoodieBloomFilterWriteSupport.HOODIE_BLOOM_FILTER_TYPE_CODE; +import static org.apache.hudi.avro.HoodieBloomFilterWriteSupport.HOODIE_MAX_RECORD_KEY_FOOTER; +import static org.apache.hudi.avro.HoodieBloomFilterWriteSupport.HOODIE_MIN_RECORD_KEY_FOOTER; + +/** + * Lance reader for Flink RowData base files. + */ +public class HoodieRowDataLanceReader implements HoodieFileReader { + + private static final int DEFAULT_BATCH_SIZE = 512; + + private final StoragePath path; + private final long dataAllocatorSize; + private final BufferAllocator metadataAllocator; + private final LanceFileReader metadataReader; + private final Schema arrowSchema; + private boolean closed; + + public HoodieRowDataLanceReader(StoragePath path, HoodieConfig hoodieConfig) { + this.path = path; + this.dataAllocatorSize = hoodieConfig.getLongOrDefault(HoodieStorageConfig.LANCE_READ_ALLOCATOR_SIZE_BYTES); + this.metadataAllocator = HoodieArrowAllocator.newChildAllocator( + getClass().getSimpleName() + "-metadata-" + path.getName(), + hoodieConfig.getLongOrDefault(HoodieStorageConfig.LANCE_READ_METADATA_ALLOCATOR_SIZE_BYTES)); + try { + this.metadataReader = LanceFileReader.open(path.toString(), metadataAllocator); + this.arrowSchema = metadataReader.schema(); + } catch (Exception e) { + close(); + throw new HoodieException("Failed to create Lance reader for: " + path, e); + } + } + + @Override + public String[] readMinMaxRecordKeys() { + Map metadata = arrowSchema.getCustomMetadata(); + if (metadata != null) { + String minKey = metadata.get(HOODIE_MIN_RECORD_KEY_FOOTER); + String maxKey = metadata.get(HOODIE_MAX_RECORD_KEY_FOOTER); + if (minKey != null && maxKey != null) { + return new String[] {minKey, maxKey}; + } + } + throw new HoodieException("Could not read min/max record key out of Lance file: " + path); + } + + @Override + public BloomFilter readBloomFilter() { + Map metadata = arrowSchema.getCustomMetadata(); + if (metadata == null || !metadata.containsKey(HOODIE_AVRO_BLOOM_FILTER_METADATA_KEY)) { + return null; + } + String bloomSer = metadata.get(HOODIE_AVRO_BLOOM_FILTER_METADATA_KEY); + String filterType = metadata.get(HOODIE_BLOOM_FILTER_TYPE_CODE); + if (filterType != null && filterType.contains(HoodieDynamicBoundedBloomFilter.TYPE_CODE_PREFIX)) { + return new HoodieDynamicBoundedBloomFilter(bloomSer); + } + return new SimpleBloomFilter(bloomSer); + } + + @Override + public Set> filterRowKeys(Set candidateRowKeys) { + throw new HoodieException("Filtering row keys from Lance files is not supported for Flink append-only tables without primary keys: " + path); + } + + @Override + public ClosableIterator> getRecordIterator(HoodieSchema readerSchema, HoodieSchema requestedSchema) throws IOException { + ClosableIterator rowDataItr = getRowDataIterator(RowDataQueryContexts.fromSchema(requestedSchema).getRowType(), requestedSchema); + return new CloseableMappingIterator<>(rowDataItr, HoodieFlinkRecord::new); + } + + @Override + public ClosableIterator getRecordKeyIterator() throws IOException { + HoodieSchema schema = HoodieSchemaUtils.getRecordKeySchema(); + ClosableIterator rowDataItr = getRowDataIterator(RowDataQueryContexts.fromSchema(schema).getRowType(), schema); + return new CloseableMappingIterator<>(rowDataItr, rowData -> rowData.getString(0).toString()); + } + + public ClosableIterator getRowDataIterator(DataType dataType, HoodieSchema requestedSchema) { + RowType rowType = (RowType) dataType.getLogicalType(); + List columnNames = new ArrayList<>(rowType.getFieldCount()); + for (RowType.RowField field : rowType.getFields()) { + columnNames.add(field.getName()); + } + BufferAllocator allocator = HoodieArrowAllocator.newChildAllocator( + getClass().getSimpleName() + "-data-" + path.getName(), dataAllocatorSize); + LanceFileReader lanceReader = null; + ArrowReader arrowReader = null; + try { + lanceReader = LanceFileReader.open(path.toString(), allocator); + arrowReader = lanceReader.readAll(columnNames, null, DEFAULT_BATCH_SIZE); + return new LanceRowDataIterator(allocator, lanceReader, arrowReader, rowType, this); + } catch (Exception e) { + if (arrowReader != null) { + try { + arrowReader.close(); + } catch (Exception closeException) { + e.addSuppressed(closeException); + } + } + if (lanceReader != null) { + try { + lanceReader.close(); + } catch (Exception closeException) { + e.addSuppressed(closeException); + } + } + allocator.close(); + throw new HoodieException("Failed to create Lance row iterator for: " + path, e); + } + } + + @Override + public HoodieSchema getSchema() { + RowType rowType = HoodieFlinkLanceArrowUtils.toRowType(arrowSchema); + return HoodieSchemaConverter.convertToSchema(rowType); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + if (metadataReader != null) { + try { + metadataReader.close(); + } catch (Exception e) { + // ignore close failure; readers surface data-path exceptions earlier + } + } + if (metadataAllocator != null) { + metadataAllocator.close(); + } + } + + @Override + public long getTotalRecords() { + try { + return metadataReader.numRows(); + } catch (Exception e) { + throw new HoodieException("Failed to read row count from Lance file: " + path, e); + } + } + + private static class LanceRowDataIterator implements ClosableIterator { + private final BufferAllocator allocator; + private final LanceFileReader lanceReader; + private final ArrowReader arrowReader; + private final RowType rowType; + private final HoodieRowDataLanceReader reader; + private VectorSchemaRoot batch; + private List orderedVectors; + private int rowId; + private boolean hasNext; + private boolean closed; + + private LanceRowDataIterator( + BufferAllocator allocator, + LanceFileReader lanceReader, + ArrowReader arrowReader, + RowType rowType, + HoodieRowDataLanceReader reader) { + this.allocator = allocator; + this.lanceReader = lanceReader; + this.arrowReader = arrowReader; + this.rowType = rowType; + this.reader = reader; + loadNextBatch(); + } + + @Override + public boolean hasNext() { + return hasNext; + } + + @Override + public RowData next() { + RowData rowData = HoodieFlinkLanceArrowUtils.toRowData(rowType, orderedVectors, rowId++); + if (rowId >= batch.getRowCount()) { + loadNextBatch(); + } + return rowData; + } + + private void loadNextBatch() { + try { + do { + hasNext = arrowReader.loadNextBatch(); + if (hasNext) { + batch = arrowReader.getVectorSchemaRoot(); + orderedVectors = orderVectors(rowType, batch.getFieldVectors()); + rowId = 0; + } + } while (hasNext && batch.getRowCount() == 0); + } catch (IOException e) { + throw new HoodieIOException("Failed to read Lance batch", e); + } + } + + private static List orderVectors(RowType rowType, List vectors) { + Map vectorsByName = new HashMap<>(); + for (FieldVector vector : vectors) { + vectorsByName.put(vector.getName(), vector); + } + List orderedVectors = new ArrayList<>(rowType.getFieldCount()); + for (RowType.RowField field : rowType.getFields()) { + FieldVector vector = vectorsByName.get(field.getName()); + if (vector == null) { + throw new HoodieException("Missing Lance column in projected batch: " + field.getName()); + } + orderedVectors.add(vector); + } + return orderedVectors; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try { + arrowReader.close(); + } catch (Exception e) { + throw new HoodieException("Failed to close Lance Arrow reader", e); + } finally { + try { + lanceReader.close(); + } catch (Exception e) { + throw new HoodieException("Failed to close Lance reader", e); + } finally { + try { + allocator.close(); + } finally { + reader.close(); + } + } + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcImageManager.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcImageManager.java index cad323b7fb815..5492d153ebe8d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcImageManager.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcImageManager.java @@ -44,6 +44,7 @@ import java.util.TreeMap; import java.util.function.Function; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.HOODIE_RECORD_KEY_COL_POS; /** @@ -104,13 +105,16 @@ private ExternalSpillableMap loadImageRecords( serializer.serialize(row, new BytesArrayOutputView(baos)); imageRecordsMap.put(recordKey, baos.toByteArray()); } + } catch (IOException | RuntimeException | Error e) { + closeSuppressing(imageRecordsMap, e); + throw e; } return imageRecordsMap; } public RowData getImageRecord( String recordKey, - ExternalSpillableMap imageCache, + Map imageCache, RowKind rowKind) { byte[] bytes = imageCache.get(recordKey); ValidationUtils.checkState(bytes != null, @@ -126,7 +130,7 @@ public RowData getImageRecord( public void updateImageRecord( String recordKey, - ExternalSpillableMap imageCache, + Map imageCache, RowData row) { ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); try { @@ -139,7 +143,7 @@ public void updateImageRecord( public RowData removeImageRecord( String recordKey, - ExternalSpillableMap imageCache) { + Map imageCache) { byte[] bytes = imageCache.remove(recordKey); if (bytes == null) { return null; @@ -153,8 +157,25 @@ public RowData removeImageRecord( @Override public void close() { - cache.values().forEach(ExternalSpillableMap::close); - cache.clear(); + RuntimeException failure = null; + try { + for (ExternalSpillableMap spillableMap : cache.values()) { + try { + spillableMap.close(); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + } finally { + cache.clear(); + } + if (failure != null) { + throw failure; + } } // ------------------------------------------------------------------------- diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcInputFormat.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcInputFormat.java index 27bb2b3a56e8d..cc4de580ae770 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcInputFormat.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcInputFormat.java @@ -48,6 +48,8 @@ import java.util.List; import java.util.function.Function; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; + /** * The base InputFormat class to read Hoodie data set as change logs. */ @@ -157,11 +159,16 @@ private ClosableIterator getRecordIterator( String logFilepath = new Path(tablePath, fileSplit.getCdcFiles().get(0)).toString(); MergeOnReadInputSplit split = CdcIterators.singleLogFile2Split(tablePath, logFilepath, maxCompactionMemoryInBytes); ClosableIterator> recordIterator = getSplitRecordIterator(split); - return new CdcIterators.DataLogFileIterator( - maxCompactionMemoryInBytes, imageManager, fileSplit, - HoodieSchema.parse(tableState.getTableSchema()), - tableState.getRequiredRowType(), tableState.getRequiredPositions(), - recordIterator, metaClient, imageManager.getWriteConfig()); + try { + return new CdcIterators.DataLogFileIterator( + maxCompactionMemoryInBytes, imageManager, fileSplit, + HoodieSchema.parse(tableState.getTableSchema()), + tableState.getRequiredRowType(), tableState.getRequiredPositions(), + recordIterator, metaClient, imageManager.getWriteConfig()); + } catch (IOException | RuntimeException | Error e) { + closeSuppressing(recordIterator, e); + throw e; + } case REPLACE_COMMIT: return new CdcIterators.ReplaceCommitIterator( tablePath, tableState.getRequiredRowType(), tableState.getRequiredPositions(), diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java index 5f47814942173..2a01db2da646e 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java @@ -54,7 +54,6 @@ import org.apache.hudi.storage.HoodieStorageUtils; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.format.FlinkReaderContextFactory; -import org.apache.hudi.table.format.FormatUtils; import org.apache.hudi.table.format.mor.MergeOnReadInputSplit; import org.apache.hudi.util.AvroToRowDataConverters; import org.apache.hudi.util.HoodieSchemaConverter; @@ -71,9 +70,11 @@ import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; import static org.apache.hudi.table.format.FormatUtils.buildAvroRecordBySchema; /** @@ -92,6 +93,11 @@ private CdcIterators() { /** * Iterates over an ordered sequence of {@link HoodieCDCFileSplit}s, delegating * per-split record reading to a user-supplied factory function. + * + *

    Not thread-safe by design: in the Source V2 read path this iterator is created, drained into a + * materialized minibatch, and closed entirely on the single split-fetcher thread (see + * {@code AbstractSplitReaderFunction}); the legacy {@link CdcInputFormat} path likewise reads and + * closes it on one thread. No method is ever invoked concurrently, so no synchronization is needed. */ public static class CdcFileSplitsIterator implements ClosableIterator { private CdcImageManager imageManager; @@ -132,11 +138,12 @@ public RowData next() { @Override public void close() { - if (recordIterator != null) { - recordIterator.close(); - } - if (imageManager != null) { - imageManager.close(); + try (CdcImageManager ignored = imageManager) { + if (recordIterator != null) { + recordIterator.close(); + } + } finally { + recordIterator = null; imageManager = null; } } @@ -239,7 +246,7 @@ public static class DataLogFileIterator implements ClosableIterator { private final String[] orderingFields; private final TypedProperties props; - private ExternalSpillableMap beforeImages; + private Map beforeImages; private RowData currentImage; private RowData sideImage; @@ -273,15 +280,15 @@ public DataLogFileIterator( metaClient.getTableConfig().getPartialUpdateMode()); this.logRecordIterator = logRecordIterator; this.deleteContext = new DeleteContext(props, tableSchema).withReaderSchema(tableSchema); - initImages(cdcFileSplit, writeConfig); + initImages(cdcFileSplit); } - private void initImages(HoodieCDCFileSplit fileSplit, HoodieWriteConfig writeConfig) throws IOException { + private void initImages(HoodieCDCFileSplit fileSplit) throws IOException { if (fileSplit.getBeforeFileSlice().isPresent() && !fileSplit.getBeforeFileSlice().get().isEmpty()) { this.beforeImages = imageManager.getOrLoadImages( maxCompactionMemoryInBytes, fileSplit.getBeforeFileSlice().get()); } else { - this.beforeImages = FormatUtils.spillableMap(writeConfig, maxCompactionMemoryInBytes, getClass().getSimpleName()); + this.beforeImages = Collections.emptyMap(); } } @@ -334,7 +341,6 @@ public RowData next() { @Override public void close() { logRecordIterator.close(); - imageManager.close(); } @SuppressWarnings("unchecked") @@ -528,7 +534,12 @@ public BeforeImageIterator( this.maxCompactionMemoryInBytes = maxCompactionMemoryInBytes; this.projection = RowDataProjection.instance(requiredRowType, requiredPositions); this.imageManager = imageManager; - initImages(fileSplit); + try { + initImages(fileSplit); + } catch (IOException | RuntimeException | Error e) { + closeSuppressing(this, e); + throw e; + } } protected void initImages(HoodieCDCFileSplit fileSplit) throws IOException { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cow/CopyOnWriteInputFormat.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cow/CopyOnWriteInputFormat.java index 5a2e94b833baf..9e59e1ed33447 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cow/CopyOnWriteInputFormat.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cow/CopyOnWriteInputFormat.java @@ -18,12 +18,19 @@ package org.apache.hudi.table.format.cow; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.hadoop.fs.HadoopFSUtils; import org.apache.hudi.source.ExpressionPredicates.Predicate; +import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.format.FilePathUtils; +import org.apache.hudi.table.format.HoodieRowDataLanceReader; import org.apache.hudi.table.format.InternalSchemaManager; import org.apache.hudi.table.format.RecordIterators; +import org.apache.hudi.util.HoodieSchemaConverter; +import org.apache.hudi.util.StreamerUtil; import lombok.extern.slf4j.Slf4j; import org.apache.flink.api.common.io.FileInputFormat; @@ -33,6 +40,7 @@ import org.apache.flink.core.fs.FileInputSplit; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.utils.SerializableConfiguration; +import org.apache.flink.table.api.DataTypes; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.hadoop.conf.Configuration; @@ -116,32 +124,50 @@ public CopyOnWriteInputFormat( @Override public void open(FileInputSplit fileSplit) throws IOException { - LinkedHashMap partObjects = FilePathUtils.generatePartitionSpecs( - fileSplit.getPath().getPath(), - Arrays.asList(fullFieldNames), - Arrays.asList(fullFieldTypes), - this.partDefaultName, - this.partPathField, - this.hiveStylePartitioning - ); - - this.itr = RecordIterators.getParquetRecordIterator( - internalSchemaManager, - utcTimestamp, - true, - conf.conf(), - fullFieldNames, - fullFieldTypes, - partObjects, - selectedFields, - 2048, - fileSplit.getPath(), - fileSplit.getStart(), - fileSplit.getLength(), - predicates); + if (fileSplit.getPath().getName().endsWith(HoodieFileFormat.LANCE.getFileExtension())) { + this.itr = getLanceRecordIterator(fileSplit.getPath()); + } else { + LinkedHashMap partObjects = FilePathUtils.generatePartitionSpecs( + fileSplit.getPath().getPath(), + Arrays.asList(fullFieldNames), + Arrays.asList(fullFieldTypes), + this.partDefaultName, + this.partPathField, + this.hiveStylePartitioning + ); + this.itr = RecordIterators.getParquetRecordIterator( + internalSchemaManager, + utcTimestamp, + true, + conf.conf(), + fullFieldNames, + fullFieldTypes, + partObjects, + selectedFields, + 2048, + fileSplit.getPath(), + fileSplit.getStart(), + fileSplit.getLength(), + predicates); + } this.currentReadCount = 0L; } + private ClosableIterator getLanceRecordIterator(Path path) { + DataType selectedDataType = DataTypes.ROW(Arrays.stream(selectedFields) + .mapToObj(i -> DataTypes.FIELD(fullFieldNames[i], fullFieldTypes[i])) + .toArray(DataTypes.Field[]::new)) + .bridgedTo(RowData.class); + HoodieSchema requestedSchema = HoodieSchemaConverter.convertToSchema(selectedDataType.getLogicalType()); + HoodieRowDataLanceReader reader = new HoodieRowDataLanceReader(new StoragePath(path.toString()), StreamerUtil.getLanceReadConfig(conf.conf())); + try { + return reader.getRowDataIterator(selectedDataType, requestedSchema); + } catch (RuntimeException e) { + reader.close(); + throw new HoodieException("Failed to get iterator from lance reader", e); + } + } + @Override public FileInputSplit[] createInputSplits(int minNumSplits) throws IOException { if (minNumSplits < 1) { @@ -379,6 +405,10 @@ private int getBlockIndexForPosition(BlockLocation[] blocks, long offset, long h } private boolean testForUnsplittable(FileStatus pathFile) { + if (pathFile.getPath().getName().endsWith(HoodieFileFormat.LANCE.getFileExtension())) { + unsplittable = true; + return true; + } if (getInflaterInputStreamFactory(pathFile.getPath()) != null) { unsplittable = true; return true; diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java index 443cba0bcf508..7b02f9da826b6 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java @@ -135,14 +135,17 @@ private void checkCacheReload() throws IOException { } HoodieActiveTimeline latestCommit = metaClient.reloadActiveTimeline(); - Option latestCommitInstant = latestCommit.getCommitsTimeline().lastInstant(); - if (latestCommit.empty()) { + Option latestCommitInstant = + latestCommit.getCommitsTimeline().filterCompletedInstants().lastInstant(); + if (!latestCommitInstant.isPresent()) { + scheduleNextLoad(); log.info("No commit instant found currently."); return; } // Determine whether to reload data by comparing instant if (latestCommitInstant.get().equals(currentCommit)) { - log.info("Ignore loading data because the commit instant " + currentCommit + " has not changed."); + scheduleNextLoad(); + log.info("Ignore loading data because the commit instant {} has not changed.", currentCommit); return; } @@ -152,17 +155,18 @@ private void checkCacheReload() throws IOException { try { long count = 0; GenericRowData reuse = new GenericRowData(rowType.getFieldCount()); - partitionReader.open(); - RowData row; - while ((row = partitionReader.read(reuse)) != null) { - count++; - RowData rowData = serializer.copy(row); - RowData key = extractLookupKey(rowData); - cache.addRow(key, rowData); + try (HoodieLookupTableReader reader = partitionReader) { + reader.open(); + RowData row; + while ((row = reader.read(reuse)) != null) { + count++; + RowData rowData = serializer.copy(row); + RowData key = extractLookupKey(rowData); + cache.addRow(key, rowData); + } } - partitionReader.close(); currentCommit = latestCommitInstant.get(); - nextLoadTime = System.currentTimeMillis() + reloadInterval.toMillis(); + scheduleNextLoad(); log.info("Loaded {} row(s) into lookup join cache", count); return; } catch (Exception e) { @@ -185,6 +189,10 @@ private void checkCacheReload() throws IOException { } } + private void scheduleNextLoad() { + nextLoadTime = System.currentTimeMillis() + reloadInterval.toMillis(); + } + private RowData extractLookupKey(RowData row) { GenericRowData key = new GenericRowData(lookupFieldGetters.length); for (int i = 0; i < lookupFieldGetters.length; i++) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupTableReader.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupTableReader.java index 31fdf6d85d2ae..c2c3fbf0472fd 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupTableReader.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupTableReader.java @@ -28,16 +28,19 @@ import javax.annotation.Nullable; +import java.io.Closeable; import java.io.IOException; import java.io.Serializable; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.util.CloseableUtils.closeSuppressing; + /** * Hudi look up table reader. */ -public class HoodieLookupTableReader implements Serializable { +public class HoodieLookupTableReader implements Serializable, Closeable { private static final long serialVersionUID = 1L; private final SerializableSupplier> inputFormatSupplier; @@ -53,11 +56,17 @@ public HoodieLookupTableReader(SerializableSupplier> inp } public void open() throws IOException { + close(); this.inputFormat = inputFormatSupplier.get(); - inputFormat.configure(conf); - this.inputSplits = Arrays.stream(inputFormat.createInputSplits(1)).collect(Collectors.toList()); - ((RichInputFormat) inputFormat).openInputFormat(); - inputFormat.open(inputSplits.remove(0)); + try { + inputFormat.configure(conf); + this.inputSplits = Arrays.stream(inputFormat.createInputSplits(1)).collect(Collectors.toList()); + ((RichInputFormat) inputFormat).openInputFormat(); + inputFormat.open(inputSplits.remove(0)); + } catch (IOException | RuntimeException e) { + closeSuppressing(this, e); + throw e; + } } @Nullable @@ -77,12 +86,21 @@ public RowData read(RowData reuse) throws IOException { return null; } + @Override public void close() throws IOException { - if (this.inputFormat != null) { - inputFormat.close(); + InputFormat format = this.inputFormat; + this.inputFormat = null; + this.inputSplits = null; + if (format == null) { + return; } - if (inputFormat instanceof RichInputFormat) { - ((RichInputFormat) inputFormat).closeInputFormat(); + + if (format instanceof RichInputFormat) { + try (Closeable ignored = ((RichInputFormat) format)::closeInputFormat) { + format.close(); + } + } else { + format.close(); } } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClientIds.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClientIds.java index 0a18ac029e92f..c2b1c1d3a14fd 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClientIds.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClientIds.java @@ -138,7 +138,7 @@ public static boolean isHeartbeatExpired(FileSystem fs, Path path, long timeoutT } } catch (IOException e) { // if any exception happens, just return false. - log.error("Check heartbeat file existence error: " + path); + log.error("Check heartbeat file existence error: {}", path); } return false; } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClusteringUtil.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClusteringUtil.java index 41dee1cd1b07c..e0d3a9d50018d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClusteringUtil.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ClusteringUtil.java @@ -87,7 +87,7 @@ public static void rollbackClustering(HoodieFlinkTable table, HoodieFlinkWrit .filter(instant -> instant.getState() == HoodieInstant.State.INFLIGHT) .collect(Collectors.toList()); inflightInstants.forEach(inflightInstant -> { - log.info("Rollback the inflight clustering instant: " + inflightInstant + " for failover"); + log.info("Rollback the inflight clustering instant: {} for failover", inflightInstant); table.rollbackInflightClustering(inflightInstant, commitToRollback -> writeClient.getTableServiceClient().getPendingRollbackInfo(table.getMetaClient(), commitToRollback, false), writeClient.getTransactionManager()); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/CompactionUtil.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/CompactionUtil.java index 8e5456b8f1a69..39b54fefb2e21 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/CompactionUtil.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/CompactionUtil.java @@ -245,7 +245,7 @@ public static void rollbackCompaction(HoodieFlinkTable table, HoodieFlinkWrit .filter(instant -> instant.getState() == HoodieInstant.State.INFLIGHT); inflightCompactionTimeline.getInstants().forEach(inflightInstant -> { - log.info("Rollback the inflight compaction instant: " + inflightInstant + " for failover"); + log.info("Rollback the inflight compaction instant: {} for failover", inflightInstant); table.rollbackInflightCompaction(inflightInstant, commitToRollback -> writeClient.getTableServiceClient().getPendingRollbackInfo(table.getMetaClient(), commitToRollback, false), writeClient.getTransactionManager()); table.getMetaClient().reloadActiveTimeline(); @@ -269,7 +269,7 @@ public static void rollbackEarliestCompaction(HoodieFlinkTable table, Configu String currentTime = HoodieInstantTimeGenerator.getCurrentInstantTimeStr(); int timeout = conf.get(FlinkOptions.COMPACTION_TIMEOUT_SECONDS); if (StreamerUtil.instantTimeDiffSeconds(currentTime, instant.requestedTime()) >= timeout) { - log.info("Rollback the inflight compaction instant: " + instant + " for timeout(" + timeout + "s)"); + log.info("Rollback the inflight compaction instant: {} for timeout({}s)", instant, timeout); try (TransactionManager transactionManager = new TransactionManager(table.getConfig(), table.getStorage())) { table.rollbackInflightCompaction(instant, transactionManager); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkWriteClients.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkWriteClients.java index a389d721908bd..0293aad632dfb 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkWriteClients.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkWriteClients.java @@ -50,6 +50,7 @@ import java.io.IOException; import java.util.Locale; +import static org.apache.hudi.configuration.OptionsResolver.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_DEFAULT; import static org.apache.hudi.util.StreamerUtil.flinkConf2TypedProperties; import static org.apache.hudi.util.StreamerUtil.getLockConfig; import static org.apache.hudi.util.StreamerUtil.getPayloadConfig; @@ -235,7 +236,8 @@ public static HoodieWriteConfig getHoodieClientConfig( .withEngineType(EngineType.FLINK) // this affects the default value inference .enable(conf.get(FlinkOptions.METADATA_ENABLED)) .withRecordIndexFileGroupCount( - Integer.parseInt(conf.getString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), "8")), + Integer.parseInt(conf.getString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), + GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_DEFAULT)), Integer.parseInt(conf.getString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(), HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.defaultValue() + ""))) .withMaxNumDeltaCommitsBeforeCompaction(conf.get(FlinkOptions.METADATA_COMPACTION_DELTA_COMMITS)) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java index f88d1a76a3251..2f9c46b590b40 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java @@ -25,7 +25,9 @@ import org.apache.hudi.client.model.PartialUpdateFlinkRecordMerger; import org.apache.hudi.client.transaction.lock.FileSystemBasedLockProvider; import org.apache.hudi.common.config.DFSPropertiesConfiguration; +import org.apache.hudi.common.config.HoodieConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.config.HoodieTimeGeneratorConfig; import org.apache.hudi.common.config.RecordMergeMode; import org.apache.hudi.common.config.TypedProperties; @@ -135,7 +137,13 @@ public static TypedProperties appendKafkaProps(FlinkStreamerConfig config) { public static TypedProperties getProps(FlinkStreamerConfig cfg) { if (cfg.propsFilePath.isEmpty()) { - return new TypedProperties(); + TypedProperties properties = new TypedProperties(); + cfg.configs.forEach(x -> { + String[] kv = x.split("="); + ValidationUtils.checkArgument(kv.length == 2); + properties.setProperty(kv[0], kv[1]); + }); + return properties; } return readConfig( HadoopConfigurations.getHadoopConf(cfg), @@ -275,6 +283,22 @@ public static TypedProperties flinkConf2TypedProperties(Configuration conf) { return properties; } + /** + * Builds a Lance read config from storage options carried in the Hadoop configuration. + */ + public static HoodieConfig getLanceReadConfig(org.apache.hadoop.conf.Configuration conf) { + HoodieConfig hoodieConfig = new HoodieConfig(); + String dataAllocatorSize = conf.get(HoodieStorageConfig.LANCE_READ_ALLOCATOR_SIZE_BYTES.key()); + if (dataAllocatorSize != null) { + hoodieConfig.setValue(HoodieStorageConfig.LANCE_READ_ALLOCATOR_SIZE_BYTES, dataAllocatorSize); + } + String metadataAllocatorSize = conf.get(HoodieStorageConfig.LANCE_READ_METADATA_ALLOCATOR_SIZE_BYTES.key()); + if (metadataAllocatorSize != null) { + hoodieConfig.setValue(HoodieStorageConfig.LANCE_READ_METADATA_ALLOCATOR_SIZE_BYTES, metadataAllocatorSize); + } + return hoodieConfig; + } + public static void initTableFromClientIfNecessary(Configuration conf) { // Since Flink 2.0, the adaptive execution for batch job will generate job graph incrementally // for multiple stages (FLIP-469). And the write coordinator is initialized along with write @@ -318,6 +342,7 @@ public static HoodieTableMetaClient initTableIfNotExists( .setTableName(conf.get(FlinkOptions.TABLE_NAME)) .setTableVersion(conf.get(FlinkOptions.WRITE_TABLE_VERSION)) .setTableFormat(conf.get(FlinkOptions.WRITE_TABLE_FORMAT)) + .setBaseFileFormat(conf.getString(HoodieTableConfig.BASE_FILE_FORMAT.key(), null)) .setRecordMergeMode(getMergeMode(conf)) .setRecordMergeStrategyId(getMergeStrategyId(conf)) .setPayloadClassName(getPayloadClass(conf)) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java index e0f715c1c85a8..94c2104d44d14 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java @@ -68,7 +68,7 @@ public static void createProperties( */ public static FileSystemViewStorageConfig loadFromProperties(String basePath, Configuration conf) { Path propertyPath = getPropertiesFilePath(basePath, conf.get(FlinkOptions.WRITE_CLIENT_ID)); - log.info("Loading filesystem view storage properties from " + propertyPath); + log.info("Loading filesystem view storage properties from {}", propertyPath); FileSystem fs = HadoopFSUtils.getFs(basePath, HadoopConfigurations.getHadoopConf(conf)); Properties props = new Properties(); try { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsInference.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsInference.java index 5d13ae0e67ea8..0bc69ae910b87 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsInference.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsInference.java @@ -22,7 +22,12 @@ import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.util.ClientIds; +import org.apache.flink.FlinkVersion; +import org.apache.flink.api.common.RuntimeExecutionMode; import org.apache.flink.configuration.Configuration; +import org.apache.flink.configuration.ExecutionOptions; +import org.apache.flink.configuration.JobManagerOptions; +import org.apache.flink.configuration.SchedulerExecutionMode; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -30,6 +35,9 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Test cases for {@link OptionsInference}. @@ -38,6 +46,83 @@ public class TestOptionsInference { @TempDir File tempFile; + @Test + void testSetupSourceAndSinkTasks() { + Configuration conf = new Configuration(); + + OptionsInference.setupSourceTasks(conf, 3); + OptionsInference.setupSinkTasks(conf, 4); + + assertEquals(3, conf.get(FlinkOptions.READ_TASKS)); + assertEquals(4, conf.get(FlinkOptions.WRITE_TASKS)); + assertEquals(4, conf.get(FlinkOptions.BUCKET_ASSIGN_TASKS)); + assertEquals(4, conf.get(FlinkOptions.COMPACTION_TASKS)); + assertEquals(4, conf.get(FlinkOptions.CLUSTERING_TASKS)); + assertEquals(4, conf.get(FlinkOptions.INDEX_WRITE_TASKS)); + + conf.set(FlinkOptions.READ_TASKS, 7); + conf.set(FlinkOptions.WRITE_TASKS, 8); + conf.set(FlinkOptions.BUCKET_ASSIGN_TASKS, 9); + conf.set(FlinkOptions.COMPACTION_TASKS, 10); + conf.set(FlinkOptions.CLUSTERING_TASKS, 11); + conf.set(FlinkOptions.INDEX_WRITE_TASKS, 12); + + OptionsInference.setupSourceTasks(conf, 20); + OptionsInference.setupSinkTasks(conf, 20); + + assertEquals(7, conf.get(FlinkOptions.READ_TASKS)); + assertEquals(8, conf.get(FlinkOptions.WRITE_TASKS)); + assertEquals(9, conf.get(FlinkOptions.BUCKET_ASSIGN_TASKS)); + assertEquals(10, conf.get(FlinkOptions.COMPACTION_TASKS)); + assertEquals(11, conf.get(FlinkOptions.CLUSTERING_TASKS)); + assertEquals(12, conf.get(FlinkOptions.INDEX_WRITE_TASKS)); + } + + @Test + void testSetupRuntimeConfigurations() { + Configuration conf = new Configuration(); + conf.set(JobManagerOptions.SCHEDULER, JobManagerOptions.SchedulerType.AdaptiveBatch); + Configuration runtimeConf = new Configuration(); + runtimeConf.set(ExecutionOptions.RUNTIME_MODE, RuntimeExecutionMode.BATCH); + + OptionsInference.setupRuntimeConfigs(conf, runtimeConf); + + if (FlinkVersion.current().toString().compareTo("2.0") >= 0) { + assertTrue(conf.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + } else { + assertFalse(conf.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + } + + conf.set(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION, false); + runtimeConf.set(ExecutionOptions.RUNTIME_MODE, RuntimeExecutionMode.STREAMING); + OptionsInference.setupRuntimeConfigs(conf, runtimeConf); + assertFalse(conf.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + } + + @Test + void testSchedulerTypeResolutionThroughRuntimeSetup() { + Configuration runtimeConf = new Configuration(); + runtimeConf.set(ExecutionOptions.RUNTIME_MODE, RuntimeExecutionMode.BATCH); + boolean isFlink2 = FlinkVersion.current().toString().compareTo("2.0") >= 0; + + Configuration reactive = new Configuration(); + reactive.set(JobManagerOptions.SCHEDULER_MODE, SchedulerExecutionMode.REACTIVE); + OptionsInference.setupRuntimeConfigs(reactive, runtimeConf); + assertEquals(isFlink2, + reactive.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + + Configuration adaptive = new Configuration(); + adaptive.set(JobManagerOptions.SCHEDULER, JobManagerOptions.SchedulerType.Adaptive); + OptionsInference.setupRuntimeConfigs(adaptive, runtimeConf); + assertEquals(isFlink2, + adaptive.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + + Configuration defaultScheduler = new Configuration(); + defaultScheduler.set(JobManagerOptions.SCHEDULER, JobManagerOptions.SchedulerType.Default); + OptionsInference.setupRuntimeConfigs(defaultScheduler, runtimeConf); + assertFalse(defaultScheduler.get(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION)); + } + @Test void testSetupClientId() throws Exception { Configuration conf = getConf(); @@ -75,4 +160,17 @@ private Configuration getConf() { conf.set(FlinkOptions.PATH, tempFile.getAbsolutePath()); return conf; } + + @Test + void testClientIdAndIndexSetupAreNoOpsWhenNotApplicable() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.PATH, tempFile.getAbsolutePath()); + conf.set(FlinkOptions.INDEX_TYPE, "BLOOM"); + + OptionsInference.setupClientId(conf); + OptionsInference.setupIndexConfigs(conf); + + assertFalse(conf.contains(FlinkOptions.WRITE_CLIENT_ID)); + assertFalse(conf.contains(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS)); + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java index 3d9a6bba96605..ac679dba8c184 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestOptionsResolver.java @@ -18,23 +18,39 @@ package org.apache.hudi.configuration; +import org.apache.hudi.client.transaction.BucketIndexConcurrentFileWritesConflictResolutionStrategy; +import org.apache.hudi.client.transaction.SimpleConcurrentFileWritesConflictResolutionStrategy; +import org.apache.hudi.common.config.HoodieCommonConfig; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.model.DefaultHoodieRecordPayload; import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy; +import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.WriteConcurrencyMode; import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.table.cdc.HoodieCDCSupplementalLoggingMode; +import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; import org.apache.hudi.config.HoodieCleanConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.keygen.constant.KeyGeneratorOptions; +import org.apache.hudi.sink.buffer.BufferMemoryType; +import org.apache.hudi.utils.TestConfigurations; +import org.apache.flink.api.common.functions.Partitioner; import org.apache.flink.configuration.Configuration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.io.File; +import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -43,7 +59,7 @@ public class TestOptionsResolver { @TempDir File tempFile; - + @Test void testGetIndexType() { Configuration conf = getConf(); @@ -65,7 +81,7 @@ void testGetRecordKeys() { assertArrayEquals(new String[]{}, OptionsResolver.getRecordKeys(conf)); conf.set(FlinkOptions.RECORD_KEY_FIELD, "uuid, name"); - assertArrayEquals(new String[]{"uuid", " name"}, OptionsResolver.getRecordKeys(conf)); + assertArrayEquals(new String[]{"uuid", "name"}, OptionsResolver.getRecordKeys(conf)); } @Test @@ -77,7 +93,7 @@ void testGetBucketIndexKeys() { assertArrayEquals(new String[]{}, OptionsResolver.getBucketIndexKeys(conf)); conf.set(FlinkOptions.INDEX_KEY_FIELD, "uuid, name"); - assertArrayEquals(new String[]{"uuid", " name"}, OptionsResolver.getBucketIndexKeys(conf)); + assertArrayEquals(new String[]{"uuid", "name"}, OptionsResolver.getBucketIndexKeys(conf)); } @Test @@ -123,4 +139,248 @@ private Configuration getConf() { conf.set(FlinkOptions.PATH, tempFile.getAbsolutePath()); return conf; } + + @Test + void testAreTableServicesEnabled() { + Configuration conf = new Configuration(); + // default value should be true + assertTrue(OptionsResolver.areTableServicesEnabled(conf)); + + // explicitly set to true + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, true); + assertTrue(OptionsResolver.areTableServicesEnabled(conf)); + + // explicitly set to false + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + assertFalse(OptionsResolver.areTableServicesEnabled(conf)); + } + + @Test + void testTableServicesGateCompactionAndCleaning() { + Configuration conf = getConf(); + conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); + conf.setString(HoodieCleanConfig.FAILED_WRITES_CLEANER_POLICY.key(), HoodieFailedWritesCleaningPolicy.LAZY.name()); + + assertTrue(OptionsResolver.needsAsyncCompaction(conf)); + assertTrue(OptionsResolver.needsScheduleCompaction(conf)); + assertTrue(OptionsResolver.needsAsyncCleaning(conf)); + assertTrue(OptionsResolver.isLazyFailedWritesCleanPolicy(conf)); + assertTrue(OptionsResolver.isLazyFailedWritesCleaning(conf)); + + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + + assertFalse(OptionsResolver.needsAsyncCompaction(conf)); + assertFalse(OptionsResolver.needsScheduleCompaction(conf)); + assertFalse(OptionsResolver.needsAsyncCleaning(conf)); + assertTrue(OptionsResolver.isLazyFailedWritesCleanPolicy(conf)); + assertFalse(OptionsResolver.isLazyFailedWritesCleaning(conf)); + } + + @Test + void testTableServicesGateMetadataCompaction() { + Configuration conf = getConf(); + conf.set(FlinkOptions.METADATA_ENABLED, true); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.RECORD_LEVEL_INDEX.name()); + + assertTrue(OptionsResolver.needsAsyncMetadataCompaction(conf)); + assertTrue(OptionsResolver.needsScheduleMdtCompaction(conf)); + + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + + assertFalse(OptionsResolver.needsAsyncMetadataCompaction(conf)); + assertFalse(OptionsResolver.needsScheduleMdtCompaction(conf)); + } + + @Test + void testTableServicesGateClustering() { + Configuration conf = getConf(); + conf.set(FlinkOptions.OPERATION, WriteOperationType.INSERT.value()); + conf.set(FlinkOptions.CLUSTERING_ASYNC_ENABLED, true); + conf.set(FlinkOptions.CLUSTERING_SCHEDULE_ENABLED, true); + + assertTrue(OptionsResolver.needsAsyncClustering(conf)); + assertTrue(OptionsResolver.needsScheduleClustering(conf)); + + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + + assertFalse(OptionsResolver.needsAsyncClustering(conf)); + assertFalse(OptionsResolver.needsScheduleClustering(conf)); + } + + @Test + void testEstimateFileGroupCountForPartitionedRLI() { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.METADATA_ENABLED, true); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.RECORD_LEVEL_INDEX.name()); + conf.setString(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key(), "true"); + + // testing default value + assertEquals(1, OptionsResolver.estimateFileGroupCountForRLI(conf)); + + // testing user configured value + conf.setString(HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), "3"); + conf.setString(HoodieMetadataConfig.RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(), "3"); + assertEquals(3, OptionsResolver.estimateFileGroupCountForRLI(conf)); + } + + @Test + void testEstimateFileGroupCountForGlobalRLI() { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.METADATA_ENABLED, true); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name()); + conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key(), "true"); + + // testing default value + assertEquals(8, OptionsResolver.estimateFileGroupCountForRLI(conf)); + + // testing user configured value + conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), "11"); + conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(), "11"); + assertEquals(11, OptionsResolver.estimateFileGroupCountForRLI(conf)); + } + + @Test + void testIncrementalJobGraphPredicate() { + Configuration conf = new Configuration(); + assertFalse(OptionsResolver.isIncrementalJobGraph(conf)); + conf.set(FlinkOptions.WRITE_INCREMENTAL_JOB_GRAPH_GENERATION, true); + assertTrue(OptionsResolver.isIncrementalJobGraph(conf)); + } + + @Test + void testTableTypePredicates() { + Configuration conf = new Configuration(); + assertTrue(OptionsResolver.isCowTable(conf)); + assertFalse(OptionsResolver.isMorTable(conf)); + assertFalse(OptionsResolver.isMorTable(Collections.emptyMap())); + conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name().toLowerCase()); + assertTrue(OptionsResolver.isMorTable(conf)); + assertTrue(OptionsResolver.isMorTable( + Collections.singletonMap(FlinkOptions.TABLE_TYPE.key(), HoodieTableType.MERGE_ON_READ.name()))); + } + + @Test + void testOperationTypePredicates() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.OPERATION, WriteOperationType.INSERT.value()); + assertTrue(OptionsResolver.isInsertOperation(conf)); + conf.set(FlinkOptions.OPERATION, WriteOperationType.UPSERT.value()); + assertTrue(OptionsResolver.isUpsertOperation(conf)); + conf.set(FlinkOptions.OPERATION, WriteOperationType.BULK_INSERT.value()); + assertTrue(OptionsResolver.isBulkInsertOperation(conf)); + } + + @Test + void testPayloadAndCompactionPredicates() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.PAYLOAD_CLASS_NAME, DefaultHoodieRecordPayload.class.getName()); + assertTrue(OptionsResolver.isDefaultHoodieRecordPayloadClazz(conf)); + conf.set(FlinkOptions.COMPACTION_TRIGGER_STRATEGY, FlinkOptions.TIME_ELAPSED.toUpperCase()); + assertTrue(OptionsResolver.isDeltaTimeCompaction(conf)); + conf.set(FlinkOptions.COMPACTION_TRIGGER_STRATEGY, FlinkOptions.NUM_COMMITS); + assertFalse(OptionsResolver.isDeltaTimeCompaction(conf)); + } + + @Test + void testReadCommitsLimit() { + Configuration conf = new Configuration(); + assertEquals(-1, OptionsResolver.getReadCommitsLimit(conf)); + conf.set(FlinkOptions.READ_COMMITS_LIMIT, 5); + assertEquals(5, OptionsResolver.getReadCommitsLimit(conf)); + } + + @Test + void testCdcOptions() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.SUPPLEMENTAL_LOGGING_MODE, + HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER.name().toLowerCase()); + assertEquals(HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER, + OptionsResolver.getCDCSupplementalLoggingMode(conf)); + + conf.set(FlinkOptions.READ_CDC_FROM_CHANGELOG, false); + assertFalse(OptionsResolver.readCDCFromChangelog(conf)); + } + + @Test + void testIndexKeyFields() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.RECORD_KEY_FIELD, "id,tenant"); + assertEquals("id", OptionsResolver.getIndexKeyFields(conf).get(0)); + assertEquals("tenant", OptionsResolver.getIndexKeyFields(conf).get(1)); + } + + @Test + void testSchemaAndTimestampOptions() { + Configuration conf = new Configuration(); + conf.setString(HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key(), "true"); + assertTrue(OptionsResolver.isSchemaEvolutionEnabled(conf)); + conf.setString(KeyGeneratorOptions.KEYGENERATOR_CONSISTENT_LOGICAL_TIMESTAMP_ENABLED.key(), "true"); + assertTrue(OptionsResolver.isConsistentLogicalTimestampEnabled(conf)); + conf.setString(HoodieCommonConfig.INCREMENTAL_READ_HANDLE_HOLLOW_COMMIT.key(), + HollowCommitHandling.USE_TRANSITION_TIME.name()); + assertTrue(OptionsResolver.isReadByTxnCompletionTime(conf)); + } + + @Test + void testWriteFlags() { + Configuration conf = new Configuration(); + conf.setString(HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key(), "true"); + assertTrue(OptionsResolver.allowCommitOnEmptyBatch(conf)); + conf.setString(HoodieWriteConfig.COMPLEX_KEYGEN_NEW_ENCODING.key(), "true"); + assertTrue(OptionsResolver.useComplexKeygenNewEncoding(conf)); + } + + @Test + void testConcurrencyControlModes() { + Configuration conf = new Configuration(); + conf.setString(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(), + WriteConcurrencyMode.NON_BLOCKING_CONCURRENCY_CONTROL.name()); + assertTrue(OptionsResolver.isNonBlockingConcurrencyControl(conf)); + // The OPTIMISTIC_CONCURRENCY_CONTROL assertion is omitted: OptionsResolver + // #isOptimisticConcurrencyControl arrives with 348e7f13a7fb (#18946) and does not exist here. + } + + @Test + void testInsertPartitioner() { + Configuration conf = new Configuration(); + assertFalse(OptionsResolver.getInsertPartitioner(conf).isPresent()); + conf.set(FlinkOptions.INSERT_PARTITIONER_CLASS_NAME, TestPartitioner.class.getName()); + assertTrue(OptionsResolver.getInsertPartitioner(conf).isPresent()); + conf.set(FlinkOptions.INSERT_PARTITIONER_CLASS_NAME, String.class.getName()); + assertThrows(HoodieException.class, () -> OptionsResolver.getInsertPartitioner(conf)); + } + + @Test + void testConflictResolutionStrategies() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BLOOM.name()); + assertInstanceOf(SimpleConcurrentFileWritesConflictResolutionStrategy.class, + OptionsResolver.getConflictResolutionStrategy(conf)); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()); + assertInstanceOf(BucketIndexConcurrentFileWritesConflictResolutionStrategy.class, + OptionsResolver.getConflictResolutionStrategy(conf)); + } + + @Test + void testWriteBufferSizingAndManagedMemory() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.WRITE_TASK_MAX_SIZE, 300D); + conf.set(FlinkOptions.WRITE_MERGE_MAX_MEMORY, 50); + assertEquals(150L * 1024 * 1024, OptionsResolver.getWriteBufferSizeInBytes(conf)); + conf.set(FlinkOptions.WRITE_TASK_MAX_SIZE, 100D); + assertThrows(IllegalStateException.class, () -> OptionsResolver.getWriteBufferSizeInBytes(conf)); + + conf.set(FlinkOptions.WRITE_BUFFER_MEMORY_TYPE, BufferMemoryType.MANAGED.name().toLowerCase()); + assertTrue(OptionsResolver.isManagedMemoryBufferEnabled(conf)); + } + + public static class TestPartitioner implements Partitioner { + public TestPartitioner(Configuration conf) { + } + + @Override + public int partition(String key, int numPartitions) { + return 0; + } + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestFilebasedSchemaProvider.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestFilebasedSchemaProvider.java new file mode 100644 index 0000000000000..ebcbee01d93f1 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestFilebasedSchemaProvider.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.hudi.schema; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.exception.HoodieIOException; + +import org.apache.flink.configuration.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link FilebasedSchemaProvider}. + */ +class TestFilebasedSchemaProvider { + + private static final String SOURCE_SCHEMA_KEY = "hoodie.streamer.schemaprovider.source.schema.file"; + private static final String TARGET_SCHEMA_KEY = "hoodie.streamer.schemaprovider.target.schema.file"; + private static final String SOURCE_SCHEMA = + "{\"type\":\"record\",\"name\":\"SourceRecord\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"}]}"; + private static final String TARGET_SCHEMA = + "{\"type\":\"record\",\"name\":\"TargetRecord\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"}," + + "{\"name\":\"ts\",\"type\":\"long\",\"default\":0}]}"; + + @TempDir + Path tempDir; + + @Test + void testConfigurationConstructorReturnsSourceSchema() throws IOException { + Path sourcePath = writeSchema("source.avsc", SOURCE_SCHEMA); + Configuration conf = new Configuration(); + conf.set(FlinkOptions.SOURCE_AVRO_SCHEMA_PATH, sourcePath.toString()); + + FilebasedSchemaProvider provider = new FilebasedSchemaProvider(conf); + + assertEquals("SourceRecord", provider.getSourceSchema().getName()); + assertEquals("SourceRecord", provider.getTargetSchema().getName()); + } + + @Test + void testTypedPropertiesConstructorReturnsSeparateTargetSchema() throws IOException { + Path sourcePath = writeSchema("source.avsc", SOURCE_SCHEMA); + Path targetPath = writeSchema("target.avsc", TARGET_SCHEMA); + TypedProperties props = new TypedProperties(); + props.setProperty(SOURCE_SCHEMA_KEY, sourcePath.toString()); + props.setProperty(TARGET_SCHEMA_KEY, targetPath.toString()); + + FilebasedSchemaProvider provider = new FilebasedSchemaProvider(props); + + assertEquals("SourceRecord", provider.getSourceSchema().getName()); + assertEquals("TargetRecord", provider.getTargetSchema().getName()); + } + + @Test + void testReadFailureIsWrapped() { + Configuration conf = new Configuration(); + conf.set(FlinkOptions.SOURCE_AVRO_SCHEMA_PATH, tempDir.resolve("missing.avsc").toString()); + + assertThrows(HoodieIOException.class, () -> new FilebasedSchemaProvider(conf)); + } + + private Path writeSchema(String fileName, String schema) throws IOException { + Path path = tempDir.resolve(fileName); + Files.write(path, schema.getBytes(StandardCharsets.UTF_8)); + return path; + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestSchemaRegistryProvider.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestSchemaRegistryProvider.java new file mode 100644 index 0000000000000..d73fdf1a9bf8c --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/schema/TestSchemaRegistryProvider.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.hudi.schema; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.exception.HoodieIOException; + +import org.apache.avro.Schema; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SchemaRegistryProvider}. + */ +class TestSchemaRegistryProvider { + + private static final String SOURCE_URL_KEY = "hoodie.streamer.schemaprovider.registry.url"; + private static final String TARGET_URL_KEY = "hoodie.streamer.schemaprovider.registry.targetUrl"; + private static final String SOURCE_SCHEMA = + "{\"type\":\"record\",\"name\":\"SourceRecord\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"}]}"; + private static final String TARGET_SCHEMA = + "{\"type\":\"record\",\"name\":\"TargetRecord\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"}," + + "{\"name\":\"ts\",\"type\":\"long\",\"default\":0}]}"; + + @Test + void testReturnsSourceAndTargetSchemasFromRegistryResponses() { + TypedProperties props = new TypedProperties(); + props.setProperty(SOURCE_URL_KEY, "http://source:secret@localhost/source"); + props.setProperty(TARGET_URL_KEY, "http://localhost/target"); + StubSchemaRegistryProvider provider = new StubSchemaRegistryProvider(props); + + assertEquals("SourceRecord", provider.getSourceSchema().getName()); + assertEquals("source:secret", provider.authorizationCredentials); + assertEquals("TargetRecord", provider.getTargetSchema().getName()); + assertEquals("TargetRecord", provider.getTargetHoodieSchema().getName()); + } + + @Test + void testAuthorizationHeaderIsBase64Encoded() { + TypedProperties props = new TypedProperties(); + props.setProperty(SOURCE_URL_KEY, "http://localhost/source"); + StubSchemaRegistryProvider provider = new StubSchemaRegistryProvider(props); + HttpURLConnection connection = mock(HttpURLConnection.class); + + provider.setAuthorizationHeader("source:secret", connection); + + verify(connection).setRequestProperty("Authorization", "Basic c291cmNlOnNlY3JldA=="); + } + + @Test + void testTargetDefaultsToSourceRegistry() { + TypedProperties props = new TypedProperties(); + props.setProperty(SOURCE_URL_KEY, "http://localhost/source"); + StubSchemaRegistryProvider provider = new StubSchemaRegistryProvider(props); + + Schema targetSchema = provider.getTargetSchema(); + + assertEquals("SourceRecord", targetSchema.getName()); + assertNotNull(provider.getTargetHoodieSchema()); + } + + @Test + void testRegistryReadFailureIsWrapped() { + TypedProperties props = new TypedProperties(); + props.setProperty(SOURCE_URL_KEY, "http://localhost/failure"); + StubSchemaRegistryProvider provider = new StubSchemaRegistryProvider(props); + + assertThrows(HoodieIOException.class, provider::getSourceSchema); + assertThrows(HoodieIOException.class, provider::getTargetSchema); + } + + private static class StubSchemaRegistryProvider extends SchemaRegistryProvider { + private String authorizationCredentials; + + StubSchemaRegistryProvider(TypedProperties props) { + super(props); + } + + @Override + protected void setAuthorizationHeader(String creds, HttpURLConnection connection) { + super.setAuthorizationHeader(creds, connection); + authorizationCredentials = creds; + } + + @Override + protected InputStream getStream(HttpURLConnection connection) throws IOException { + String path = connection.getURL().getPath(); + if ("/failure".equals(path)) { + throw new IOException("schema registry unavailable"); + } + String schema = "/target".equals(path) ? TARGET_SCHEMA : SOURCE_SCHEMA; + String response = "{\"schema\":" + quote(schema) + "}"; + return new ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8)); + } + + private static String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java index d5903ef946dcd..bb57dc45314c6 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java @@ -20,6 +20,7 @@ import org.apache.hudi.client.WriteStatus; import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient; +import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.HoodieWriteStat; @@ -106,7 +107,7 @@ public class TestStreamWriteOperatorCoordinator { @BeforeEach public void before() throws Exception { - coordinator = createCoordinator(TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()), 2); + coordinator = startCoordinator(TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()), 2); } @AfterEach @@ -142,6 +143,12 @@ public void testTableInitialized() throws IOException { } } + /** + * Verifies both coordinator restore paths. In case 1, a newly constructed coordinator receives + * checkpoint data before {@link StreamWriteOperatorCoordinator#start()}, so it must deserialize + * the event buffers for restoration during start. In case 2, global failover resets an already + * started coordinator, so it recommits its live event buffers directly. + */ @ParameterizedTest @ValueSource(booleans = {true, false}) public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled) throws Exception { @@ -151,7 +158,7 @@ public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled) throw conf.set(FlinkOptions.INDEX_TYPE, GLOBAL_RECORD_LEVEL_INDEX.name()); conf.set(FlinkOptions.INDEX_WRITE_TASKS, 2); } - coordinator = createCoordinator(conf, 2); + coordinator = startCoordinator(conf, 2); requestInstantTime(-1); String instant = coordinator.getInstant(); @@ -171,16 +178,32 @@ public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled) throw CompletableFuture future = new CompletableFuture<>(); coordinator.checkpointCoordinator(1, future); - coordinator.notifyCheckpointComplete(1); + + // Case 1: job restart restores checkpoint data before the coordinator starts. + try (StreamWriteOperatorCoordinator restoredCoordinator = createCoordinator(conf, 2)) { + restoredCoordinator.resetToCheckpoint(1, future.get()); + + EventBuffers.EventBuffer eventBuffer = restoredCoordinator.getEventBuffer(-1); + assertEquals(2, eventBuffer.getDataWriteEventBuffer().length); + assertEquals(isStreamingIndexWriteEnabled ? 2 : 0, eventBuffer.getIndexWriteEventBuffer().length); + } + + // Case 2: global failover recommits the live buffers of the already started coordinator. coordinator.resetToCheckpoint(1, future.get()); - EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer(); - assertEquals(2, eventBuffer.getDataWriteEventBuffer().length); - assertEquals(isStreamingIndexWriteEnabled ? 2 : 0, eventBuffer.getIndexWriteEventBuffer().length); + assertNull(coordinator.getEventBuffer()); + assertTrue(StreamerUtil.createMetaClient(conf).reloadActiveTimeline() + .filterCompletedInstants().containsInstant(instant)); } + /** + * Verifies legacy checkpoint compatibility for both restore paths. Case 1 deserializes the legacy + * checkpoint into a newly constructed coordinator. Case 2 intentionally does not deserialize the + * checkpoint because a coordinator surviving global failover recommits its live buffers directly. + */ @Test public void testRestoreFromLegacyState() throws Exception { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); requestInstantTime(-1); String instant = coordinator.getInstant(); assertNotEquals("", instant); @@ -192,7 +215,6 @@ public void testRestoreFromLegacyState() throws Exception { CompletableFuture future = new CompletableFuture<>(); coordinator.checkpointCoordinator(1, future); - coordinator.notifyCheckpointComplete(1); Map> eventBuffers = SerializationUtils.deserialize(future.get()); // convert to legacy event buffers @@ -200,11 +222,22 @@ public void testRestoreFromLegacyState() throws Exception { eventBuffers.forEach((ckpId, eventBuffer) -> { legacyEventBuffers.put(ckpId, Pair.of(eventBuffer.getLeft(), eventBuffer.getRight().getDataWriteEventBuffer())); }); - // simulate recovering from legacy state + + // Case 1: job restart restores legacy checkpoint data before the coordinator starts. + try (StreamWriteOperatorCoordinator restoredCoordinator = createCoordinator(conf, 2)) { + restoredCoordinator.resetToCheckpoint(1, SerializationUtils.serialize(legacyEventBuffers)); + + EventBuffers.EventBuffer eventBuffer = restoredCoordinator.getEventBuffer(-1); + assertEquals(2, eventBuffer.getDataWriteEventBuffer().length); + assertEquals(0, eventBuffer.getIndexWriteEventBuffer().length); + } + + // Case 2: global failover ignores checkpoint bytes and recommits the live buffers. coordinator.resetToCheckpoint(1, SerializationUtils.serialize(legacyEventBuffers)); - EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer(); - assertEquals(2, eventBuffer.getDataWriteEventBuffer().length); - assertEquals(0, eventBuffer.getIndexWriteEventBuffer().length); + + assertNull(coordinator.getEventBuffer()); + assertTrue(StreamerUtil.createMetaClient(conf).reloadActiveTimeline() + .filterCompletedInstants().containsInstant(instant)); } @Test @@ -226,7 +259,7 @@ public void testReceiveInvalidEvent() { public void testEventReset() throws Exception { Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); - coordinator = createCoordinator(conf, 2); + coordinator = startCoordinator(conf, 2); CompletableFuture future = new CompletableFuture<>(); coordinator.checkpointCoordinator(1, future); String instant = requestInstantTime(0); @@ -269,7 +302,7 @@ public void testCheckpointCompleteWithPartialEvents() throws Exception { conf.setString(HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key(), "false"); OperatorCoordinator.Context context = new MockOperatorCoordinatorContext(new OperatorID(), 2); - coordinator = createCoordinator(conf, 2); + coordinator = startCoordinator(conf, 2); final CompletableFuture future = new CompletableFuture<>(); coordinator.checkpointCoordinator(1, future); @@ -315,7 +348,7 @@ public void testRecordLevelIndexFlag(String indexType) throws Exception { conf.set(FlinkOptions.INDEX_TYPE, indexType); conf.set(FlinkOptions.INDEX_WRITE_TASKS, 1); - try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf, 1)) { + try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf, 1)) { assertTrue(getRecordLevelIndexFlag(coordinator)); } } @@ -327,7 +360,7 @@ public void testStopHeartbeatForUncommittedEventWithLazyCleanPolicy() throws Exc // override the default configuration Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.setString(HoodieCleanConfig.FAILED_WRITES_CLEANER_POLICY.key(), HoodieFailedWritesCleaningPolicy.LAZY.name()); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); assertTrue(coordinator.getWriteClient().getConfig().getFailedWritesCleanPolicy().isLazy()); @@ -373,7 +406,7 @@ public void testHiveSyncInvoked() throws Exception { // override the default configuration Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.set(FlinkOptions.HIVE_SYNC_ENABLED, true); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); String instant = mockWriteWithMetadata(0); assertNotEquals("", instant); @@ -391,7 +424,7 @@ void testSyncMetadataTable() throws Exception { int metadataCompactionDeltaCommits = 5; conf.set(FlinkOptions.METADATA_ENABLED, true); conf.set(FlinkOptions.METADATA_COMPACTION_DELTA_COMMITS, metadataCompactionDeltaCommits); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); String instant = coordinator.getInstant(); assertEquals("", instant); @@ -468,7 +501,7 @@ void testSyncMetadataTableWithLogCompaction() throws Exception { conf.set(FlinkOptions.METADATA_ENABLED, true); conf.set(FlinkOptions.METADATA_COMPACTION_DELTA_COMMITS, 20); conf.setString("hoodie.metadata.log.compaction.enable", "true"); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); String instant = coordinator.getInstant(); assertEquals("", instant); @@ -512,7 +545,7 @@ void testSyncMetadataTableWithRollback() throws Exception { // override the default configuration Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.set(FlinkOptions.METADATA_ENABLED, true); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); String instant = coordinator.getInstant(); assertEquals("", instant); @@ -536,7 +569,7 @@ void testSyncMetadataTableWithRollback() throws Exception { metadataTableMetaClient.getActiveTimeline().transitionRequestedToInflight(HoodieActiveTimeline.DELTA_COMMIT_ACTION, instant); metadataTableMetaClient.reloadActiveTimeline(); // reset the coordinator to mimic the job failover. - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); // write another commit with new instant on the metadata timeline instant = mockWriteWithMetadata(ckp); @@ -555,7 +588,7 @@ public void testEndInputIsTheLastEvent() throws Exception { Logger logger = Mockito.mock(Logger.class); // avoid too many logs by executor NonThrownExecutor executor = NonThrownExecutor.builder(logger).waitForTasksFinish(true).build(); - try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf, 1)) { + try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf, 1)) { coordinator.start(); coordinator.setExecutor(executor); TimeUnit.SECONDS.sleep(5); // wait for handled bootstrap event @@ -593,7 +626,7 @@ void testLockForMetadataTable() throws Exception { conf.setString(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(), WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL.name()); conf.setString("hoodie.write.lock.client.num_retries", "1"); - coordinator = createCoordinator(conf, 1); + coordinator = startCoordinator(conf, 1); String instant = coordinator.getInstant(); assertEquals("", instant); @@ -618,7 +651,7 @@ void testLockForMetadataTable() throws Exception { public void testCommitOnEmptyBatch() throws Exception { Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.setString(HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key(), "true"); - try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf, 2)) { + try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf, 2)) { // Coordinator start the instant String instant = requestInstantTime(coordinator, -1); @@ -649,7 +682,7 @@ public void testCommitOnEmptyBatch() throws Exception { void testHandleInFlightInstantsRequest() throws Exception { Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); - coordinator = createCoordinator(conf, 2); + coordinator = startCoordinator(conf, 2); // Request an instant time to create an initial instant String instant1 = requestInstantTime(1); @@ -701,15 +734,20 @@ private String requestInstantTime(StreamWriteOperatorCoordinator coordinator, lo } } - private static StreamWriteOperatorCoordinator createCoordinator(Configuration conf, int subTasks) throws Exception { - MockOperatorCoordinatorContext coordinatorContext = new MockOperatorCoordinatorContext(new OperatorID(), subTasks); - StreamWriteOperatorCoordinator coordinator = new StreamWriteOperatorCoordinator(conf, coordinatorContext); + private static StreamWriteOperatorCoordinator startCoordinator(Configuration conf, int subTasks) throws Exception { + StreamWriteOperatorCoordinator coordinator = createCoordinator(conf, subTasks); coordinator.start(); + MockOperatorCoordinatorContext coordinatorContext = (MockOperatorCoordinatorContext) coordinator.getContext(); coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext)); coordinator.setInstantRequestExecutor(new MockCoordinatorExecutor(coordinatorContext)); return coordinator; } + private static StreamWriteOperatorCoordinator createCoordinator(Configuration conf, int subTasks) { + MockOperatorCoordinatorContext coordinatorContext = new MockOperatorCoordinatorContext(new OperatorID(), subTasks); + return new StreamWriteOperatorCoordinator(conf, coordinatorContext); + } + private String mockWriteWithMetadata(long checkpointId) { String instant = requestInstantTime(checkpointId); OperatorEvent event = createOperatorEvent(0, checkpointId, instant, "par1", false, true, 0.1); @@ -759,7 +797,7 @@ private static WriteMetadataEvent createOperatorEvent( HoodieWriteStat writeStat = new HoodieWriteStat(); writeStat.setPartitionPath(partitionPath); writeStat.setFileId("fileId123"); - writeStat.setPath("path123"); + writeStat.setPath(partitionPath + "/" + FSUtils.makeBaseFileName(instant, "1-0-1", "fileId123", ".parquet")); writeStat.setFileSizeInBytes(123); writeStat.setTotalWriteBytes(123); writeStat.setNumWrites(1); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java index a4e869428c243..e7cffbab75af5 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java @@ -359,6 +359,35 @@ public void testInsertDuplicates() throws Exception { .end(); } + @Test + public void testInsertWithTableServiceDisabled() throws Exception { + // reset the config option + conf.set(FlinkOptions.OPERATION, "insert"); + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + conf.set(FlinkOptions.CLUSTERING_SCHEDULE_ENABLED, true); + conf.set(FlinkOptions.CLUSTERING_ASYNC_ENABLED, true); + conf.set(FlinkOptions.CLUSTERING_DELTA_COMMITS, 1); + + preparePipeline(conf) + .consume(TestData.DATA_SET_INSERT_SAME_KEY) + .checkpoint(1) + .handleEvents(1) + .checkpointComplete(1) + .checkWrittenData(EXPECTED4, 1) + // insert duplicates again + .consume(TestData.DATA_SET_INSERT_SAME_KEY) + .checkpoint(2) + .handleEvents(1) + .checkpointComplete(2) + .checkWrittenDataCOW(EXPECTED5) + .end(); + HoodieFlinkWriteClient writeClient = FlinkWriteClients.createWriteClient(conf); + long completedReplaceCommit = writeClient.getHoodieTable().getActiveTimeline().getCompletedReplaceTimeline().getInstants().stream().count(); + long pendingReplaceCommit = writeClient.getHoodieTable().getActiveTimeline().filterPendingReplaceTimeline().getInstants().stream().count(); + assertEquals(0, completedReplaceCommit); + assertEquals(0, pendingReplaceCommit); + } + @Test public void testUpsert() throws Exception { // open the function and ingest data @@ -723,7 +752,7 @@ public void testWriteMultiWriterInvolved(WriteConcurrencyMode writeConcurrencyMo protected void validateNonBlockingConcurrencyControlConditions() { assertThrows( - IllegalArgumentException.class, + HoodieException.class, () -> preparePipeline(conf), "Non-blocking concurrency control requires the MOR table with simple bucket index"); } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java index 5e4deee54c966..a0ddf33eb703d 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java @@ -317,6 +317,41 @@ public void testRecommitOnJobRestartTriggeringGlobalFailover() throws Exception .end(); } + @Test + public void testRecommitOnGlobalFailoverWithStreamingIndex() throws Exception { + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name()); + conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key(), "true"); + conf.setString(HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key(), "true"); + conf.set(FlinkOptions.WRITE_COMMIT_ACK_TIMEOUT, 10_000L); + conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); + + Map expected = new HashMap<>(); + expected.put("par1", "[id1,par1,id1,Danny,23,1,par1]"); + expected.put("par2", "[id3,par2,id3,Julian,53,3,par2]"); + + preparePipeline(conf) + .consume(TestData.DATA_SET_PART1) + .emptyEventBuffer() + .checkpoint(1) + .assertNextEvent(1, "par1") + .consume(TestData.DATA_SET_PART3) + .checkpoint(2) + // both ckp-1 and ckp-2 are not committing + .assertNextEvent(1, "par2") + .checkCompletedInstantCount(0) + // Simulate the global failover path. The coordinator resets to ckp-2, recommits ckp-1 + // from the coordinator state, and recommits ckp-2 from the restored writer state. + .jobFailover() + .checkCompletedInstantCount(2) + // This is already the global failover path, so recommit does not need to trigger another failover. + .assertGlobalFailure(false) + .checkIndexLoaded( + new HoodieKey("id1", "par1"), + new HoodieKey("id3", "par2")) + .checkWrittenData(expected, 2) + .end(); + } + @Test public void testInsertDuplicateRecordsWithCDCMode() throws Exception { conf.set(FlinkOptions.WRITE_COMMIT_ACK_TIMEOUT, 10_000L); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnReadWithCompact.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnReadWithCompact.java index 7515f89043272..3f24c2763faf1 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnReadWithCompact.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnReadWithCompact.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.PartialUpdateAvroPayload; import org.apache.hudi.common.model.WriteConcurrencyMode; +import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; @@ -42,7 +43,9 @@ import java.util.List; import java.util.Map; +import static org.apache.hudi.common.table.timeline.HoodieTimeline.COMPACTION_ACTION; import static org.apache.hudi.utils.TestData.insertRow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** @@ -56,6 +59,34 @@ protected void setUp(Configuration conf) { conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); } + @Test + public void testUpsertWithTableServiceDisabled() throws Exception { + // reset the config option + conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false); + conf.set(FlinkOptions.COMPACTION_SCHEDULE_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + + preparePipeline(conf) + .consume(TestData.DATA_SET_INSERT) + .assertEmptyDataFiles() + .checkpoint(1) + .handleEvents(1) + .checkpointComplete(1) + .consume(TestData.DATA_SET_INSERT) + .checkpoint(2) + .handleEvents(1) + .checkpointComplete(2) + .end(); + HoodieFlinkWriteClient writeClient = FlinkWriteClients.createWriteClient(conf); + long completedCompaction = writeClient.getHoodieTable().getActiveTimeline().getInstants().stream() + .filter(s -> s.getAction().equals(COMPACTION_ACTION)) + .filter(HoodieInstant::isCompleted).count(); + long pendingCompaction = writeClient.getHoodieTable().getActiveTimeline().filterPendingCompactionTimeline().getInstants().stream().count(); + assertEquals(0, completedCompaction); + assertEquals(0, pendingCompaction); + } + @Test public void testPartialFailover() { // partial failover is only valid for append mode. diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bootstrap/TestRLIBootstrapOperator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bootstrap/TestRLIBootstrapOperator.java new file mode 100644 index 0000000000000..2e684d0652ae5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bootstrap/TestRLIBootstrapOperator.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.hudi.sink.bootstrap; + +import org.apache.hudi.client.model.HoodieFlinkInternalRow; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.metadata.MetadataPartitionType; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.TestConfigurations; + +import org.apache.flink.configuration.Configuration; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link RLIBootstrapOperator}. + */ +public class TestRLIBootstrapOperator { + + @TempDir + File tempFile; + + @Test + void testSkipPreloadForFreshTableWithoutMetadataTable() throws Exception { + Configuration conf = getRLIConf(); + StreamerUtil.initTableIfNotExists(conf); + + try (OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(new RLIBootstrapOperator(conf), 1, 1, 0)) { + harness.open(); + + assertEquals(0, harness.getOutput().size()); + } + } + + @Test + void testFailFastWhenMetadataTableIsMarkedAvailableButCannotBeLoaded() throws Exception { + Configuration conf = getRLIConf(); + HoodieTableMetaClient metaClient = StreamerUtil.initTableIfNotExists(conf); + metaClient.getTableConfig().setMetadataPartitionState(metaClient, MetadataPartitionType.FILES.getPartitionPath(), true); + + try (OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(new RLIBootstrapOperator(conf), 1, 1, 0)) { + RuntimeException error = assertThrows(RuntimeException.class, harness::open); + + assertEquals("Can not initialize the table metadata", error.getMessage()); + } + } + + private Configuration getRLIConf() { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.METADATA_ENABLED, true); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name()); + return conf; + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/sort/TestSortOperatorGen.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/sort/TestSortOperatorGen.java new file mode 100644 index 0000000000000..8a6c7169e73b9 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/bulk/sort/TestSortOperatorGen.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.sink.bulk.sort; + +import org.apache.flink.core.memory.MemorySegment; +import org.apache.flink.core.memory.MemorySegmentFactory; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.runtime.generated.NormalizedKeyComputer; +import org.apache.flink.table.runtime.generated.RecordComparator; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +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.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link SortOperatorGen}. + */ +class TestSortOperatorGen { + + @Test + void testGeneratedRecordComparator() { + RowType rowType = RowType.of( + new LogicalType[] { + new IntType(), + new VarCharType(), + new VarBinaryType(), + new DecimalType(10, 2), + new TimestampType(3) + }, + new String[] {"id", "name", "bytes", "amount", "ts"}); + + SortOperatorGen sortOperatorGen = + new SortOperatorGen(rowType, new String[] {"id", "name", "bytes", "amount", "ts"}); + RecordComparator comparator = sortOperatorGen.generateRecordComparator("TestSortComparator") + .newInstance(Thread.currentThread().getContextClassLoader()); + + GenericRowData row1 = row(1, "a", new byte[] {1, 2}, "1.00", 1000); + GenericRowData row2 = row(1, "a", new byte[] {1, 3}, "1.00", 1000); + GenericRowData row3 = row(1, "a", new byte[] {1, 3}, "2.00", 1000); + GenericRowData row4 = row(1, "a", new byte[] {1, 3}, "2.00", 2000); + GenericRowData nullRow = row(null, "a", new byte[] {1, 3}, "2.00", 2000); + + assertTrue(comparator.compare(row1, row2) < 0); + assertTrue(comparator.compare(row2, row3) < 0); + assertTrue(comparator.compare(row3, row4) < 0); + assertTrue(comparator.compare(nullRow, row1) > 0); + assertTrue(comparator.compare(row1, nullRow) < 0); + assertEquals(0, comparator.compare(row4, row4)); + } + + @Test + void testGeneratedNormalizedKeyComputer() { + RowType rowType = RowType.of(new LogicalType[] {new IntType()}, new String[] {"id"}); + NormalizedKeyComputer computer = new SortOperatorGen(rowType, new String[] {"id"}) + .generateNormalizedKeyComputer("TestSortComputer") + .newInstance(Thread.currentThread().getContextClassLoader()); + MemorySegment segment1 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + MemorySegment segment2 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + + computer.putKey(GenericRowData.of(1), segment1, 0); + computer.putKey(GenericRowData.of(2), segment2, 0); + + assertTrue(computer.getNumKeyBytes() > 1); + assertTrue(computer.isKeyFullyDetermines()); + assertTrue(computer.compareKey(segment1, 0, segment2, 0) < 0); + } + + @Test + void testGeneratedNormalizedKeyComputerWithNullsLast() { + RowType rowType = RowType.of(new LogicalType[] {new IntType()}, new String[] {"id"}); + NormalizedKeyComputer computer = new SortOperatorGen(rowType, new String[] {"id"}) + .generateNormalizedKeyComputer("TestSortComputer") + .newInstance(Thread.currentThread().getContextClassLoader()); + MemorySegment segment1 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + MemorySegment segment2 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + + computer.putKey(GenericRowData.of(1), segment1, 0); + computer.putKey(GenericRowData.of((Object) null), segment2, 0); + + assertTrue(computer.compareKey(segment1, 0, segment2, 0) < 0); + } + + @Test + void testGeneratedNormalizedKeyComputerStopsAfterVariableLengthPrefix() { + RowType rowType = RowType.of( + new LogicalType[] {new VarCharType(), new IntType()}, + new String[] {"name", "id"}); + NormalizedKeyComputer computer = new SortOperatorGen(rowType, new String[] {"name", "id"}) + .generateNormalizedKeyComputer("TestSortComputer") + .newInstance(Thread.currentThread().getContextClassLoader()); + MemorySegment segment1 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + MemorySegment segment2 = MemorySegmentFactory.wrap(new byte[computer.getNumKeyBytes()]); + + computer.putKey(GenericRowData.of(StringData.fromString("abcdefghx"), 2), segment1, 0); + computer.putKey(GenericRowData.of(StringData.fromString("abcdefghy"), 1), segment2, 0); + + assertFalse(computer.isKeyFullyDetermines()); + assertEquals(0, computer.compareKey(segment1, 0, segment2, 0)); + } + + private static GenericRowData row(Integer id, String name, byte[] bytes, String amount, long timestampMillis) { + return GenericRowData.of( + id, + StringData.fromString(name), + bytes, + DecimalData.fromBigDecimal(new BigDecimal(amount), 10, 2), + TimestampData.fromEpochMillis(timestampMillis)); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/cluster/ITTestHoodieFlinkClustering.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/cluster/ITTestHoodieFlinkClustering.java index 4fbf90e2e421a..c7816afc38ae9 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/cluster/ITTestHoodieFlinkClustering.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/cluster/ITTestHoodieFlinkClustering.java @@ -65,7 +65,6 @@ import org.apache.flink.table.api.config.ExecutionConfigOptions; import org.apache.flink.table.api.config.TableConfigOptions; import org.apache.flink.table.api.internal.TableEnvironmentImpl; -import org.apache.flink.table.planner.plan.nodes.exec.utils.ExecNodeUtil; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.Row; @@ -84,6 +83,7 @@ import java.util.stream.Collectors; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; +import static org.apache.hudi.sink.utils.FlinkTransformationUtils.setManagedMemoryWeight; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -193,7 +193,7 @@ public void testHoodieFlinkClustering() throws Exception { new ClusteringOperator(conf, rowType)) .setParallelism(clusteringPlan.getInputGroups().size()); - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); dataStream @@ -398,7 +398,7 @@ public void testHoodieFlinkClusteringScheduleAfterArchive() throws Exception { new ClusteringOperator(conf, rowType)) .setParallelism(clusteringPlan.getInputGroups().size()); - ExecNodeUtil.setManagedMemoryWeight( + setManagedMemoryWeight( dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); @@ -661,7 +661,7 @@ private void runCluster(RowType rowType) throws Exception { new ClusteringOperator(conf, rowType)) .setParallelism(clusteringPlan.getInputGroups().size()); - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); dataStream @@ -765,7 +765,7 @@ private void runOfflineCluster(TableEnvironment tableEnv, Configuration conf) th new ClusteringOperator(conf, rowType)) .setParallelism(clusteringPlan.getInputGroups().size()); - ExecNodeUtil.setManagedMemoryWeight(dataStream.getTransformation(), + setManagedMemoryWeight(dataStream.getTransformation(), conf.get(FlinkOptions.WRITE_SORT_MEMORY) * 1024L * 1024L); dataStream diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/clustering/TestFlinkClusteringConfig.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/clustering/TestFlinkClusteringConfig.java new file mode 100644 index 0000000000000..b4340ceae20b0 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/clustering/TestFlinkClusteringConfig.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.hudi.sink.clustering; + +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.TestConfigurations; + +import com.beust.jcommander.JCommander; +import org.apache.flink.configuration.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link FlinkClusteringConfig}. + */ +class TestFlinkClusteringConfig { + + @TempDir + Path tempDir; + + @Test + void testParseAndDeriveFlinkConfiguration() throws Exception { + Configuration tableConf = TestConfigurations.getDefaultConf(tempDir.toString()); + tableConf.set(FlinkOptions.URL_ENCODE_PARTITIONING, true); + tableConf.set(FlinkOptions.HIVE_STYLE_PARTITIONING, true); + StreamerUtil.initTableIfNotExists(tableConf); + + FlinkClusteringConfig config = new FlinkClusteringConfig(); + JCommander.newBuilder().addObject(config).build().parse( + "--path", tempDir.toString(), + "--clustering-delta-commits", "6", + "--clustering-tasks", "4", + "--clean-retain-commits", "12", + "--clean-retain-hours", "36", + "--clean-retain-file-versions", "7", + "--archive-min-commits", "25", + "--archive-max-commits", "40", + "--schedule", + "--clean-async-enabled", + "--plan-partition-filter-mode", "RECENT_DAYS", + "--target-file-max-bytes", "1048576", + "--small-file-limit", "524288", + "--skip-from-latest-partitions", "2", + "--sort-columns", "event_ts,order_id", + "--sort-memory", "256", + "--max-num-groups", "10", + "--target-partitions", "5", + "--cluster-begin-partition", "2026-01-01", + "--cluster-end-partition", "2026-01-31", + "--partition-regex-pattern", "2026-01-.*", + "--partition-selected", "2026-01-01,2026-01-02", + "--hoodie-conf", "hoodie.test.clustering.option=from-cli"); + + Configuration conf = FlinkClusteringConfig.toFlinkConfig(config); + + assertEquals(tempDir.toString(), conf.get(FlinkOptions.PATH)); + assertEquals(6, conf.get(FlinkOptions.CLUSTERING_DELTA_COMMITS)); + assertEquals(4, conf.get(FlinkOptions.CLUSTERING_TASKS)); + assertEquals(12, conf.get(FlinkOptions.CLEAN_RETAIN_COMMITS)); + assertEquals(36, conf.get(FlinkOptions.CLEAN_RETAIN_HOURS)); + assertEquals(7, conf.get(FlinkOptions.CLEAN_RETAIN_FILE_VERSIONS)); + assertEquals(25, conf.get(FlinkOptions.ARCHIVE_MIN_COMMITS)); + assertEquals(40, conf.get(FlinkOptions.ARCHIVE_MAX_COMMITS)); + assertEquals("RECENT_DAYS", conf.get(FlinkOptions.CLUSTERING_PLAN_PARTITION_FILTER_MODE_NAME)); + assertEquals(1048576L, conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_TARGET_FILE_MAX_BYTES)); + assertEquals(524288L, conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_SMALL_FILE_LIMIT)); + assertEquals(2, conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_SKIP_PARTITIONS_FROM_LATEST)); + assertEquals("event_ts,order_id", conf.get(FlinkOptions.CLUSTERING_SORT_COLUMNS)); + assertEquals(256, conf.get(FlinkOptions.WRITE_SORT_MEMORY)); + assertEquals(10, conf.get(FlinkOptions.CLUSTERING_MAX_NUM_GROUPS)); + assertEquals(5, conf.get(FlinkOptions.CLUSTERING_TARGET_PARTITIONS)); + assertEquals("2026-01-01", + conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_CLUSTER_BEGIN_PARTITION)); + assertEquals("2026-01-31", + conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_CLUSTER_END_PARTITION)); + assertEquals("2026-01-.*", + conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_PARTITION_REGEX_PATTERN)); + assertEquals("2026-01-01,2026-01-02", + conf.get(FlinkOptions.CLUSTERING_PLAN_STRATEGY_PARTITION_SELECTED)); + assertTrue(conf.get(FlinkOptions.CLEAN_ASYNC_ENABLED)); + assertFalse(conf.get(FlinkOptions.CLUSTERING_ASYNC_ENABLED)); + assertTrue(conf.get(FlinkOptions.CLUSTERING_SCHEDULE_ENABLED)); + assertTrue(conf.get(FlinkOptions.URL_ENCODE_PARTITIONING)); + assertTrue(conf.get(FlinkOptions.HIVE_STYLE_PARTITIONING)); + assertEquals("from-cli", conf.getString("hoodie.test.clustering.option", null)); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/TestFlinkCompactionConfig.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/TestFlinkCompactionConfig.java new file mode 100644 index 0000000000000..10ac0f7357dc1 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/TestFlinkCompactionConfig.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.hudi.sink.compact; + +import org.apache.hudi.common.config.HoodieMemoryConfig; +import org.apache.hudi.common.config.HoodieReaderConfig; +import org.apache.hudi.configuration.FlinkOptions; + +import com.beust.jcommander.JCommander; +import org.apache.flink.configuration.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link FlinkCompactionConfig}. + */ +class TestFlinkCompactionConfig { + + @TempDir + Path tempDir; + + @Test + void testParseAndDeriveFlinkConfiguration() { + FlinkCompactionConfig config = new FlinkCompactionConfig(); + JCommander.newBuilder().addObject(config).build().parse( + "--path", tempDir.toString(), + "--compaction-trigger-strategy", FlinkCompactionConfig.NUM_OR_TIME, + "--disable-file-group-reader", + "--compaction-delta-commits", "8", + "--compaction-delta-seconds", "900", + "--clean-async-enabled", + "--compaction-max-memory", "256", + "--compaction-target-io", "2048", + "--compaction-tasks", "4", + "--schedule", + "--spillable_map_path", tempDir.resolve("spill").toString(), + "--hoodie-conf", "hoodie.test.compaction.option=from-cli"); + + Configuration conf = FlinkCompactionConfig.toFlinkConfig(config); + + assertEquals(tempDir.toString(), conf.get(FlinkOptions.PATH)); + assertEquals(FlinkCompactionConfig.NUM_OR_TIME, conf.get(FlinkOptions.COMPACTION_TRIGGER_STRATEGY)); + assertEquals(8, conf.get(FlinkOptions.COMPACTION_DELTA_COMMITS)); + assertEquals(900, conf.get(FlinkOptions.COMPACTION_DELTA_SECONDS)); + assertEquals(256, conf.get(FlinkOptions.COMPACTION_MAX_MEMORY)); + assertEquals(256, conf.get(FlinkOptions.WRITE_MERGE_MAX_MEMORY)); + assertEquals(2048L, conf.get(FlinkOptions.COMPACTION_TARGET_IO)); + assertEquals(4, conf.get(FlinkOptions.COMPACTION_TASKS)); + assertTrue(conf.get(FlinkOptions.CLEAN_ASYNC_ENABLED)); + assertFalse(conf.get(FlinkOptions.COMPACTION_OPERATION_EXECUTE_ASYNC_ENABLED)); + assertTrue(conf.get(FlinkOptions.COMPACTION_SCHEDULE_ENABLED)); + assertEquals(tempDir.resolve("spill").toString(), + conf.getString(HoodieMemoryConfig.SPILLABLE_MAP_BASE_PATH.key(), null)); + assertEquals("false", conf.getString(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key(), null)); + assertEquals("from-cli", conf.getString("hoodie.test.compaction.option", null)); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/handler/TestDefaultCleanHandler.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/handler/TestDefaultCleanHandler.java new file mode 100644 index 0000000000000..7c2c5524d49da --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/compact/handler/TestDefaultCleanHandler.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.hudi.sink.compact.handler; + +import org.apache.hudi.client.HoodieFlinkWriteClient; + +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +class TestDefaultCleanHandler { + + @Test + void testCloseClosesWriteClientWithoutTriggeringClean() { + HoodieFlinkWriteClient writeClient = mock(HoodieFlinkWriteClient.class); + DefaultCleanHandler handler = new DefaultCleanHandler(writeClient); + + handler.close(); + + verify(writeClient).close(); + verifyNoMoreInteractions(writeClient); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java index 4f682084050bc..3b5c265f7c916 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java @@ -19,11 +19,17 @@ package org.apache.hudi.sink.partitioner; import org.apache.hudi.client.common.HoodieFlinkEngineContext; +import org.apache.hudi.common.config.HoodieStorageConfig; +import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.config.HoodieCompactionConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.hadoop.fs.HadoopFSUtils; +import org.apache.hudi.sink.partitioner.profile.DeltaWriteProfile; import org.apache.hudi.sink.partitioner.profile.WriteProfile; import org.apache.hudi.table.action.commit.BucketInfo; import org.apache.hudi.table.action.commit.BucketType; @@ -41,11 +47,13 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Queue; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -151,7 +159,8 @@ public void testAddInsert() { @Test public void testInsertOverBucketAssigned() { - conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_INSERT_SPLIT_SIZE.key(), "2"); + conf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), String.valueOf(512 * 1024)); writeConfig = FlinkWriteClients.getHoodieClientConfig(conf); MockBucketAssigner mockBucketAssigner = new MockBucketAssigner(context, writeConfig); @@ -402,6 +411,151 @@ public void testWriteProfileMetadataCache() throws Exception { writeProfile.getMetadataCache().size(), is(3)); } + @Test + public void testWriteProfileRecordsPerBucketUsesProfiledRecordSize() { + conf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_INSERT_SPLIT_SIZE.key(), "2"); + conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), "1024"); + writeConfig = FlinkWriteClients.getHoodieClientConfig(conf); + + WriteProfile writeProfile = new WriteProfile(writeConfig, context); + + assertThat("Average record size should use the configured estimate for an empty table", + writeProfile.getAvgSize(), is(1024L)); + assertThat("Records per bucket should be derived from the max parquet file size", + writeProfile.getRecordsPerBucket(), is(1024L)); + } + + @Test + public void testWriteProfileRecordsPerBucketUsesProfiledRecordSizeWithSmallEstimationThreshold() throws Exception { + conf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), String.valueOf(1024 * 1024)); + conf.setString(HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "1"); + TestData.writeData(TestData.DATA_SET_INSERT, conf); + + writeConfig = FlinkWriteClients.getHoodieClientConfig(conf); + WriteProfile writeProfile = new WriteProfile(writeConfig, context); + String latestInstant = getLastCompleteInstant(writeProfile); + HoodieCommitMetadata commitMetadata = writeProfile.getMetadataCache().get(latestInstant); + assertNotNull(commitMetadata); + long expectedAvgSize = (long) Math.ceil( + 1.0 * commitMetadata.fetchTotalBytesWritten() / commitMetadata.fetchTotalRecordsWritten()); + + assertThat("Average record size should use commit metadata when it is large enough relative to small file limit", + writeProfile.getAvgSize(), is(expectedAvgSize)); + assertThat("Records per bucket should use the profiled record size", + writeProfile.getRecordsPerBucket(), is(writeConfig.getParquetMaxFileSize() / expectedAvgSize)); + } + + @Test + public void testWriteProfileReusesPreviousAvgSizeWhenNoEligibleCommitOnReload() throws Exception { + conf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + conf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), "1024"); + TestData.writeData(TestData.DATA_SET_INSERT, conf); + + writeConfig = FlinkWriteClients.getHoodieClientConfig(conf); + setScriptedRecordSizes(512L, -1L); + WriteProfile writeProfile = new ScriptedRecordSizeWriteProfile(writeConfig, context); + assertThat("Average record size should use the profiled commit metadata", + writeProfile.getAvgSize(), is(512L)); + + writeProfile.reload(1); + + assertThat("Average record size should reuse the previous estimate when no eligible commit metadata is found", + writeProfile.getAvgSize(), is(512L)); + assertThat("Records per bucket should continue to use the previous estimate", + writeProfile.getRecordsPerBucket(), is(writeConfig.getParquetMaxFileSize() / 512L)); + } + + @Test + public void testDeltaWriteProfileRecordsPerBucketUsesCompressionRatio() throws Exception { + File morPath = new File(tempFile, "mor"); + Configuration morConf = TestConfigurations.getDefaultConf(morPath.getAbsolutePath()); + morConf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); + morConf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + morConf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), "1024"); + morConf.setString(HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "1"); + morConf.setString(HoodieStorageConfig.LOGFILE_TO_PARQUET_COMPRESSION_RATIO_FRACTION.key(), "0.5"); + StreamerUtil.initTableIfNotExists(morConf); + TestData.writeData(TestData.DATA_SET_INSERT, morConf); + + HoodieWriteConfig morWriteConfig = FlinkWriteClients.getHoodieClientConfig(morConf); + HoodieFlinkEngineContext morContext = new HoodieFlinkEngineContext( + HadoopFSUtils.getStorageConf(HadoopConfigurations.getHadoopConf(morConf)), + new FlinkTaskContextSupplier(null)); + + DeltaWriteProfile writeProfile = new DeltaWriteProfile(morWriteConfig, morContext); + String latestInstant = getLastCompleteInstant(writeProfile); + HoodieCommitMetadata commitMetadata = writeProfile.getMetadataCache().get(latestInstant); + assertNotNull(commitMetadata); + long expectedAvgSize = (long) Math.ceil( + 0.5 * commitMetadata.fetchTotalBytesWritten() / commitMetadata.fetchTotalRecordsWritten()); + + assertThat("Average record size from commit metadata should be corrected for MOR log-to-parquet compression", + writeProfile.getAvgSize(), is(expectedAvgSize)); + assertThat("Records per bucket should use the corrected MOR average record size", + writeProfile.getRecordsPerBucket(), is(morWriteConfig.getParquetMaxFileSize() / expectedAvgSize)); + } + + @Test + public void testDeltaWriteProfileRecordsPerBucketSkipsCompressionRatioForParquetLogBlocks() throws Exception { + File morPath = new File(tempFile, "mor_parquet_logs"); + Configuration morConf = TestConfigurations.getDefaultConf(morPath.getAbsolutePath()); + morConf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); + morConf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + morConf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), "1024"); + morConf.setString(HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "1"); + morConf.setString(HoodieStorageConfig.LOGFILE_TO_PARQUET_COMPRESSION_RATIO_FRACTION.key(), "0.5"); + morConf.setString(HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), "parquet"); + StreamerUtil.initTableIfNotExists(morConf); + TestData.writeData(TestData.DATA_SET_INSERT, morConf); + + HoodieWriteConfig morWriteConfig = FlinkWriteClients.getHoodieClientConfig(morConf); + HoodieFlinkEngineContext morContext = new HoodieFlinkEngineContext( + HadoopFSUtils.getStorageConf(HadoopConfigurations.getHadoopConf(morConf)), + new FlinkTaskContextSupplier(null)); + + DeltaWriteProfile writeProfile = new DeltaWriteProfile(morWriteConfig, morContext); + String latestInstant = getLastCompleteInstant(writeProfile); + HoodieCommitMetadata commitMetadata = writeProfile.getMetadataCache().get(latestInstant); + assertNotNull(commitMetadata); + long expectedAvgSize = (long) Math.ceil( + 1.0 * commitMetadata.fetchTotalBytesWritten() / commitMetadata.fetchTotalRecordsWritten()); + + assertThat("Average record size from parquet log blocks should not be corrected again", + writeProfile.getAvgSize(), is(expectedAvgSize)); + assertThat("Records per bucket should use the uncorrected parquet log block average record size", + writeProfile.getRecordsPerBucket(), is(morWriteConfig.getParquetMaxFileSize() / expectedAvgSize)); + } + + @Test + public void testDeltaWriteProfileReusesPreviousAvgSizeWhenNoEligibleDeltaCommitOnReload() throws Exception { + File morPath = new File(tempFile, "mor_reuse_previous_avg"); + Configuration morConf = TestConfigurations.getDefaultConf(morPath.getAbsolutePath()); + morConf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); + morConf.set(FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE, 1); + morConf.setString(HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), "1024"); + StreamerUtil.initTableIfNotExists(morConf); + TestData.writeData(TestData.DATA_SET_INSERT, morConf); + + HoodieWriteConfig morWriteConfig = FlinkWriteClients.getHoodieClientConfig(morConf); + HoodieFlinkEngineContext morContext = new HoodieFlinkEngineContext( + HadoopFSUtils.getStorageConf(HadoopConfigurations.getHadoopConf(morConf)), + new FlinkTaskContextSupplier(null)); + + setScriptedRecordSizes(256L, -1L); + DeltaWriteProfile writeProfile = new ScriptedRecordSizeDeltaWriteProfile(morWriteConfig, morContext); + assertThat("Average record size should use the profiled delta commit metadata", + writeProfile.getAvgSize(), is(256L)); + + writeProfile.reload(1); + + assertThat("Average record size should reuse the previous estimate when no eligible delta commit metadata is found", + writeProfile.getAvgSize(), is(256L)); + assertThat("Records per bucket should continue to use the previous estimate", + writeProfile.getRecordsPerBucket(), is(morWriteConfig.getParquetMaxFileSize() / 256L)); + } + private static String getLastCompleteInstant(WriteProfile profile) { return StreamerUtil.getLastCompletedInstant(profile.getMetaClient()); } @@ -423,6 +577,40 @@ private void assertBucketEquals( assertThat(bucketInfo.getBucketType(), is(bucketType)); } + private static Queue scriptedRecordSizes = new ArrayDeque<>(); + + private static void setScriptedRecordSizes(Long... recordSizes) { + scriptedRecordSizes = new ArrayDeque<>(Arrays.asList(recordSizes)); + } + + /** + * WriteProfile with scripted record size estimates. + */ + static class ScriptedRecordSizeWriteProfile extends WriteProfile { + ScriptedRecordSizeWriteProfile(HoodieWriteConfig config, HoodieFlinkEngineContext context) { + super(config, context); + } + + @Override + protected long calculateRecordSizeThroughCommitMetadata(HoodieTimeline commitTimeline, double fileSizeCalibrationRatio) { + return scriptedRecordSizes.remove(); + } + } + + /** + * DeltaWriteProfile with scripted record size estimates. + */ + static class ScriptedRecordSizeDeltaWriteProfile extends DeltaWriteProfile { + ScriptedRecordSizeDeltaWriteProfile(HoodieWriteConfig config, HoodieFlinkEngineContext context) { + super(config, context); + } + + @Override + protected long calculateRecordSizeThroughCommitMetadata(HoodieTimeline commitTimeline, double fileSizeCalibrationRatio) { + return scriptedRecordSizes.remove(); + } + } + /** * Mock BucketAssigner that can specify small files explicitly. */ diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestMinibatchBucketAssignFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestMinibatchBucketAssignFunction.java index a98a95529fc18..0e83ff81f75ad 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestMinibatchBucketAssignFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestMinibatchBucketAssignFunction.java @@ -234,14 +234,94 @@ public void testGlobalIndexUpdate() throws Exception { } } + @Test + public void testDuplicateKeyCrossPartitionUpdate() throws Exception { + HoodieFlinkInternalRow record1 = insertRecord("id1", "par2", 1); + HoodieFlinkInternalRow record2 = insertRecord("id1", "par3", 2); + + testHarness.processElement(new StreamRecord<>(record1)); + testHarness.processElement(new StreamRecord<>(record2)); + testHarness.prepareSnapshotPreBarrier(1L); + + List output = testHarness.extractOutputValues(); + assertEquals(4, output.size(), "Two cross-partition updates should each emit delete and insert records"); + + HoodieFlinkInternalRow deleteForOriginalPartition = output.get(0); + HoodieFlinkInternalRow insertForFirstUpdate = output.get(1); + HoodieFlinkInternalRow deleteForFirstUpdate = output.get(2); + HoodieFlinkInternalRow insertForSecondUpdate = output.get(3); + + assertEquals("par1", deleteForOriginalPartition.getPartitionPath(), + "First update should delete the location prefetched from the metadata table"); + assertEquals("-U", deleteForOriginalPartition.getOperationType()); + assertEquals("U", deleteForOriginalPartition.getInstantTime()); + + assertEquals("par2", insertForFirstUpdate.getPartitionPath()); + assertEquals("I", insertForFirstUpdate.getOperationType()); + assertEquals("U", insertForFirstUpdate.getInstantTime()); + assertTrue(insertForFirstUpdate.getFileId() != null && !insertForFirstUpdate.getFileId().isEmpty(), + "First update should be assigned to a data bucket"); + + assertEquals("par2", deleteForFirstUpdate.getPartitionPath(), + "Duplicate key should re-read the location updated by the preceding record in the same minibatch"); + assertEquals(insertForFirstUpdate.getFileId(), deleteForFirstUpdate.getFileId(), + "The delete for the second update should target the bucket assigned to the first update"); + assertEquals("-U", deleteForFirstUpdate.getOperationType()); + assertEquals("U", deleteForFirstUpdate.getInstantTime()); + + assertEquals("par3", insertForSecondUpdate.getPartitionPath()); + assertEquals("I", insertForSecondUpdate.getOperationType()); + assertEquals("U", insertForSecondUpdate.getInstantTime()); + assertTrue(insertForSecondUpdate.getFileId() != null && !insertForSecondUpdate.getFileId().isEmpty(), + "Second update should be assigned to a data bucket"); + } + + @Test + public void testDuplicateKeyInSameMinibatch() throws Exception { + HoodieFlinkInternalRow record1 = insertRecord("new_duplicate_key", "par_insert", 1); + HoodieFlinkInternalRow record2 = insertRecord("new_duplicate_key", "par_insert", 2); + + testHarness.processElement(new StreamRecord<>(record1)); + testHarness.processElement(new StreamRecord<>(record2)); + testHarness.prepareSnapshotPreBarrier(1L); + + List output = testHarness.extractOutputValues(); + assertEquals(2, output.size(), "Duplicate insert-miss records should both be emitted"); + + HoodieFlinkInternalRow insertRecord = output.get(0); + HoodieFlinkInternalRow duplicateRecord = output.get(1); + + assertEquals("new_duplicate_key", insertRecord.getRecordKey()); + assertEquals("par_insert", insertRecord.getPartitionPath()); + assertEquals("I", insertRecord.getInstantTime(), + "The first record should be assigned as an insert because the prefetched location is missing"); + assertEquals("I", insertRecord.getOperationType()); + assertTrue(insertRecord.getFileId() != null && !insertRecord.getFileId().isEmpty(), + "First insert should be assigned to a data bucket"); + + assertEquals("new_duplicate_key", duplicateRecord.getRecordKey()); + assertEquals("par_insert", duplicateRecord.getPartitionPath()); + assertEquals(insertRecord.getFileId(), duplicateRecord.getFileId(), + "Duplicate key should re-read the location written by the first insert in the same minibatch"); + assertEquals("U", duplicateRecord.getInstantTime(), + "The duplicate record should be assigned as an update to the first record's bucket"); + assertEquals("I", duplicateRecord.getOperationType()); + } + @Test public void testCloseFunction() throws Exception { // Test that close doesn't throw exceptions HoodieFlinkInternalRow record = new HoodieFlinkInternalRow("id1", "par1", "I", insertRow(StringData.fromString("id1"), StringData.fromString("Danny"), 23, TimestampData.fromEpochMillis(1), StringData.fromString("par1"))); testHarness.processElement(new StreamRecord<>(record)); - + // Close should not throw any exceptions testHarness.close(); } + + private static HoodieFlinkInternalRow insertRecord(String recordKey, String partitionPath, long ts) { + return new HoodieFlinkInternalRow(recordKey, partitionPath, "I", + insertRow(StringData.fromString(recordKey), StringData.fromString("Danny"), 23, + TimestampData.fromEpochMillis(ts), StringData.fromString(partitionPath))); + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestRecordIndexPartitioner.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestRecordIndexPartitioner.java index 59cd3d93fb0be..e8b6c757ba771 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestRecordIndexPartitioner.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestRecordIndexPartitioner.java @@ -97,6 +97,7 @@ void testSinglePartition() throws Exception { private RecordIndexPartitioner newPartitioner() throws Exception { Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.RECORD_LEVEL_INDEX.name()); + conf.setString(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key(), "true"); conf.setString(HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(), String.valueOf(FILE_GROUP_COUNT)); StreamerUtil.initTableIfNotExists(conf); return new RecordIndexPartitioner(conf); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java index 3b3f72a9fcb56..1241b720af703 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java @@ -44,7 +44,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.apache.hudi.common.model.HoodieTableType.COPY_ON_WRITE; import static org.mockito.Mockito.mock; @@ -81,10 +80,12 @@ void testRecordLevelIndexBackend() throws Exception { assertEquals("par1", location.getPartitionPath()); assertEquals(firstCommitTime, location.getInstantTime()); + HoodieRecordGlobalLocation location1 = globalRecordLevelIndexBackend.get("id1"); + assertEquals(location1, location); + // get record location with non existed key location = globalRecordLevelIndexBackend.get(Collections.singletonList("new_key")).get("new_key"); assertNull(location); - assertThrows(UnsupportedOperationException.class, () -> globalRecordLevelIndexBackend.get("id1")); // get records locations for multiple record keys Map locations = globalRecordLevelIndexBackend.get(Arrays.asList("id1", "id2", "id3")); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java index 26f79d870d664..1d5cc2fc9a97e 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/BulkInsertFunctionWrapper.java @@ -24,6 +24,7 @@ import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.index.bucket.partition.NumBucketsFunction; import org.apache.hudi.sink.StreamWriteOperatorCoordinator; import org.apache.hudi.sink.bucket.BucketBulkInsertWriterHelper; import org.apache.hudi.sink.bulk.BulkInsertWriteFunction; @@ -167,9 +168,7 @@ public void checkpointComplete(long checkpointId) { } public void coordinatorFails() throws Exception { - this.coordinator.close(); - this.coordinator.start(); - this.coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext)); + // Do nothing since there is no state recovery for bulk insert. } public void restartCoordinator() throws Exception { @@ -215,10 +214,12 @@ private void setupWriteFunction() throws Exception { private void setupMapFunction() { RowDataKeyGen keyGen = RowDataKeyGens.instance(conf, rowType); - String indexKeys = OptionsResolver.getIndexKeyField(conf); + List indexKeyFieldList = OptionsResolver.getIndexKeyFields(conf); + NumBucketsFunction numBucketsFunction = new NumBucketsFunction(conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_EXPRESSIONS), + conf.get(FlinkOptions.BUCKET_INDEX_PARTITION_RULE), conf.get(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS)); boolean needFixedFileIdSuffix = OptionsResolver.isNonBlockingConcurrencyControl(conf); this.bucketIdToFileId = new HashMap<>(); - this.mapFunction = r -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, r, indexKeys, conf, needFixedFileIdSuffix); + this.mapFunction = r -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, r, indexKeyFieldList, numBucketsFunction, needFixedFileIdSuffix); } private void setupSortOperator() throws Exception { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/InsertFunctionWrapper.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/InsertFunctionWrapper.java index 8c0369a889c77..9a8262155973a 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/InsertFunctionWrapper.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/InsertFunctionWrapper.java @@ -49,6 +49,8 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.logical.RowType; +import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; /** @@ -70,6 +72,7 @@ public class InsertFunctionWrapper implements TestFunctionWrapper { private final boolean asyncClustering; private ClusteringFunctionWrapper clusteringFunctionWrapper; + private final TreeMap coordinatorStateStore; /** * Append write function. @@ -97,6 +100,7 @@ public InsertFunctionWrapper(String tablePath, Configuration conf, ExecutionConf this.coordinatorContext = new MockOperatorCoordinatorContext(new OperatorID(), 1); this.coordinator = new StreamWriteOperatorCoordinator(conf, this.coordinatorContext); this.stateInitializationContext = new MockStateInitializationContext(); + this.coordinatorStateStore = new TreeMap<>(); this.asyncClustering = OptionsResolver.needsAsyncClustering(conf); StreamConfig streamConfig = new StreamConfig(conf); @@ -142,8 +146,10 @@ public OperatorEvent getNextSubTaskEvent() { } public void checkpointFunction(long checkpointId) throws Exception { + CompletableFuture completableFuture = new CompletableFuture<>(); // checkpoint the coordinator first - this.coordinator.checkpointCoordinator(checkpointId, new CompletableFuture<>()); + this.coordinator.checkpointCoordinator(checkpointId, completableFuture); + this.coordinatorStateStore.put(checkpointId, completableFuture.get()); writeFunction.snapshotState(new MockFunctionSnapshotContext(checkpointId)); stateInitializationContext.checkpointBegin(checkpointId); @@ -167,9 +173,15 @@ public void checkpointComplete(long checkpointId) { } public void coordinatorFails() throws Exception { - this.coordinator.close(); - this.coordinator.start(); - this.coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext)); + resetCoordinatorToCheckpoint(); + } + + private void resetCoordinatorToCheckpoint() { + if (coordinatorStateStore.isEmpty()) { + return; + } + Map.Entry latestState = this.coordinatorStateStore.lastEntry(); + this.coordinator.resetToCheckpoint(latestState.getKey(), latestState.getValue()); } public void restartCoordinator() throws Exception { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/StreamWriteFunctionWrapper.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/StreamWriteFunctionWrapper.java index 7b1684c8e629e..131c14602cdef 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/StreamWriteFunctionWrapper.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/StreamWriteFunctionWrapper.java @@ -368,13 +368,7 @@ public void jobFailover() throws Exception { } public void coordinatorFails() throws Exception { - this.coordinator.close(); - if (isStreamingWriteIndexEnabled) { - this.coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext)); - } resetCoordinatorToCheckpoint(); - this.coordinator.start(); - this.coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext)); } public void restartCoordinator() throws Exception { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestHiveSyncContext.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestHiveSyncContext.java index f246662b5efef..e15135d871c4c 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestHiveSyncContext.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestHiveSyncContext.java @@ -28,6 +28,8 @@ import java.util.Properties; +import static org.apache.hudi.common.config.HoodieCommonConfig.BASE_PATH; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_BASE_PATH; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_FIELDS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -58,6 +60,21 @@ void testSyncedPartitions() { assertEquals(partitionPathField, props2.getProperty(META_SYNC_PARTITION_FIELDS.key())); } + /** + * Test table path syncs to both canonical and meta sync base path configs. + */ + @Test + void testSyncedBasePath() { + Configuration configuration = new Configuration(); + String basePath = "/tmp/hudi_table"; + configuration.set(FlinkOptions.PATH, basePath); + + Properties props = HiveSyncContext.buildSyncConfig(configuration); + + assertEquals(basePath, props.getProperty(BASE_PATH.key())); + assertEquals(basePath, props.getProperty(META_SYNC_BASE_PATH.key())); + } + /** * Test an option that has no shortcut key. */ diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestHoodieSource.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestHoodieSource.java index 2a8956e20f7e8..516d5e7c2da55 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestHoodieSource.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestHoodieSource.java @@ -413,18 +413,18 @@ private HoodieSource createHoodieSourceWithPruner( .build(); HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType); HadoopStorageConfiguration hadoopConf = new HadoopStorageConfiguration(HadoopConfigurations.getHadoopConf(conf)); - HoodieSplitReaderFunction splitReaderFunction = new HoodieSplitReaderFunction( - conf, - schema, // schema will be resolved from table - schema, // required schema - InternalSchemaManager.get(hadoopConf, this.metaClient), - conf.get(FlinkOptions.MERGE_TYPE), - Collections.emptyList(), - false); + InternalSchemaManager internalSchemaManager = InternalSchemaManager.get(hadoopConf, this.metaClient); return new HoodieSource<>( scanContext, - splitReaderFunction, + () -> new HoodieSplitReaderFunction( + conf, + schema, // schema will be resolved from table + schema, // required schema + internalSchemaManager, + conf.get(FlinkOptions.MERGE_TYPE), + Collections.emptyList(), + false), new HoodieSourceSplitComparator(), metaClient, new HoodieRecordEmitter<>()); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestIncrementalInputSplits.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestIncrementalInputSplits.java index deaa492ce15dd..ce97b2e70f753 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestIncrementalInputSplits.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestIncrementalInputSplits.java @@ -438,11 +438,11 @@ void testInputSplitsWithSpeedLimit() throws Exception { IncrementalInputSplits.Result result = iis.inputSplits(metaClient, firstInstant.getCompletionTime(), false); String minStartCommit = result.getInputSplits().stream() - .map(split -> split.getInstantRange().get().getStartInstant().get()) + .map(split -> split.getInstantRange().get().getStartInstantOpt().get()) .min((commit1,commit2) -> compareTimestamps(commit1, LESSER_THAN, commit2) ? 1 : 0) .orElse(null); String maxEndCommit = result.getInputSplits().stream() - .map(split -> split.getInstantRange().get().getEndInstant().get()) + .map(split -> split.getInstantRange().get().getEndInstantOpt().get()) .max((commit1,commit2) -> compareTimestamps(commit1, GREATER_THAN, commit2) ? 1 : 0) .orElse(null); assertEquals(0, intervalBetween2Instants(commitsTimeline, minStartCommit, maxEndCommit), "Should read 1 instant"); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadMonitoringFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadMonitoringFunction.java index ec6f3b863cafb..bb28ee92e2dff 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadMonitoringFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadMonitoringFunction.java @@ -505,8 +505,8 @@ public void testCheckpointRestoreWithLimit() throws Exception { private static boolean isPointInstantRange(InstantRange instantRange, String timestamp) { return instantRange != null - && Objects.equals(timestamp, instantRange.getStartInstant().get()) - && Objects.equals(timestamp, instantRange.getEndInstant().get()); + && Objects.equals(timestamp, instantRange.getStartInstantOpt().get()) + && Objects.equals(timestamp, instantRange.getEndInstantOpt().get()); } private AbstractStreamOperatorTestHarness createHarness( diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.java new file mode 100644 index 0000000000000..3b129099a2a3b --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.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.hudi.source.enumerator; + +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; +import org.apache.hudi.source.HoodieScanContext; +import org.apache.hudi.source.HoodieSource; +import org.apache.hudi.source.reader.HoodieRecordEmitter; +import org.apache.hudi.source.reader.function.HoodieSplitReaderFunction; +import org.apache.hudi.source.split.DefaultHoodieSplitProvider; +import org.apache.hudi.source.split.GlobalHoodieSplitProvider; +import org.apache.hudi.source.split.HoodieCdcSourceSplit; +import org.apache.hudi.source.split.HoodieSourceSplit; +import org.apache.hudi.source.split.HoodieSourceSplitComparator; +import org.apache.hudi.source.split.HoodieSourceSplitState; +import org.apache.hudi.source.split.HoodieSourceSplitStatus; +import org.apache.hudi.source.split.HoodieSplitProvider; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; +import org.apache.hudi.table.format.InternalSchemaManager; +import org.apache.hudi.util.HoodieSchemaConverter; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.TestConfigurations; +import org.apache.hudi.utils.TestData; + +import org.apache.flink.api.connector.source.ReaderInfo; +import org.apache.flink.api.connector.source.SourceEvent; +import org.apache.flink.api.connector.source.SplitEnumerator; +import org.apache.flink.api.connector.source.SplitEnumeratorContext; +import org.apache.flink.api.connector.source.SplitsAssignment; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.metrics.groups.SplitEnumeratorMetricGroup; +import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +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.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests which split provider {@link HoodieSource} wires into the enumerator. + * + *

    Bounded reads use the shared work-stealing pool ({@link GlobalHoodieSplitProvider}); streaming + * keeps per-subtask assignment ({@link DefaultHoodieSplitProvider}) so that a file id's successive + * incremental splits stay affine to one reader. Because {@code HoodieSource.createEnumerator} + * handles fresh creation and restore in the same method, both paths are covered for every mode. + * + *

    These tests also assert the property that makes work stealing safe for bounded reads: every + * bounded query mode emits exactly one split per file group, so there is no cross-commit + * continuation and no ordering relationship between splits that a shared pool could break. + * + *

    Lives in the enumerator package so it can read the package-private + * {@link AbstractHoodieSplitEnumerator#splitProvider}. + */ +public class TestHoodieSourceEnumeratorRouting { + + @TempDir + File tempDir; + + private Configuration conf; + private StoragePath tablePath; + private HoodieTableMetaClient metaClient; + + /** + * The bounded query modes {@code HoodieSource.createBatchHoodieSplits()} covers. All of them are + * routed to the shared pool, so all of them are exercised here. + * + *

    Incremental appears three times on purpose. {@code IncrementalInputSplits.inputSplits()} + * branches on {@code fullTableScan}, which is true when the query consumes from the earliest + * instant, and the two sides build their file slice set differently: the full scan lists the + * table directly, while the other side derives partitions and files from the commit metadata of + * the instants in range (and, when CDC is on, leaves through the CDC extractor entirely). Only + * covering {@code earliest} would leave the metadata-driven branch untested. + */ + private enum BoundedMode { + COW_SNAPSHOT(HoodieTableType.COPY_ON_WRITE, FlinkOptions.QUERY_TYPE_SNAPSHOT, false, IncrementalStart.NOT_INCREMENTAL), + MOR_SNAPSHOT(HoodieTableType.MERGE_ON_READ, FlinkOptions.QUERY_TYPE_SNAPSHOT, false, IncrementalStart.NOT_INCREMENTAL), + MOR_READ_OPTIMIZED(HoodieTableType.MERGE_ON_READ, FlinkOptions.QUERY_TYPE_READ_OPTIMIZED, false, IncrementalStart.NOT_INCREMENTAL), + COW_INCREMENTAL(HoodieTableType.COPY_ON_WRITE, FlinkOptions.QUERY_TYPE_INCREMENTAL, false, IncrementalStart.LAST_COMMIT), + COW_INCREMENTAL_FROM_EARLIEST(HoodieTableType.COPY_ON_WRITE, FlinkOptions.QUERY_TYPE_INCREMENTAL, false, IncrementalStart.EARLIEST), + COW_INCREMENTAL_CDC(HoodieTableType.COPY_ON_WRITE, FlinkOptions.QUERY_TYPE_INCREMENTAL, true, IncrementalStart.LAST_COMMIT); + + private final HoodieTableType tableType; + private final String queryType; + private final boolean cdcEnabled; + private final IncrementalStart incrementalStart; + + BoundedMode(HoodieTableType tableType, String queryType, boolean cdcEnabled, IncrementalStart incrementalStart) { + this.tableType = tableType; + this.queryType = queryType; + this.cdcEnabled = cdcEnabled; + this.incrementalStart = incrementalStart; + } + + boolean isIncremental() { + return incrementalStart != IncrementalStart.NOT_INCREMENTAL; + } + } + + /** + * Where an incremental mode starts reading, which is what decides the {@code fullTableScan} + * branch: {@code earliest} leaves {@code startInstant} empty and takes the full scan, + * a real completion time takes the metadata-driven branch. + */ + private enum IncrementalStart { + NOT_INCREMENTAL, + EARLIEST, + LAST_COMMIT + } + + @BeforeEach + public void setUp() { + conf = TestConfigurations.getDefaultConf(tempDir.getAbsolutePath()); + tablePath = new StoragePath(tempDir.getAbsolutePath()); + } + + @ParameterizedTest + @EnumSource(BoundedMode.class) + public void testBoundedReadUsesSharedSplitPool(BoundedMode mode) throws Exception { + HoodieSource source = prepareBoundedSource(mode); + MockSplitEnumeratorContext context = new MockSplitEnumeratorContext(); + + SplitEnumerator enumerator = + source.createEnumerator(context); + + assertInstanceOf(HoodieStaticSplitEnumerator.class, enumerator, + "Bounded read should use the static enumerator for mode " + mode); + assertInstanceOf(GlobalHoodieSplitProvider.class, providerOf(enumerator), + "Bounded read should use the shared work-stealing pool for mode " + mode); + List splits = pendingSplits(enumerator); + assertOneSplitPerFileGroup(splits, mode); + if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) { + // Guards the parameterization: if the start commit stopped making fullTableScan false, these + // splits would come from a full table listing and cover par1 through par6, and this mode + // would silently stop exercising the metadata-driven branch. + assertEquals(new HashSet<>(Arrays.asList("par5", "par6")), + splits.stream().map(HoodieSourceSplit::getPartitionPath).collect(Collectors.toSet()), + "Mode " + mode + " should read only the partitions written by the start commit, " + + "which is what distinguishes the incremental branch from a full table scan"); + } + if (mode.cdcEnabled) { + // Likewise, a full table scan would bypass the CDC extractor and yield plain splits. + splits.forEach(split -> assertInstanceOf(HoodieCdcSourceSplit.class, split, + "CDC mode should produce CDC splits")); + } + } + + @ParameterizedTest + @EnumSource(BoundedMode.class) + public void testBoundedRestoreKeepsSharedSplitPool(BoundedMode mode) throws Exception { + HoodieSource source = prepareBoundedSource(mode); + // Snapshot the real splits of this mode, then restore from a subset of them. + List discovered = + pendingSplits(source.createEnumerator(new MockSplitEnumeratorContext())); + assertFalse(discovered.isEmpty(), "Expected at least one split for mode " + mode); + List checkpointed = discovered.subList(0, 1); + + MockSplitEnumeratorContext context = new MockSplitEnumeratorContext(); + SplitEnumerator restored = + source.restoreEnumerator(context, enumeratorStateOf(checkpointed)); + + assertInstanceOf(HoodieStaticSplitEnumerator.class, restored, + "Restored bounded read should still use the static enumerator for mode " + mode); + assertInstanceOf(GlobalHoodieSplitProvider.class, providerOf(restored), + "Restored bounded read should still use the shared work-stealing pool for mode " + mode); + assertEquals(checkpointed.size(), providerOf(restored).pendingSplitCount(), + "Restore should replay exactly the checkpointed splits into the shared pool " + + "and must not re-run split discovery for mode " + mode); + } + + /** + * A restored pending split is not owned by any subtask: whichever reader asks for work claims it. + * Parameterized over the requesting subtask so the assertion is deterministic - under per-subtask + * pinning only the one subtask the file id hashes to could ever receive it. + */ + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3}) + public void testRestoredSplitIsClaimedByWhicheverSubtaskAsks(int requestingSubtask) throws Exception { + HoodieSource source = prepareBoundedSource(BoundedMode.COW_SNAPSHOT); + List discovered = + pendingSplits(source.createEnumerator(new MockSplitEnumeratorContext())); + List checkpointed = discovered.subList(0, 1); + + MockSplitEnumeratorContext context = new MockSplitEnumeratorContext(); + SplitEnumerator restored = + source.restoreEnumerator(context, enumeratorStateOf(checkpointed)); + restored.start(); + for (int subtask = 0; subtask < 4; subtask++) { + context.registerReader(new ReaderInfo(subtask, "localhost")); + } + + restored.handleSplitRequest(requestingSubtask, "localhost"); + + assertEquals(checkpointed, context.getAssignedSplits().get(requestingSubtask), + "The restored split should go to whichever subtask asked for work"); + assertFalse(context.getNoMoreSplitsSignaled().contains(requestingSubtask), + "The requesting subtask received a split, so it should not be told no-more-splits"); + } + + @Test + public void testStreamingReadKeepsPerSubtaskProvider() throws Exception { + HoodieSource source = prepareStreamingSource(); + + SplitEnumerator enumerator = + source.createEnumerator(new MockSplitEnumeratorContext()); + + assertInstanceOf(HoodieContinuousSplitEnumerator.class, enumerator, + "Streaming read should use the continuous enumerator"); + assertInstanceOf(DefaultHoodieSplitProvider.class, providerOf(enumerator), + "Streaming read must keep per-subtask assignment for file id affinity"); + } + + @Test + public void testStreamingRestoreKeepsPerSubtaskProvider() throws Exception { + HoodieSource source = prepareStreamingSource(); + List checkpointed = Collections.singletonList( + new HoodieSourceSplit(0, null, Option.empty(), tablePath.toString(), "par1", + FlinkOptions.REALTIME_PAYLOAD_COMBINE, "20260126034717000", "file-0", Option.empty())); + + SplitEnumerator restored = + source.restoreEnumerator(new MockSplitEnumeratorContext(), enumeratorStateOf(checkpointed)); + + assertInstanceOf(HoodieContinuousSplitEnumerator.class, restored, + "Restored streaming read should use the continuous enumerator"); + assertInstanceOf(DefaultHoodieSplitProvider.class, providerOf(restored), + "Restored streaming read must keep per-subtask assignment"); + assertEquals(1, providerOf(restored).pendingSplitCount(), + "Restored split should be replayed into the per-subtask provider"); + } + + // Helper methods + + private static HoodieSplitProvider providerOf( + SplitEnumerator enumerator) { + return ((AbstractHoodieSplitEnumerator) enumerator).splitProvider; + } + + private static List pendingSplits( + SplitEnumerator enumerator) { + return providerOf(enumerator).state().stream() + .map(HoodieSourceSplitState::getSplit) + .collect(Collectors.toList()); + } + + private static HoodieSplitEnumeratorState enumeratorStateOf(List splits) { + List states = splits.stream() + .map(split -> new HoodieSourceSplitState(split, HoodieSourceSplitStatus.UNASSIGNED)) + .collect(Collectors.toList()); + return new HoodieSplitEnumeratorState(states, Option.empty(), Option.empty()); + } + + /** + * Asserts the invariant that makes a shared pool safe for a bounded read: one split per file + * group, hence no cross-commit continuation and no ordering relationship between splits. + */ + private static void assertOneSplitPerFileGroup(List splits, BoundedMode mode) { + assertFalse(splits.isEmpty(), "Expected at least one split for mode " + mode); + Set fileIds = splits.stream() + .map(HoodieSourceSplit::getFileId) + .collect(Collectors.toSet()); + assertEquals(splits.size(), fileIds.size(), + "Mode " + mode + " must emit exactly one split per file group, otherwise splits of the " + + "same file group could be read concurrently by different readers"); + } + + private HoodieSource prepareBoundedSource(BoundedMode mode) throws Exception { + conf.set(FlinkOptions.TABLE_TYPE, mode.tableType.name()); + conf.set(FlinkOptions.READ_AS_STREAMING, false); + if (mode.tableType == HoodieTableType.MERGE_ON_READ) { + // Compact the first commit so the MOR file groups own a base file (a read-optimized read + // sees nothing otherwise); the second commit below is then written as logs only, so a + // snapshot read exercises real base + log file slices. + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + } + if (mode.cdcEnabled) { + conf.set(FlinkOptions.CDC_ENABLED, true); + conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); // for batch update + } + + TestData.writeData(TestData.DATA_SET_INSERT, conf); + if (mode.tableType == HoodieTableType.MERGE_ON_READ) { + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, false); + } + TestData.writeData(TestData.DATA_SET_UPDATE_INSERT, conf); + if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) { + // A last commit that only touches par5 and par6, so the partitions of the resulting splits + // show which branch produced them: the metadata-driven branch derives its read partitions + // from this commit alone, a full table scan would list par1 through par6. + TestData.writeData(TestData.DATA_SET_INSERT_SEPARATE_PARTITION, conf); + } + metaClient = StreamerUtil.createMetaClient(conf); + + conf.set(FlinkOptions.QUERY_TYPE, mode.queryType); + if (mode.incrementalStart == IncrementalStart.EARLIEST) { + conf.set(FlinkOptions.READ_START_COMMIT, FlinkOptions.START_COMMIT_EARLIEST); + } else if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) { + conf.set(FlinkOptions.READ_START_COMMIT, lastCompletionTime()); + } + return createSource(); + } + + private HoodieSource prepareStreamingSource() throws Exception { + conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name()); + conf.set(FlinkOptions.READ_AS_STREAMING, true); + + TestData.writeData(TestData.DATA_SET_INSERT, conf); + metaClient = StreamerUtil.createMetaClient(conf); + + return createSource(); + } + + private String lastCompletionTime() { + List commits = metaClient.getCommitsTimeline().filterCompletedInstants() + .getInstantsAsStream() + .map(HoodieInstant::getCompletionTime) + .collect(Collectors.toList()); + assertTrue(commits.size() > 1, "Expected more than one commit to read changes from"); + return commits.get(commits.size() - 1); + } + + private HoodieSource createSource() { + RowType rowType = TestConfigurations.ROW_TYPE; + HoodieScanContext scanContext = HoodieScanContext.builder() + .conf(conf) + .path(tablePath) + .rowType(rowType) + .startInstant(conf.get(FlinkOptions.READ_START_COMMIT)) + .endInstant(conf.get(FlinkOptions.READ_END_COMMIT)) + .maxCompactionMemoryInBytes(conf.get(FlinkOptions.COMPACTION_MAX_MEMORY)) + .maxPendingSplits(1000) + .skipCompaction(conf.get(FlinkOptions.READ_STREAMING_SKIP_COMPACT)) + .skipClustering(conf.get(FlinkOptions.READ_STREAMING_SKIP_CLUSTERING)) + .skipInsertOverwrite(conf.get(FlinkOptions.READ_STREAMING_SKIP_INSERT_OVERWRITE)) + .cdcEnabled(conf.get(FlinkOptions.CDC_ENABLED)) + .isStreaming(conf.get(FlinkOptions.READ_AS_STREAMING)) + .build(); + HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType); + HadoopStorageConfiguration hadoopConf = + new HadoopStorageConfiguration(HadoopConfigurations.getHadoopConf(conf)); + InternalSchemaManager internalSchemaManager = InternalSchemaManager.get(hadoopConf, metaClient); + + return new HoodieSource<>( + scanContext, + () -> new HoodieSplitReaderFunction( + conf, + schema, + schema, + internalSchemaManager, + conf.get(FlinkOptions.MERGE_TYPE), + Collections.emptyList(), + false), + new HoodieSourceSplitComparator(), + metaClient, + new HoodieRecordEmitter<>()); + } + + /** + * Minimal mock of {@link SplitEnumeratorContext} for the wiring assertions above. + */ + private static class MockSplitEnumeratorContext implements SplitEnumeratorContext { + private final Map registeredReaders = new HashMap<>(); + private final Map> assignedSplits = new HashMap<>(); + private final List noMoreSplitsSignaled = new ArrayList<>(); + + void registerReader(ReaderInfo readerInfo) { + registeredReaders.put(readerInfo.getSubtaskId(), readerInfo); + } + + Map> getAssignedSplits() { + return assignedSplits; + } + + List getNoMoreSplitsSignaled() { + return noMoreSplitsSignaled; + } + + @Override + public SplitEnumeratorMetricGroup metricGroup() { + return UnregisteredMetricsGroup.createSplitEnumeratorMetricGroup(); + } + + @Override + public void sendEventToSourceReader(int subtaskId, SourceEvent event) { + // No-op for testing + } + + @Override + public int currentParallelism() { + return Math.max(registeredReaders.size(), 1); + } + + @Override + public Map registeredReaders() { + return new HashMap<>(registeredReaders); + } + + @Override + public void assignSplits(SplitsAssignment newSplitAssignments) { + newSplitAssignments.assignment().forEach((subtask, splits) -> + assignedSplits.computeIfAbsent(subtask, k -> new ArrayList<>()).addAll(splits)); + } + + @Override + public void assignSplit(HoodieSourceSplit split, int subtask) { + assignedSplits.computeIfAbsent(subtask, k -> new ArrayList<>()).add(split); + } + + @Override + public void signalNoMoreSplits(int subtask) { + noMoreSplitsSignaled.add(subtask); + } + + @Override + public void callAsync(Callable callable, BiConsumer handler) { + // No-op: split discovery is not exercised by these wiring tests. + } + + @Override + public void callAsync(Callable callable, BiConsumer handler, long initialDelay, long period) { + // No-op: split discovery is not exercised by these wiring tests. + } + + @Override + public void runInCoordinatorThread(Runnable runnable) { + runnable.run(); + } + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java index 4d081eb8b7587..714ce3d90380e 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.source.split.assign.HoodieSplitNumberAssigner; import org.apache.hudi.source.split.DefaultHoodieSplitProvider; +import org.apache.hudi.source.split.GlobalHoodieSplitProvider; import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.source.split.SplitRequestEvent; @@ -43,6 +44,7 @@ import java.util.function.BiConsumer; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -219,6 +221,90 @@ public void testHandleSourceEventWithAttemptNumber() { assertTrue(context.getAssignedSplits().size() > 0, "Should assign split via attempt-aware method"); } + @Test + public void testGlobalProviderWorkStealingAcrossSubtasks() { + // With the shared work-stealing pool a single reader can drain every split: the enumerator no + // longer pins splits to a subtask. DefaultHoodieSplitProvider with a number/hash assigner would + // hand most of these to other subtasks and starve reader 0. + GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider(); + HoodieStaticSplitEnumerator globalEnumerator = + new HoodieStaticSplitEnumerator("test-table", context, globalProvider); + globalProvider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + globalEnumerator.start(); + + context.registerReader(new ReaderInfo(0, "localhost")); + context.registerReader(new ReaderInfo(1, "localhost")); + + // Reader 0 keeps finishing and asking for more; it takes all three splits by itself. + globalEnumerator.handleSplitRequest(0, "localhost"); + globalEnumerator.handleSplitRequest(0, "localhost"); + globalEnumerator.handleSplitRequest(0, "localhost"); + + assertEquals(3, context.getAssignedSplits().get(0).size(), + "A single reader should be able to steal the entire pool"); + assertFalse(context.getNoMoreSplitsSignaled().contains(0), + "No-more-splits must not fire while the pool still had splits"); + } + + @Test + public void testGlobalProviderSignalsNoMoreSplitsOnlyWhenPoolEmpty() { + GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider(); + HoodieStaticSplitEnumerator globalEnumerator = + new HoodieStaticSplitEnumerator("test-table", context, globalProvider); + globalProvider.onDiscoveredSplits(Collections.singletonList(split1)); // one split, two readers + globalEnumerator.start(); + + context.registerReader(new ReaderInfo(0, "localhost")); + context.registerReader(new ReaderInfo(1, "localhost")); + + globalEnumerator.handleSplitRequest(0, "localhost"); // reader 0 takes the only split + globalEnumerator.handleSplitRequest(1, "localhost"); // reader 1 finds the shared pool empty + + assertTrue(context.getAssignedSplits().containsKey(0), "Reader 0 should receive the split"); + assertFalse(context.getNoMoreSplitsSignaled().contains(0), + "Reader 0 got a split, so it should not be told no-more-splits"); + assertTrue(context.getNoMoreSplitsSignaled().contains(1), + "Reader 1 should be told no-more-splits once the shared pool is drained"); + } + + @Test + public void testGlobalProviderAddSplitsBackAfterOtherReadersGotNoMoreSplits() { + // Failure recovery once the pool has already been drained and some readers have finished: + // the split a failed reader hands back must land in the shared pool and stay claimable by a + // subtask that is neither the failed one nor an already-finished one. Under per-subtask + // pinning it would instead be re-pinned to hash(fileId), possibly a reader that is already + // done, and never be read. + GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider(); + HoodieStaticSplitEnumerator globalEnumerator = + new HoodieStaticSplitEnumerator("test-table", context, globalProvider); + globalProvider.onDiscoveredSplits(Collections.singletonList(split1)); + globalEnumerator.start(); + + context.registerReader(new ReaderInfo(0, "localhost")); + context.registerReader(new ReaderInfo(1, "localhost")); + context.registerReader(new ReaderInfo(2, "localhost")); + + globalEnumerator.handleSplitRequest(0, "localhost"); // reader 0 takes the only split + globalEnumerator.handleSplitRequest(1, "localhost"); // pool is drained, reader 1 finishes + assertTrue(context.getNoMoreSplitsSignaled().contains(1), + "Reader 1 should already have been told no-more-splits"); + + // Reader 0 fails mid-split and its split is returned. + context.unregisterReader(0); + globalEnumerator.addSplitsBack(Collections.singletonList(split1), 0); + assertEquals(1, globalProvider.pendingSplitCount(), + "Returned split should be back in the shared pool"); + + // Reader 2, which has neither failed nor finished, claims it. + globalEnumerator.handleSplitRequest(2, "localhost"); + + assertEquals(Collections.singletonList(split1), context.getAssignedSplits().get(2), + "A different, still-running subtask should claim the returned split"); + assertFalse(context.getNoMoreSplitsSignaled().contains(2), + "Reader 2 got the returned split, so it should not be told no-more-splits"); + assertEquals(0, globalProvider.pendingSplitCount(), "Pool should be drained again"); + } + private HoodieSourceSplit createTestSplit(int splitNum, String fileId) { return new HoodieSourceSplit( splitNum, diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java index 69af5702f1f42..5b111dddcc035 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java @@ -18,14 +18,11 @@ package org.apache.hudi.source.reader; -import org.apache.hudi.common.util.collection.ClosableIterator; - import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Set; @@ -36,16 +33,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Test cases for {@link BatchRecords}. + * Test cases for {@link BatchRecords}, which now holds a materialized, bounded minibatch of records. */ public class TestBatchRecords { @Test - public void testForRecordsWithEmptyIterator() { + public void testForRecordsWithEmptyList() { String splitId = "test-split-1"; - ClosableIterator emptyIterator = createClosableIterator(Collections.emptyList()); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, emptyIterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, Collections.emptyList(), 0, 0L); assertNotNull(batchRecords); assertEquals(splitId, batchRecords.nextSplit()); @@ -57,9 +53,8 @@ public void testForRecordsWithEmptyIterator() { public void testForRecordsWithMultipleRecords() { String splitId = "test-split-2"; List records = Arrays.asList("record1", "record2", "record3"); - ClosableIterator iterator = createClosableIterator(records); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, 0, 0L); // Verify split ID assertEquals(splitId, batchRecords.nextSplit()); @@ -86,46 +81,13 @@ public void testForRecordsWithMultipleRecords() { assertNull(batchRecords.nextRecordFromSplit()); } - @Test - public void testSeekToStartingOffset() { - String splitId = "test-split-3"; - List records = Arrays.asList("record1", "record2", "record3", "record4", "record5"); - ClosableIterator iterator = createClosableIterator(records); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 2L); - batchRecords.seek(2L); - - // After seeking to offset 2, we should start from record3 - batchRecords.nextSplit(); - - HoodieRecordWithPosition record = batchRecords.nextRecordFromSplit(); - assertNotNull(record); - assertEquals("record3", record.record()); - } - - @Test - public void testSeekBeyondAvailableRecords() { - String splitId = "test-split-4"; - List records = Arrays.asList("record1", "record2"); - ClosableIterator iterator = createClosableIterator(records); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); - - IllegalStateException exception = assertThrows(IllegalStateException.class, () -> { - batchRecords.seek(10L); - }); - - assertTrue(exception.getMessage().contains("Invalid starting record offset")); - } - @Test public void testFileOffsetPersistence() { String splitId = "test-split-5"; int fileOffset = 5; List records = Arrays.asList("record1", "record2"); - ClosableIterator iterator = createClosableIterator(records); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, fileOffset, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, fileOffset, 0L); batchRecords.nextSplit(); HoodieRecordWithPosition record1 = batchRecords.nextRecordFromSplit(); @@ -140,12 +102,11 @@ public void testFileOffsetPersistence() { @Test public void testConstructorWithFinishedSplits() { String splitId = "test-split-7"; - List records = Arrays.asList("record1"); - ClosableIterator iterator = createClosableIterator(records); + List records = Collections.singletonList("record1"); Set finishedSplits = new HashSet<>(Arrays.asList("split1", "split2")); BatchRecords batchRecords = new BatchRecords<>( - splitId, iterator, 0, 0L, finishedSplits); + splitId, records, 0, 0L, finishedSplits); assertEquals(2, batchRecords.finishedSplits().size()); assertTrue(batchRecords.finishedSplits().contains("split1")); @@ -157,10 +118,9 @@ public void testRecordOffsetIncrementsCorrectly() { String splitId = "test-split-8"; long startingRecordOffset = 10L; List records = Arrays.asList("A", "B", "C"); - ClosableIterator iterator = createClosableIterator(records); BatchRecords batchRecords = BatchRecords.forRecords( - splitId, iterator, 0, startingRecordOffset); + splitId, records, 0, startingRecordOffset); batchRecords.nextSplit(); // First record should be at startingRecordOffset + 1 @@ -176,13 +136,37 @@ public void testRecordOffsetIncrementsCorrectly() { assertEquals(startingRecordOffset + 3, record3.recordOffset()); } + @Test + public void testOffsetContinuityAcrossMinibatches() { + // Two consecutive minibatches of the same split: the reader function threads the running + // record offset so that offsets stay monotonic across batch boundaries (batch 2 starts where + // batch 1 left off). This mirrors AbstractSplitReaderFunction#readBatch. + String splitId = "test-split-continuity"; + + BatchRecords batch1 = BatchRecords.forRecords(splitId, Arrays.asList("a", "b", "c"), 0, 0L); + batch1.nextSplit(); + assertEquals(1L, batch1.nextRecordFromSplit().recordOffset()); + assertEquals(2L, batch1.nextRecordFromSplit().recordOffset()); + assertEquals(3L, batch1.nextRecordFromSplit().recordOffset()); + assertNull(batch1.nextRecordFromSplit()); + + // batch 2 opens at startingRecordOffset = 3 (the count already consumed by batch 1) + BatchRecords batch2 = BatchRecords.forRecords(splitId, Arrays.asList("d", "e"), 0, 3L); + batch2.nextSplit(); + HoodieRecordWithPosition d = batch2.nextRecordFromSplit(); + assertEquals("d", d.record()); + assertEquals(4L, d.recordOffset()); + HoodieRecordWithPosition e = batch2.nextRecordFromSplit(); + assertEquals("e", e.record()); + assertEquals(5L, e.recordOffset()); + } + @Test public void testSplitIdReturnedOnlyOnce() { String splitId = "test-split-9"; - List records = Arrays.asList("record1"); - ClosableIterator iterator = createClosableIterator(records); + List records = Collections.singletonList("record1"); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, 0, 0L); assertEquals(splitId, batchRecords.nextSplit()); assertNull(batchRecords.nextSplit()); @@ -191,187 +175,64 @@ public void testSplitIdReturnedOnlyOnce() { } @Test - public void testRecycleClosesIterator() { + public void testRecycleIsNoOp() { + // The minibatch is fully materialized, so recycle() releases nothing and is a harmless no-op: + // it must not throw and must not affect the still-readable records. String splitId = "test-split-10"; List records = Arrays.asList("record1", "record2"); - MockClosableIterator mockIterator = new MockClosableIterator<>(records); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, mockIterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, 0, 0L); + batchRecords.nextSplit(); batchRecords.recycle(); - assertTrue(mockIterator.isClosed(), "Iterator should be closed after recycle"); - } - - @Test - public void testRecycleWithNullIterator() { - // Test that recycle handles null iterator gracefully (though in practice this shouldn't happen) - // This tests the null check in recycle() method - String splitId = "test-split-11"; - ClosableIterator emptyIterator = createClosableIterator(Collections.emptyList()); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, emptyIterator, 0, 0L); - - // Should not throw exception - batchRecords.recycle(); + assertNotNull(batchRecords.nextRecordFromSplit(), "records remain readable after recycle"); + assertNotNull(batchRecords.nextRecordFromSplit()); + assertNull(batchRecords.nextRecordFromSplit()); } @Test public void testNextRecordFromSplitAfterExhaustion() { String splitId = "test-split-12"; - List records = Arrays.asList("record1"); - ClosableIterator iterator = createClosableIterator(records); + List records = Collections.singletonList("record1"); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, 0, 0L); batchRecords.nextSplit(); // Read the only record assertNotNull(batchRecords.nextRecordFromSplit()); - // After exhaustion, should return null + // After exhaustion, should return null (and stay null) assertNull(batchRecords.nextRecordFromSplit()); assertNull(batchRecords.nextRecordFromSplit()); } - @Test - public void testSeekWithZeroOffset() { - String splitId = "test-split-13"; - List records = Arrays.asList("record1", "record2", "record3"); - ClosableIterator iterator = createClosableIterator(records); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); - - // Seeking to 0 should not skip any records - batchRecords.seek(0L); - batchRecords.nextSplit(); - - HoodieRecordWithPosition record = batchRecords.nextRecordFromSplit(); - assertNotNull(record); - assertEquals("record1", record.record()); - } - @Test public void testConstructorNullValidation() { String splitId = "test-split-14"; - List records = Arrays.asList("record1"); - ClosableIterator iterator = createClosableIterator(records); + List records = Collections.singletonList("record1"); // Test null finishedSplits - assertThrows(IllegalArgumentException.class, () -> { - new BatchRecords<>(splitId, iterator, 0, 0L, null); - }); - - // Test null recordIterator - assertThrows(IllegalArgumentException.class, () -> { - new BatchRecords<>(splitId, null, 0, 0L, new HashSet<>()); - }); + assertThrows(IllegalArgumentException.class, () -> + new BatchRecords<>(splitId, records, 0, 0L, null)); + + // Test null records + assertThrows(IllegalArgumentException.class, () -> + new BatchRecords<>(splitId, null, 0, 0L, new HashSet<>())); } @Test public void testRecordPositionReusability() { String splitId = "test-split-15"; List records = Arrays.asList("A", "B", "C"); - ClosableIterator iterator = createClosableIterator(records); - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 0, 0L); + BatchRecords batchRecords = BatchRecords.forRecords(splitId, records, 0, 0L); batchRecords.nextSplit(); HoodieRecordWithPosition pos1 = batchRecords.nextRecordFromSplit(); HoodieRecordWithPosition pos2 = batchRecords.nextRecordFromSplit(); - // Should reuse the same object + // Should reuse the same object (safe because SourceReaderBase emits each record before the next) assertTrue(pos1 == pos2, "Should reuse the same HoodieRecordWithPosition object"); } - - @Test - public void testSeekUpdatesPosition() { - String splitId = "test-split-16"; - List records = Arrays.asList("r1", "r2", "r3", "r4", "r5"); - ClosableIterator iterator = createClosableIterator(records); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, iterator, 5, 10L); - - // Seek to offset 3 - batchRecords.seek(3L); - - batchRecords.nextSplit(); - - // After seeking 3, next record should be r4 (4th record) - HoodieRecordWithPosition record = batchRecords.nextRecordFromSplit(); - assertNotNull(record); - assertEquals("r4", record.record()); - } - - @Test - public void testIteratorClosedAfterExhaustion() { - String splitId = "test-split-17"; - List records = Arrays.asList("record1"); - MockClosableIterator mockIterator = new MockClosableIterator<>(records); - - BatchRecords batchRecords = BatchRecords.forRecords(splitId, mockIterator, 0, 0L); - batchRecords.nextSplit(); - - // Read records - batchRecords.nextRecordFromSplit(); - - // Trigger close operation - batchRecords.nextRecordFromSplit(); - - // After exhaustion, nextRecordFromSplit should close the iterator - assertTrue(mockIterator.isClosed(), "Iterator should be closed after exhaustion"); - } - - /** - * Helper method to create a ClosableIterator from a list of items. - */ - private ClosableIterator createClosableIterator(List items) { - Iterator iterator = items.iterator(); - return new ClosableIterator() { - @Override - public void close() { - // No-op for test - } - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public T next() { - return iterator.next(); - } - }; - } - - /** - * Mock closable iterator for testing close behavior. - */ - private static class MockClosableIterator implements ClosableIterator { - private final Iterator iterator; - private boolean closed = false; - - public MockClosableIterator(List items) { - this.iterator = items.iterator(); - } - - @Override - public void close() { - closed = true; - } - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public T next() { - return iterator.next(); - } - - public boolean isClosed() { - return closed; - } - } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java index 4ceb09caee43d..285d924b94521 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java @@ -20,8 +20,8 @@ import org.apache.flink.api.connector.source.SourceReaderContext; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.hudi.common.function.SerializableSupplier; import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.source.reader.function.SplitReaderFunction; import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.source.split.SerializableComparator; @@ -33,13 +33,20 @@ import org.mockito.Mockito; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BooleanSupplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -63,7 +70,7 @@ public void setUp() { public void testFetchWithNoSplits() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); RecordsWithSplitIds> result = reader.fetch(); @@ -77,7 +84,7 @@ public void testFetchWithSingleSplit() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split = createTestSplit(1, "file1"); SplitsAddition splitsChange = new SplitsAddition<>(Collections.singletonList(split)); @@ -95,7 +102,7 @@ public void testFetchWithMultipleSplits() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -123,7 +130,7 @@ public void testHandleSplitsChangesWithComparator() throws IOException { (s1, s2) -> s2.getFileId().compareTo(s1.getFileId()); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, comparator, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, comparator, Option.empty()); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -146,7 +153,7 @@ public void testAddingSplitsInMultipleBatches() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); // First batch HoodieSourceSplit split1 = createTestSplit(1, "file1"); @@ -168,7 +175,7 @@ public void testAddingSplitsInMultipleBatches() throws IOException { public void testClose() throws Exception { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split = createTestSplit(1, "file1"); reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); @@ -184,20 +191,242 @@ public void testClose() throws Exception { } @Test - public void testWakeUp() { + public void testWakeUp() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); - // wakeUp is a no-op, should not throw any exception + // wakeUp() now sets a flag but must not throw, and the flag is reset at the top of each fetch() + // so a wakeUp with no in-flight drain leaves the next fetch() unaffected. reader.wakeUp(); + RecordsWithSplitIds> result = reader.fetch(); + assertNotNull(result); + assertNull(result.nextSplit()); + } + + // ------------------------------------------------------------------------- + // wakeUp — cooperative cancellation of the minibatch drain + // ------------------------------------------------------------------------- + + @Test + public void testWakeUpMidDrainReturnsPartialBatchAndResumes() throws IOException { + // A wakeUp() landing after the first record stops the drain between records: fetch() returns the + // 1 record buffered so far as a NON-finishing batch without closing the split; a later fetch() + // resumes the rest with continuous offsets, and the split is closed only once at true EOF. + List testData = Arrays.asList("r1", "r2", "r3", "r4", "r5"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + boolean[] fired = {false}; + readerFunction.setDrainProbe((buffered, hasNext) -> { + if (buffered == 1 && !fired[0]) { + fired[0] = true; + reader.wakeUp(); + } + }); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + // First fetch: woken after r1 -> partial batch of 1, split not finished, not closed. + RecordsWithSplitIds> b1 = reader.fetch(); + assertEquals(split.splitId(), b1.nextSplit()); + HoodieRecordWithPosition first = b1.nextRecordFromSplit(); + assertNotNull(first); + assertEquals("r1", first.record()); + assertEquals(1L, first.recordOffset()); + assertNull(b1.nextRecordFromSplit(), "drain must stop at the first record on wake-up"); + assertTrue(b1.finishedSplits().isEmpty(), "woken split must not be finished"); + assertEquals(0, readerFunction.getCloseCurrentSplitCount(), "woken split must not be closed"); + + // Second fetch: resumes the remaining records with continuous offsets (2..5). + RecordsWithSplitIds> b2 = reader.fetch(); + assertEquals(split.splitId(), b2.nextSplit()); + HoodieRecordWithPosition next = b2.nextRecordFromSplit(); + assertNotNull(next); + assertEquals("r2", next.record()); + assertEquals(2L, next.recordOffset(), "offset continues across the wake boundary"); + assertEquals(3, drainRecordCount(b2), "r3, r4, r5 remain"); + assertTrue(b2.finishedSplits().isEmpty()); + + // Third fetch: true EOF -> finish signal, split closed exactly once, opened exactly once. + RecordsWithSplitIds> b3 = reader.fetch(); + assertTrue(b3.finishedSplits().contains(split.splitId())); + assertEquals(1, readerFunction.getCloseCurrentSplitCount()); + assertEquals(1, readerFunction.getOpenCount()); + } + + @Test + public void testWakeUpBeforeAnyRecordReturnsEmptyNonFinishingBatch() throws IOException { + // A wakeUp() landing before any record is buffered must return an empty NON-finishing batch, not + // a finish signal: the split stays open (not closed) and resumes on the next fetch(). + List testData = Arrays.asList("r1", "r2", "r3"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + boolean[] fired = {false}; + readerFunction.setDrainProbe((buffered, hasNext) -> { + // Wake at the start of the drain (count 0) while data is still available. + if (buffered == 0 && hasNext && !fired[0]) { + fired[0] = true; + reader.wakeUp(); + } + }); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + RecordsWithSplitIds> b1 = reader.fetch(); + assertNull(b1.nextSplit(), "empty non-finishing batch carries no split records"); + assertTrue(b1.finishedSplits().isEmpty(), "must not finish the split on wake-up"); + assertEquals(0, readerFunction.getCloseCurrentSplitCount(), "split must stay open"); + + // Next fetch resumes and returns all records (the one-shot wake has fired). + RecordsWithSplitIds> b2 = reader.fetch(); + assertEquals(split.splitId(), b2.nextSplit()); + assertEquals(3, drainRecordCount(b2)); + } + + @Test + public void testWakeUpCoincidingWithEofDefersFinishByOneFetch() throws IOException { + // readBatch returning null is ambiguous between true-EOF and woken-empty. When a wakeUp lands + // exactly at genuine exhaustion, fetch() returns an empty non-finishing batch once (deferring the + // finish), and the next fetch() finishes the split - it must never stall. + List testData = Arrays.asList("r1", "r2"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + boolean[] fired = {false}; + readerFunction.setDrainProbe((buffered, hasNext) -> { + // Wake only at the start of a drain that finds the cursor already exhausted. + if (buffered == 0 && !hasNext && !fired[0]) { + fired[0] = true; + reader.wakeUp(); + } + }); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + // First fetch drains both records (cursor still had data at drain start, so no wake). + RecordsWithSplitIds> b1 = reader.fetch(); + assertEquals(2, drainRecordCount(b1)); + assertEquals(0, readerFunction.getCloseCurrentSplitCount()); + + // Second fetch: cursor is exhausted at drain start -> wake fires -> empty non-finishing, deferred. + RecordsWithSplitIds> b2 = reader.fetch(); + assertTrue(b2.finishedSplits().isEmpty(), "finish deferred by the coinciding wake-up"); + assertEquals(0, readerFunction.getCloseCurrentSplitCount()); + + // Third fetch: no wake now -> true EOF finishes and closes the split. + RecordsWithSplitIds> b3 = reader.fetch(); + assertTrue(b3.finishedSplits().contains(split.splitId())); + assertEquals(1, readerFunction.getCloseCurrentSplitCount()); + } + + @Test + public void testWakeUpWithLimitReachedStillFinishesSplit() throws IOException { + // Guard: the woken-resume short-circuit must live strictly on the readBatch-returned-null path. + // A wakeUp coinciding with the limit-reached path must still finish the split, never loop forever + // returning non-finishing batches. The limiter wakes the reader exactly when the limit is reached. + List testData = Arrays.asList("r1", "r2", "r3"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + AtomicReference> holder = new AtomicReference<>(); + RecordLimiter wakingLimiter = new RecordLimiter(2L) { + @Override + public boolean isLimitReached() { + boolean reached = super.isLimitReached(); + if (reached && holder.get() != null) { + holder.get().wakeUp(); + } + return reached; + } + }; + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(wakingLimiter)); + holder.set(reader); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + // First fetch returns the split data; the limit wrapper caps consumption at 2 records. + RecordsWithSplitIds> b1 = reader.fetch(); + assertEquals(split.splitId(), b1.nextSplit()); + assertEquals(2, drainRecordCount(b1), "limit caps drained records at 2"); + + // Second fetch hits the limit-reached path (which wakes the reader); it must finish, not defer. + RecordsWithSplitIds> b2 = reader.fetch(); + assertTrue(b2.finishedSplits().contains(split.splitId()), "limit-reached path must finish the split"); + assertEquals(1, readerFunction.getCloseCurrentSplitCount()); + } + + @Test + public void testWakeUpPartialBatchRespectsLimit() throws IOException { + // A partial (woken) batch must not double-count against the pushed-down limit: with limit=3 over a + // 5-record split and a wake after the first record, the total emitted across batches stays at 3. + List testData = Arrays.asList("r1", "r2", "r3", "r4", "r5"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(3L))); + boolean[] fired = {false}; + readerFunction.setDrainProbe((buffered, hasNext) -> { + if (buffered == 1 && !fired[0]) { + fired[0] = true; + reader.wakeUp(); + } + }); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + int total = 0; + // Drain fetches until the split is finished; assert the limit caps the total at 3. + for (int i = 0; i < 10; i++) { + RecordsWithSplitIds> batch = reader.fetch(); + if (batch.nextSplit() != null) { + total += drainRecordCount(batch); + } + if (batch.finishedSplits().contains(split.splitId())) { + break; + } + } + assertEquals(3, total, "pushed-down limit must cap the total across partial woken batches"); + } + + @Test + public void testCloseReleasesWokenStillOpenSplit() throws Exception { + // Unit proxy for the real shutdown path (SplitFetcher.run() -> splitReader.close() on the fetcher + // thread): a split left open by a wake-up is released when the reader is closed. + List testData = Arrays.asList("r1", "r2", "r3"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + boolean[] fired = {false}; + readerFunction.setDrainProbe((buffered, hasNext) -> { + if (buffered == 1 && !fired[0]) { + fired[0] = true; + reader.wakeUp(); + } + }); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + // Woken fetch leaves the split open (not closed). + reader.fetch(); + assertEquals(0, readerFunction.getCloseCurrentSplitCount()); + + // close() on the (split-fetcher) thread releases the still-open split. + reader.close(); + assertEquals(1, readerFunction.getCloseCurrentSplitCount()); + assertTrue(readerFunction.isClosed()); } @Test public void testPauseOrResumeSplits() { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -215,14 +444,14 @@ public void testReaderFunctionCalledCorrectly() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split = createTestSplit(1, "file1"); reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); reader.fetch(); - assertEquals(1, readerFunction.getReadCount()); + assertEquals(1, readerFunction.getOpenCount()); assertEquals(split, readerFunction.getLastReadSplit()); } @@ -230,24 +459,48 @@ public void testReaderFunctionCalledCorrectly() throws IOException { public void testReaderFunctionClosedOnReaderClose() throws Exception { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); reader.close(); assertTrue(readerFunction.isClosed(), "Reader function should be closed"); } + @Test + public void testEachSplitReaderUsesIndependentReaderFunction() throws Exception { + List readerFunctions = new ArrayList<>(); + SerializableSupplier> readerFunctionSupplier = () -> { + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); + readerFunctions.add(readerFunction); + return readerFunction; + }; + + HoodieSourceSplitReader firstReader = new HoodieSourceSplitReader<>( + TABLE_NAME, readerContext, readerFunctionSupplier, null, Option.empty()); + HoodieSourceSplitReader secondReader = new HoodieSourceSplitReader<>( + TABLE_NAME, readerContext, readerFunctionSupplier, null, Option.empty()); + + assertEquals(2, readerFunctions.size()); + firstReader.close(); + assertTrue(readerFunctions.get(0).isClosed()); + assertFalse(readerFunctions.get(1).isClosed(), + "Closing an idle fetcher's split reader must not close the next fetcher's reader function"); + + secondReader.close(); + assertTrue(readerFunctions.get(1).isClosed()); + } + @Test public void testFetchEmptyResultWhenNoSplitsAdded() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); RecordsWithSplitIds> result = reader.fetch(); assertNotNull(result); assertNull(result.nextSplit()); - assertEquals(0, readerFunction.getReadCount(), "Should not read any splits"); + assertEquals(0, readerFunction.getOpenCount(), "Should not read any splits"); } @Test @@ -257,7 +510,7 @@ public void testSplitOrderPreservedWithoutComparator() throws IOException { // No comparator - should preserve insertion order HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split3 = createTestSplit(3, "file3"); HoodieSourceSplit split1 = createTestSplit(1, "file1"); @@ -279,7 +532,7 @@ public void testReaderIteratorClosedOnSplitFinish() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.empty()); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -301,7 +554,7 @@ public void testLimitCapsRecordsFromSingleSplit() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.of(new RecordLimiter(2L))); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(2L))); HoodieSourceSplit split = createTestSplit(1, "file1"); reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); @@ -329,7 +582,7 @@ public void testLimitDrainsRemainingSplitsWhenLimitReached() throws IOException TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.of(new RecordLimiter(2L))); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(2L))); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -348,8 +601,8 @@ public void testLimitDrainsRemainingSplitsWhenLimitReached() throws IOException assertTrue(drainBatch.finishedSplits().contains(split2.splitId()), "split2 should be drained as finished once limit is reached"); assertNull(drainBatch.nextSplit()); - // readerFunction.read() was called only once (for split1, never for split2) - assertEquals(1, readerFunction.getReadCount()); + // readerFunction was opened only once (for split1, never for split2) + assertEquals(1, readerFunction.getOpenCount()); } @Test @@ -357,7 +610,7 @@ public void testLimitZeroDrainsAllSplitsImmediately() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(Arrays.asList("r1", "r2")); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.of(new RecordLimiter(0L))); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(0L))); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -369,8 +622,8 @@ public void testLimitZeroDrainsAllSplitsImmediately() throws IOException { assertTrue(finished.contains(split1.splitId())); assertTrue(finished.contains(split2.splitId())); assertNull(batch.nextSplit()); - // readerFunction.read() was never called - assertEquals(0, readerFunction.getReadCount()); + // readerFunction was never opened + assertEquals(0, readerFunction.getOpenCount()); } @Test @@ -380,7 +633,7 @@ public void testLimitExactlyMatchingTotalRecords() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.of(new RecordLimiter(3L))); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(3L))); HoodieSourceSplit split = createTestSplit(1, "file1"); reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); @@ -406,7 +659,7 @@ public void testLimitSpanningMultipleSplits() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, Option.of(new RecordLimiter(5L))); + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.of(new RecordLimiter(5L))); HoodieSourceSplit split1 = createTestSplit(1, "file1"); HoodieSourceSplit split2 = createTestSplit(2, "file2"); @@ -439,7 +692,7 @@ public void testNoLimitSentinelReturnsAllRecords() throws IOException { TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); HoodieSourceSplitReader reader = - new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, readerFunction, null, + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); HoodieSourceSplit split = createTestSplit(1, "file1"); @@ -450,6 +703,78 @@ public void testNoLimitSentinelReturnsAllRecords() throws IOException { assertEquals(5, drainRecordCount(batch)); } + // ------------------------------------------------------------------------- + // Minibatch / resume tests + // ------------------------------------------------------------------------- + + @Test + public void testSplitSpanningMultipleMinibatches() throws IOException { + // A split larger than the mini-batch bound (2048) is emitted as multiple non-finished batches, + // then one finish signal. The reader function is opened once and closed once across the split, + // and the record offset stays continuous across the minibatch boundary. + int n = 2049; + List testData = IntStream.range(0, n).mapToObj(i -> "r" + i).collect(Collectors.toList()); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + // First minibatch: 2048 records, offsets 1..2048, split not yet finished. + RecordsWithSplitIds> b1 = reader.fetch(); + assertEquals(split.splitId(), b1.nextSplit()); + long lastOffset = 0L; + int c1 = 0; + HoodieRecordWithPosition rec; + while ((rec = b1.nextRecordFromSplit()) != null) { + lastOffset = rec.recordOffset(); + c1++; + } + assertEquals(2048, c1); + assertEquals(2048L, lastOffset); + assertTrue(b1.finishedSplits().isEmpty(), "split not finished after the first minibatch"); + + // Second minibatch: the remaining record, offset continues at 2049. + RecordsWithSplitIds> b2 = reader.fetch(); + assertEquals(split.splitId(), b2.nextSplit()); + HoodieRecordWithPosition only = b2.nextRecordFromSplit(); + assertNotNull(only); + assertEquals(2049L, only.recordOffset()); + assertNull(b2.nextRecordFromSplit()); + assertTrue(b2.finishedSplits().isEmpty()); + + // Third fetch: split exhausted -> finish signal. + RecordsWithSplitIds> b3 = reader.fetch(); + assertTrue(b3.finishedSplits().contains(split.splitId())); + + assertEquals(1, readerFunction.getOpenCount(), "split opened exactly once across minibatches"); + assertEquals(1, readerFunction.getCloseCurrentSplitCount(), "split closed exactly once at EOF"); + } + + @Test + public void testResumeSkipsConsumedRecords() throws IOException { + // A recovered split carries a consumed offset; open() skips that many records and the emitted + // offsets resume at consumed+1. + List testData = Arrays.asList("r1", "r2", "r3", "r4", "r5"); + TestSplitReaderFunction readerFunction = new TestSplitReaderFunction(testData); + HoodieSourceSplitReader reader = + new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, () -> readerFunction, null, Option.empty()); + + HoodieSourceSplit split = createTestSplit(1, "file1"); + split.updatePosition(0, 2L); // 2 records already consumed before recovery + reader.handleSplitsChanges(new SplitsAddition<>(Collections.singletonList(split))); + + RecordsWithSplitIds> batch = reader.fetch(); + assertEquals(split.splitId(), batch.nextSplit()); + HoodieRecordWithPosition first = batch.nextRecordFromSplit(); + assertNotNull(first); + assertEquals("r3", first.record(), "should resume past the 2 consumed records"); + assertEquals(3L, first.recordOffset(), "offset resumes at consumed + 1"); + // r4, r5 remain + assertEquals(2, drainRecordCount(batch)); + } + /** * Fetches the next batch that contains actual split data, skipping split-finish signal batches. * Split-finish batches have non-empty {@code finishedSplits()} but no records. @@ -496,14 +821,29 @@ private HoodieSourceSplit createTestSplit(int splitNum, String fileId) { } /** - * Test implementation of SplitReaderFunction. + * Test implementation of the stateful {@link SplitReaderFunction} cursor contract: {@code open} + * materializes the split's data and honors the consumed-offset skip, {@code readBatch} drains a + * bounded minibatch, and {@code closeCurrentSplit}/{@code close} release it. */ private static class TestSplitReaderFunction implements SplitReaderFunction { private final List testData; - private int readCount = 0; + private int openCount = 0; + private int closeCurrentSplitCount = 0; private HoodieSourceSplit lastReadSplit = null; private boolean closed = false; + // per-split cursor + private Iterator cursor; + private long nextRecordOffset; + + // Optional hook invoked as (bufferedCount, cursorHasNext) at readBatch start (count 0) and after + // each buffered record, so a test can call reader.wakeUp() at a precise point in the drain. + private BiConsumer drainProbe; + + void setDrainProbe(BiConsumer drainProbe) { + this.drainProbe = drainProbe; + } + public TestSplitReaderFunction() { this(Collections.emptyList()); } @@ -513,25 +853,63 @@ public TestSplitReaderFunction(List testData) { } @Override - public RecordsWithSplitIds> read(HoodieSourceSplit split) { - readCount++; + public void open(HoodieSourceSplit split) { + openCount++; lastReadSplit = split; - ClosableIterator iterator = createClosableIterator(testData); - return BatchRecords.forRecords( - split.splitId(), - iterator, - split.getFileOffset(), - split.getConsumed() - ); + cursor = testData.iterator(); + long consumed = split.getConsumed(); + for (long i = 0; i < consumed; i++) { + if (cursor.hasNext()) { + cursor.next(); + } else { + throw new IllegalStateException( + "Invalid starting record offset " + consumed + " for split " + split.splitId()); + } + } + nextRecordOffset = consumed; + } + + @Override + public BatchRecords readBatch(HoodieSourceSplit split, int batchSize, BooleanSupplier wakeupSignal) { + List buffer = new ArrayList<>(); + if (drainProbe != null) { + drainProbe.accept(0, cursor.hasNext()); + } + while (buffer.size() < batchSize && !wakeupSignal.getAsBoolean() && cursor.hasNext()) { + buffer.add(cursor.next()); + if (drainProbe != null) { + drainProbe.accept(buffer.size(), cursor.hasNext()); + } + } + if (buffer.isEmpty()) { + return null; + } + long startingRecordOffset = nextRecordOffset; + nextRecordOffset += buffer.size(); + return BatchRecords.forRecords(split.splitId(), buffer, split.getFileOffset(), startingRecordOffset); } @Override - public void close() throws Exception { + public void closeCurrentSplit() { + closeCurrentSplitCount++; + cursor = null; + } + + @Override + public void close() { + // Mirror AbstractSplitReaderFunction#close(): releasing the reader also releases any split + // still open (e.g. one left open by a wake-up), on the split-fetcher thread. + closeCurrentSplit(); closed = true; } - public int getReadCount() { - return readCount; + // Number of splits opened; mirrors the old per-split read() count. + public int getOpenCount() { + return openCount; + } + + public int getCloseCurrentSplitCount() { + return closeCurrentSplitCount; } public HoodieSourceSplit getLastReadSplit() { @@ -541,25 +919,5 @@ public HoodieSourceSplit getLastReadSplit() { public boolean isClosed() { return closed; } - - private ClosableIterator createClosableIterator(List items) { - Iterator iterator = items.iterator(); - return new ClosableIterator() { - @Override - public void close() { - // No-op - } - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public String next() { - return iterator.next(); - } - }; - } } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java index 8b48772ab5f7d..ae95089dd8b0d 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java @@ -19,14 +19,20 @@ package org.apache.hudi.source.reader.function; import org.apache.flink.configuration.Configuration; -import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds; +import org.apache.flink.table.data.GenericRowData; import org.apache.flink.table.data.RowData; import org.apache.flink.table.expressions.FieldReferenceExpression; import org.apache.flink.table.expressions.ValueLiteralExpression; import org.apache.flink.table.types.AtomicDataType; +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.logical.VarCharType; +import org.apache.flink.types.RowKind; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.source.ExpressionPredicates; +import org.apache.hudi.source.reader.BatchRecords; import org.apache.hudi.source.reader.HoodieRecordWithPosition; import org.apache.hudi.source.split.HoodieSourceSplit; import org.apache.hudi.table.format.InternalSchemaManager; @@ -39,10 +45,13 @@ import java.io.File; import java.util.Collections; import java.util.List; +import java.util.function.BooleanSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -70,8 +79,8 @@ public void setUp() { /** * Minimal concrete implementation that exposes the protected helper methods - * ({@code getWriteConfig()} / {@code getHadoopConf()}) for testing, and leaves - * {@code read()} / {@code close()} as no-ops. + * ({@code getWriteConfig()} / {@code getHadoopConf()}) for testing. The template methods return an + * empty iterator / a trivial row type; the constructor and singleton tests never drive the cursor. */ private static class MinimalSplitReaderFunction extends AbstractSplitReaderFunction { @@ -84,12 +93,13 @@ private static class MinimalSplitReaderFunction extends AbstractSplitReaderFunct } @Override - public RecordsWithSplitIds> read(HoodieSourceSplit split) { - return null; + protected ClosableIterator createRecordIterator(HoodieSourceSplit split) { + return ClosableIterator.wrap(Collections.emptyIterator()); } @Override - public void close() throws Exception { + protected RowType producedRowType() { + return RowType.of(new IntType()); } HoodieWriteConfig writeConfigForTest() { @@ -243,4 +253,135 @@ public void testTwoInstancesHaveStableIndependentWriteConfig() { assertSame(wc2, fn2.writeConfigForTest(), "fn2's writeConfig must remain the same singleton across calls"); } + + // ----------------------------------------------------------------------- + // readBatch — copy-on-materialize (object-reuse regression guard) + // ----------------------------------------------------------------------- + + @Test + public void testReadBatchCopiesReusedRecordObjects() { + // Columnar readers return the SAME mutable RowData object on every next(); readBatch must copy + // each record, otherwise every entry in a materialized minibatch would alias the last row. + ReusedObjectSplitReaderFunction fn = + new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 3); + HoodieSourceSplit split = createSplit(); + + fn.open(split); + BatchRecords batch = fn.readBatch(split, 10, () -> false); + assertNotNull(batch); + batch.nextSplit(); + + RowData r0 = batch.nextRecordFromSplit().record(); + RowData r1 = batch.nextRecordFromSplit().record(); + RowData r2 = batch.nextRecordFromSplit().record(); + assertNull(batch.nextRecordFromSplit()); + + // Distinct values despite the source reusing a single object. + assertEquals(0, r0.getInt(0)); + assertEquals(1, r1.getInt(0)); + assertEquals(2, r2.getInt(0)); + assertNotSame(r0, r1, "records must be copies, not the reused source object"); + assertNotSame(r1, r2); + // RowKind is preserved across the copy. + assertEquals(RowKind.DELETE, r0.getRowKind()); + assertEquals(RowKind.INSERT, r1.getRowKind()); + assertEquals(RowKind.DELETE, r2.getRowKind()); + } + + // ----------------------------------------------------------------------- + // readBatch — wake-up signal (cooperative cancellation between records) + // ----------------------------------------------------------------------- + + @Test + public void testReadBatchStopsOnWakeupSignal() { + // The wakeupSignal is polled between records; once it trips, materialization stops early and the + // records buffered so far are returned as a partial minibatch with continuous offsets. + ReusedObjectSplitReaderFunction fn = + new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 5); + HoodieSourceSplit split = createSplit(); + fn.open(split); + + // Returns false, false, true: the loop buffers 2 records, then the 3rd poll stops it. + int[] polls = {0}; + BooleanSupplier signal = () -> (++polls[0]) > 2; + + BatchRecords batch = fn.readBatch(split, 10, signal); + assertNotNull(batch); + batch.nextSplit(); + + // nextRecordFromSplit() returns the same reused position wrapper each call, so read each record's + // value/offset before advancing. Offsets are 1-based and continuous from the starting offset (0). + HoodieRecordWithPosition rec = batch.nextRecordFromSplit(); + assertNotNull(rec); + assertEquals(0, rec.record().getInt(0)); + assertEquals(1L, rec.recordOffset()); + + rec = batch.nextRecordFromSplit(); + assertNotNull(rec); + assertEquals(1, rec.record().getInt(0)); + assertEquals(2L, rec.recordOffset()); + + assertNull(batch.nextRecordFromSplit(), "materialization must stop at 2 records on wake-up"); + } + + @Test + public void testReadBatchReturnsNullWhenWokenBeforeAnyRecord() { + // A wake-up that lands before the first record is buffered yields an empty batch, signalled as + // null (the same sentinel as EOF); HoodieSourceSplitReader.fetch() disambiguates the two. + ReusedObjectSplitReaderFunction fn = + new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 5); + HoodieSourceSplit split = createSplit(); + fn.open(split); + + assertNull(fn.readBatch(split, 10, () -> true)); + } + + private HoodieSourceSplit createSplit() { + return new HoodieSourceSplit( + 1, "base", Option.of(Collections.emptyList()), "/tbl", "/part", + "read_optimized", "19700101000000000", "file1", Option.empty()); + } + + /** + * Reader function whose iterator returns the SAME mutable {@link GenericRowData} instance on every + * {@code next()} (mimicking a columnar reader), used to prove readBatch copies each record. + */ + private static class ReusedObjectSplitReaderFunction extends AbstractSplitReaderFunction { + private final int count; + + ReusedObjectSplitReaderFunction(Configuration conf, InternalSchemaManager ism, int count) { + super(conf, Collections.emptyList(), ism, false); + this.count = count; + } + + @Override + protected ClosableIterator createRecordIterator(HoodieSourceSplit split) { + return new ClosableIterator() { + private final GenericRowData reused = new GenericRowData(1); + private int i = 0; + + @Override + public boolean hasNext() { + return i < count; + } + + @Override + public RowData next() { + reused.setField(0, i); + reused.setRowKind(i % 2 == 0 ? RowKind.DELETE : RowKind.INSERT); + i++; + return reused; // same object every call + } + + @Override + public void close() { + } + }; + } + + @Override + protected RowType producedRowType() { + return RowType.of(new IntType()); + } + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java index b0d0579da0846..d5dc3eca57707 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java @@ -156,11 +156,11 @@ public void testReadWithNonCdcSplitDelegatesToFallback() { 1, "base.parquet", Option.empty(), tempDir.getAbsolutePath(), "", "read_optimized", "20230101000000000", "file-1", Option.empty()); - Exception ex = assertThrows(Exception.class, () -> function.read(nonCdcSplit)); + Exception ex = assertThrows(Exception.class, () -> function.open(nonCdcSplit)); assertNotNull(ex); // Must not be IllegalArgumentException (which the old type-guard wrongly threw) if (ex instanceof IllegalArgumentException) { - throw new AssertionError("read() should not throw IllegalArgumentException for non-CDC split; " + throw new AssertionError("open() should not throw IllegalArgumentException for non-CDC split; " + "it should fall through to the fallback reader", ex); } } @@ -223,7 +223,7 @@ public void testConstructorWithLimitZeroIsAccepted() { // ------------------------------------------------------------------------- @Test - public void testReadAcceptsCdcSourceSplitType() { + public void testReadAcceptsCdcSourceSplitType() throws Exception { // Verify that HoodieCdcSourceSplit is accepted (cast doesn't throw). // Actual I/O would require a real Hoodie table, so we only check the // type-guard passes by catching the downstream I/O error rather than @@ -237,7 +237,8 @@ public void testReadAcceptsCdcSourceSplitType() { 1, tempDir.getAbsolutePath(), 128 * 1024 * 1024L, "file-cdc", EMPTY_PARTITION_PATH, changes, "read_optimized", "20230101000000000"); - // Should not throw exception - function.read(cdcSplit); + // Opening the split creates the CDC iterator lazily (no I/O yet); it must not throw. + function.open(cdcSplit); + function.close(); } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java index 2d6522d7941e9..59cde8d85afad 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java @@ -23,9 +23,15 @@ import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.read.HoodieFileGroupReader; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.internal.schema.InternalSchema; import org.apache.hudi.source.ExpressionPredicates; +import org.apache.hudi.source.split.HoodieSourceSplit; +import org.apache.hudi.util.StreamerUtil; import org.apache.flink.configuration.Configuration; +import org.apache.flink.table.data.RowData; import org.apache.flink.table.expressions.FieldReferenceExpression; import org.apache.flink.table.expressions.ValueLiteralExpression; import org.apache.flink.table.types.logical.VarCharType; @@ -34,14 +40,23 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; import java.io.File; +import java.io.IOException; import java.util.Collections; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -283,7 +298,8 @@ public void testConfigurationIsStored() { @Test public void testReadMethodSignature() { - // Verify that the read method returns CloseableIterator + // Verify the reader function constructs via its public signature (the read path is driven + // through open/readBatch on the split-fetcher thread). HoodieSplitReaderFunction function = new HoodieSplitReaderFunction( conf, @@ -443,4 +459,78 @@ public void testDefaultConstructor() { assertNotNull(function); } + + // ------------------------------------------------------------------------- + // createRecordIterator — file-group reader cleanup when iterator init fails + // ------------------------------------------------------------------------- + + @Test + public void testCreateRecordIteratorClosesReaderWhenInitFails() throws Exception { + // getClosableIterator() runs initRecordIterators(), which can open the reader's I/O resources and + // then throw. The reader is only a local in createRecordIterator, so the failure path must close + // it (nothing else would), preserving the original failure as the cause. + HoodieFileGroupReader reader = mockReader(); + IOException initFailure = new IOException("init failed"); + when(reader.getClosableIterator()).thenThrow(initFailure); + + HoodieSplitReaderFunction function = readerFunctionReturning(reader); + HoodieSourceSplit split = createSplit(); + + try (MockedStatic mockedStreamerUtil = mockStatic(StreamerUtil.class)) { + mockedStreamerUtil.when(() -> StreamerUtil.metaClientForReader(any(), any())) + .thenReturn(mockMetaClient); + + HoodieIOException thrown = + assertThrows(HoodieIOException.class, () -> function.createRecordIterator(split)); + assertSame(initFailure, thrown.getCause(), "the original init failure must be the cause"); + } + verify(reader, times(1)).close(); + } + + @Test + public void testCreateRecordIteratorSuppressesCloseError() throws Exception { + // A close() failure on the cleanup path must not mask the init failure: it is attached as a + // suppressed exception on the original. + HoodieFileGroupReader reader = mockReader(); + IOException initFailure = new IOException("init failed"); + IOException closeFailure = new IOException("close failed"); + when(reader.getClosableIterator()).thenThrow(initFailure); + doThrow(closeFailure).when(reader).close(); + + HoodieSplitReaderFunction function = readerFunctionReturning(reader); + HoodieSourceSplit split = createSplit(); + + try (MockedStatic mockedStreamerUtil = mockStatic(StreamerUtil.class)) { + mockedStreamerUtil.when(() -> StreamerUtil.metaClientForReader(any(), any())) + .thenReturn(mockMetaClient); + + HoodieIOException thrown = + assertThrows(HoodieIOException.class, () -> function.createRecordIterator(split)); + assertSame(initFailure, thrown.getCause()); + assertEquals(1, initFailure.getSuppressed().length, "close failure must be suppressed, not lost"); + assertSame(closeFailure, initFailure.getSuppressed()[0]); + } + } + + @SuppressWarnings("unchecked") + private static HoodieFileGroupReader mockReader() { + return mock(HoodieFileGroupReader.class); + } + + private HoodieSplitReaderFunction readerFunctionReturning(HoodieFileGroupReader reader) { + return new HoodieSplitReaderFunction( + conf, tableSchema, requiredSchema, mockInternalSchemaManager, + "AVRO_PAYLOAD", Collections.emptyList(), false) { + @Override + protected HoodieFileGroupReader createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient metaClient) { + return reader; + } + }; + } + + private static HoodieSourceSplit createSplit() { + return new HoodieSourceSplit( + 1, "base", Option.of(Collections.emptyList()), "/tbl", "/part", + "read_optimized", "19700101000000000", "file1", Option.empty()); + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.java new file mode 100644 index 0000000000000..807c306c504ff --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.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.hudi.source.split; + +import org.apache.hudi.common.util.Option; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link GlobalHoodieSplitProvider}. + * + *

    The distinguishing behavior from {@link DefaultHoodieSplitProvider} is that splits are NOT + * pinned to a subtask: any requesting subtask gets the next split from a single shared pool (work + * stealing), and the pool drains fully regardless of which subtasks do the asking. + */ +public class TestGlobalHoodieSplitProvider { + private GlobalHoodieSplitProvider provider; + private HoodieSourceSplit split1; + private HoodieSourceSplit split2; + private HoodieSourceSplit split3; + + @BeforeEach + public void setUp() { + provider = new GlobalHoodieSplitProvider(); + split1 = createTestSplit(1, "file1"); + split2 = createTestSplit(2, "file2"); + split3 = createTestSplit(3, "file3"); + } + + @Test + public void testGetNextFromEmptyProvider() { + assertFalse(provider.getNext(0, null).isPresent(), + "Should return empty option when no splits available"); + } + + @Test + public void testAnySubtaskGetsNextSplit() { + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + + // Three unrelated subtask ids each pull one split from the shared pool; together they drain it, + // and every discovered split is handed out exactly once. + Set served = new HashSet<>(); + served.add(requireSplit(provider.getNext(0, null)).splitId()); + served.add(requireSplit(provider.getNext(5, null)).splitId()); + served.add(requireSplit(provider.getNext(99, "some-host")).splitId()); + + assertEquals( + new HashSet<>(Arrays.asList(split1.splitId(), split2.splitId(), split3.splitId())), + served, + "Every split should be served exactly once across arbitrary subtasks"); + assertFalse(provider.getNext(0, null).isPresent(), "Pool should be drained"); + assertEquals(0, provider.pendingSplitCount()); + } + + @Test + public void testSingleSubtaskCanDrainEntirePool() { + // Work stealing: one reader may take every split. DefaultHoodieSplitProvider would instead pin + // most of these to other subtasks and starve this one. + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + + assertTrue(provider.getNext(7, null).isPresent()); + assertTrue(provider.getNext(7, null).isPresent()); + assertTrue(provider.getNext(7, null).isPresent()); + assertFalse(provider.getNext(7, null).isPresent(), + "Fourth request should be empty once the single reader drained the pool"); + } + + @Test + public void testServedOldestCommitFirstRegardlessOfSubtask() { + HoodieSourceSplit early = createSplitWithCommit(1, "20260126034716930", "file_early"); + HoodieSourceSplit middle = createSplitWithCommit(2, "20260126034717000", "file_middle"); + HoodieSourceSplit late = createSplitWithCommit(3, "20260126034718000", "file_late"); + + // Discover out of order and request from different subtasks: ordering is by commit time, not by + // requester or insertion order. + provider.onDiscoveredSplits(Arrays.asList(late, early, middle)); + + assertEquals(early.splitId(), requireSplit(provider.getNext(3, null)).splitId()); + assertEquals(middle.splitId(), requireSplit(provider.getNext(8, null)).splitId()); + assertEquals(late.splitId(), requireSplit(provider.getNext(0, null)).splitId()); + } + + @Test + public void testOnUnassignedSplitsReturnedToPoolForAnySubtask() { + provider.onDiscoveredSplits(Collections.singletonList(split1)); + HoodieSourceSplit taken = requireSplit(provider.getNext(0, null)); + assertEquals(0, provider.pendingSplitCount()); + + // A failed reader hands the split back; a different subtask can pick it up. + provider.onUnassignedSplits(Collections.singletonList(taken)); + assertEquals(1, provider.pendingSplitCount(), "Returned split should be back in the pool"); + assertEquals(taken.splitId(), requireSplit(provider.getNext(4, null)).splitId()); + } + + @Test + public void testPendingSplitCount() { + assertEquals(0, provider.pendingSplitCount(), "Initially should have 0 pending splits"); + + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + assertEquals(3, provider.pendingSplitCount()); + + provider.getNext(0, null); + provider.getNext(1, null); + assertEquals(1, provider.pendingSplitCount(), + "Count should drop as splits are served to any subtask"); + } + + @Test + public void testMultipleDiscoveryCalls() { + provider.onDiscoveredSplits(Collections.singletonList(split1)); + provider.onDiscoveredSplits(Arrays.asList(split2, split3)); + assertEquals(3, provider.pendingSplitCount(), "All discovered splits accumulate in the pool"); + } + + @Test + public void testEmptyDiscoveredSplits() { + provider.onDiscoveredSplits(Collections.emptyList()); + assertEquals(0, provider.pendingSplitCount()); + assertFalse(provider.getNext(0, null).isPresent()); + } + + @Test + public void testState() { + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + + Collection states = provider.state(); + assertEquals(3, states.size(), "State should contain all pending splits"); + for (HoodieSourceSplitState state : states) { + assertEquals(HoodieSourceSplitStatus.UNASSIGNED, state.getStatus(), + "Pending splits should be UNASSIGNED"); + } + } + + @Test + public void testStateAfterConsumingSomeSplits() { + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + provider.getNext(0, null); + provider.getNext(1, null); + + assertEquals(1, provider.state().size(), "State should only reflect the remaining split"); + } + + @Test + public void testStateRoundTripsThroughRediscovery() { + // Mirrors the enumerator restore path: snapshot pending splits, rebuild a fresh provider and + // re-discover them. No split is lost or duplicated. + provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3)); + provider.getNext(0, null); // one assigned, two remain pending in the checkpoint + + List checkpointed = new ArrayList<>(); + for (HoodieSourceSplitState state : provider.state()) { + checkpointed.add(state.getSplit()); + } + + GlobalHoodieSplitProvider restored = new GlobalHoodieSplitProvider(); + restored.onDiscoveredSplits(checkpointed); + assertEquals(2, restored.pendingSplitCount()); + assertTrue(restored.getNext(0, null).isPresent()); + assertTrue(restored.getNext(0, null).isPresent()); + assertFalse(restored.getNext(0, null).isPresent()); + } + + @Test + public void testIsAvailable() { + CompletableFuture future = provider.isAvailable(); + assertNotNull(future, "isAvailable should return a future"); + assertFalse(future.isDone(), "Future should not be completed with no splits"); + assertSame(future, provider.isAvailable(), + "The same future should be returned until it completes"); + } + + @Test + public void testIsAvailableCompletesOnDiscovery() { + CompletableFuture future = provider.isAvailable(); + + provider.onDiscoveredSplits(Collections.singletonList(split1)); + + assertTrue(future.isDone(), "Future should complete once splits land in the pool"); + assertFalse(provider.isAvailable().isDone(), + "A fresh, uncompleted future should be handed out afterwards"); + } + + @Test + public void testPendingRecordsUnsupported() { + assertThrows(UnsupportedOperationException.class, () -> provider.pendingRecords()); + } + + @Test + public void testConcurrentDrainServesEachSplitExactlyOnce() throws Exception { + final int splitCount = 500; + final int readerCount = 8; + List splits = new ArrayList<>(); + for (int i = 0; i < splitCount; i++) { + splits.add(createTestSplit(i, "file" + i)); + } + provider.onDiscoveredSplits(splits); + + ConcurrentLinkedQueue served = new ConcurrentLinkedQueue<>(); + CountDownLatch start = new CountDownLatch(1); + List> drains = new ArrayList<>(); + ExecutorService readers = Executors.newFixedThreadPool(readerCount); + try { + for (int reader = 0; reader < readerCount; reader++) { + final int subtaskId = reader; + drains.add(readers.submit(() -> { + start.await(); + Option next; + while ((next = provider.getNext(subtaskId, null)).isPresent()) { + served.add(next.get().splitId()); + } + return null; + })); + } + start.countDown(); + readers.shutdown(); + assertTrue(readers.awaitTermination(30, TimeUnit.SECONDS), "Readers should drain the pool"); + for (Future drain : drains) { + drain.get(); // surface any failure inside a reader thread + } + } finally { + readers.shutdownNow(); + } + + assertEquals(splitCount, served.size(), "No split should be served twice"); + assertEquals(splitCount, new HashSet<>(served).size(), "No split should be lost"); + assertEquals(0, provider.pendingSplitCount(), "Pool should be fully drained"); + } + + private static HoodieSourceSplit requireSplit(Option option) { + assertTrue(option.isPresent(), "Expected a split to be available"); + return option.get(); + } + + private HoodieSourceSplit createSplitWithCommit(int splitNum, String latestCommit, String basePath) { + return new HoodieSourceSplit( + splitNum, + basePath, + Option.empty(), + "/table/path", + "/table/path/partition1", + "read_optimized", + latestCommit, + "file" + splitNum, + Option.empty()); + } + + private HoodieSourceSplit createTestSplit(int splitNum, String fileId) { + return new HoodieSourceSplit( + splitNum, + "40e603a8-3cc1-4d09-b0a5-1432992b4bf7_1-0" + splitNum + "_20260126034717000.parquet", + Option.empty(), + "/table/path", + "/table/path/partition1", + "read_optimized", + "2026012603471700" + splitNum, + fileId, + Option.empty()); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplit.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplit.java index 1cb244245ce82..8dd32af3adf3b 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplit.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplit.java @@ -360,8 +360,8 @@ public void testInstantRangePresent() { ); assertTrue(split.getInstantRange().isPresent()); - assertEquals("20230101000000000", split.getInstantRange().get().getStartInstant().get()); - assertEquals("20230131235959999", split.getInstantRange().get().getEndInstant().get()); + assertEquals("20230101000000000", split.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230131235959999", split.getInstantRange().get().getEndInstantOpt().get()); } @Test @@ -402,9 +402,9 @@ public void testInstantRangeWithOnlyStart() { ); assertTrue(split.getInstantRange().isPresent()); - assertTrue(split.getInstantRange().get().getStartInstant().isPresent()); - assertFalse(split.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230101000000000", split.getInstantRange().get().getStartInstant().get()); + assertTrue(split.getInstantRange().get().getStartInstantOpt().isPresent()); + assertFalse(split.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230101000000000", split.getInstantRange().get().getStartInstantOpt().get()); } @Test @@ -476,7 +476,7 @@ public void testClosedClosedInstantRange() { ); assertTrue(split.getInstantRange().isPresent()); - assertTrue(split.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(split.getInstantRange().get().getEndInstant().isPresent()); + assertTrue(split.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(split.getInstantRange().get().getEndInstantOpt().isPresent()); } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplitSerializer.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplitSerializer.java index fc30da2193d7c..04b526a805420 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplitSerializer.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestHoodieSourceSplitSerializer.java @@ -509,10 +509,10 @@ public void testSerializeWithInstantRangeStartAndEnd() throws IOException { assertNotNull(deserialized); assertTrue(deserialized.getInstantRange().isPresent()); - assertTrue(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstant().get()); - assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstant().get()); + assertTrue(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstantOpt().get()); } @Test @@ -540,9 +540,9 @@ public void testSerializeWithInstantRangeOnlyStart() throws IOException { assertNotNull(deserialized); assertTrue(deserialized.getInstantRange().isPresent()); - assertTrue(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertFalse(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstant().get()); + assertTrue(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertFalse(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstantOpt().get()); } @Test @@ -570,10 +570,10 @@ public void testSerializeWithClosedClosedInstantRange() throws IOException { assertNotNull(deserialized); assertTrue(deserialized.getInstantRange().isPresent()); - assertTrue(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstant().get()); - assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstant().get()); + assertTrue(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstantOpt().get()); } @Test @@ -605,8 +605,8 @@ public void testSerializeWithInstantRangeAndConsumedState() throws IOException { assertTrue(deserialized.getInstantRange().isPresent()); assertEquals(10, deserialized.getFileOffset()); assertEquals(500L, deserialized.getConsumed()); - assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstant().get()); - assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstant().get()); + assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstantOpt().get()); } @Test @@ -637,13 +637,13 @@ public void testSerializeMultipleSplitsWithInstantRange() throws IOException { // Verify split1 assertTrue(deserialized1.getInstantRange().isPresent()); - assertEquals("20230101000000000", deserialized1.getInstantRange().get().getStartInstant().get()); - assertEquals("20230131235959999", deserialized1.getInstantRange().get().getEndInstant().get()); + assertEquals("20230101000000000", deserialized1.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230131235959999", deserialized1.getInstantRange().get().getEndInstantOpt().get()); // Verify split2 assertTrue(deserialized2.getInstantRange().isPresent()); - assertEquals("20230201000000000", deserialized2.getInstantRange().get().getStartInstant().get()); - assertFalse(deserialized2.getInstantRange().get().getEndInstant().isPresent()); + assertEquals("20230201000000000", deserialized2.getInstantRange().get().getStartInstantOpt().get()); + assertFalse(deserialized2.getInstantRange().get().getEndInstantOpt().isPresent()); // Verify split3 assertFalse(deserialized3.getInstantRange().isPresent()); @@ -863,15 +863,15 @@ public void testSerializeMultipleSplitsWithDifferentRangeTypes() throws IOExcept assertTrue(deserialized2.getInstantRange().isPresent()); assertEquals(InstantRange.RangeType.OPEN_CLOSED, deserialized2.getInstantRange().get().getRangeType()); - assertEquals("20230201000000000", deserialized2.getInstantRange().get().getStartInstant().get()); - assertEquals("20230228235959999", deserialized2.getInstantRange().get().getEndInstant().get()); + assertEquals("20230201000000000", deserialized2.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230228235959999", deserialized2.getInstantRange().get().getEndInstantOpt().get()); // Verify split3 (CLOSED_CLOSED) assertTrue(deserialized3.getInstantRange().isPresent()); assertEquals(InstantRange.RangeType.CLOSED_CLOSED, deserialized3.getInstantRange().get().getRangeType()); - assertEquals("20230301000000000", deserialized3.getInstantRange().get().getStartInstant().get()); - assertEquals("20230331235959999", deserialized3.getInstantRange().get().getEndInstant().get()); + assertEquals("20230301000000000", deserialized3.getInstantRange().get().getStartInstantOpt().get()); + assertEquals("20230331235959999", deserialized3.getInstantRange().get().getEndInstantOpt().get()); } @Test @@ -954,9 +954,9 @@ public void testSerializeWithClosedClosedRangeOnlyStart() throws IOException { assertTrue(deserialized.getInstantRange().isPresent()); assertEquals(InstantRange.RangeType.CLOSED_CLOSED, deserialized.getInstantRange().get().getRangeType()); - assertTrue(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertFalse(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstant().get()); + assertTrue(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertFalse(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230101000000000", deserialized.getInstantRange().get().getStartInstantOpt().get()); // Verify range behavior - start is inclusive, no end boundary assertTrue(deserialized.getInstantRange().get().isInRange("20230101000000000")); // start inclusive @@ -992,9 +992,9 @@ public void testSerializeWithClosedClosedRangeOnlyEnd() throws IOException { assertTrue(deserialized.getInstantRange().isPresent()); assertEquals(InstantRange.RangeType.CLOSED_CLOSED, deserialized.getInstantRange().get().getRangeType()); - assertFalse(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstant().get()); + assertFalse(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstantOpt().get()); // Verify range behavior - no start boundary, end is inclusive assertTrue(deserialized.getInstantRange().get().isInRange("19700101000000000")); @@ -1029,9 +1029,9 @@ public void testSerializeWithOpenClosedRangeOnlyEnd() throws IOException { assertTrue(deserialized.getInstantRange().isPresent()); assertEquals(InstantRange.RangeType.OPEN_CLOSED, deserialized.getInstantRange().get().getRangeType()); - assertFalse(deserialized.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized.getInstantRange().get().getEndInstant().isPresent()); - assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstant().get()); + assertFalse(deserialized.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized.getInstantRange().get().getEndInstantOpt().isPresent()); + assertEquals("20230131235959999", deserialized.getInstantRange().get().getEndInstantOpt().get()); // Verify range behavior - no start boundary, end is inclusive assertTrue(deserialized.getInstantRange().get().isInRange("19700101000000000")); @@ -1087,23 +1087,23 @@ public void testSerializeWithAllRangeTypesAndNullableBoundaries() throws IOExcep HoodieSourceSplit deserialized4 = serializer.deserialize(serializer.getVersion(), serialized4); // Verify OPEN_CLOSED with only start - assertTrue(deserialized1.getInstantRange().get().getStartInstant().isPresent()); - assertFalse(deserialized1.getInstantRange().get().getEndInstant().isPresent()); + assertTrue(deserialized1.getInstantRange().get().getStartInstantOpt().isPresent()); + assertFalse(deserialized1.getInstantRange().get().getEndInstantOpt().isPresent()); assertEquals(InstantRange.RangeType.OPEN_CLOSED, deserialized1.getInstantRange().get().getRangeType()); // Verify OPEN_CLOSED with only end - assertFalse(deserialized2.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized2.getInstantRange().get().getEndInstant().isPresent()); + assertFalse(deserialized2.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized2.getInstantRange().get().getEndInstantOpt().isPresent()); assertEquals(InstantRange.RangeType.OPEN_CLOSED, deserialized2.getInstantRange().get().getRangeType()); // Verify CLOSED_CLOSED with only start - assertTrue(deserialized3.getInstantRange().get().getStartInstant().isPresent()); - assertFalse(deserialized3.getInstantRange().get().getEndInstant().isPresent()); + assertTrue(deserialized3.getInstantRange().get().getStartInstantOpt().isPresent()); + assertFalse(deserialized3.getInstantRange().get().getEndInstantOpt().isPresent()); assertEquals(InstantRange.RangeType.CLOSED_CLOSED, deserialized3.getInstantRange().get().getRangeType()); // Verify CLOSED_CLOSED with only end - assertFalse(deserialized4.getInstantRange().get().getStartInstant().isPresent()); - assertTrue(deserialized4.getInstantRange().get().getEndInstant().isPresent()); + assertFalse(deserialized4.getInstantRange().get().getStartInstantOpt().isPresent()); + assertTrue(deserialized4.getInstantRange().get().getEndInstantOpt().isPresent()); assertEquals(InstantRange.RangeType.CLOSED_CLOSED, deserialized4.getInstantRange().get().getRangeType()); } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestFlinkStreamerConfig.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestFlinkStreamerConfig.java new file mode 100644 index 0000000000000..446b95558c577 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestFlinkStreamerConfig.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.hudi.streamer; + +import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.configuration.FlinkOptions; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.ParameterException; +import org.apache.flink.configuration.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link FlinkStreamerConfig}. + */ +class TestFlinkStreamerConfig { + + @TempDir + Path tempDir; + + @Test + void testParseAndDeriveFlinkConfiguration() { + FlinkStreamerConfig config = parse( + "--kafka-topic", "orders", + "--kafka-group-id", "flink-writers", + "--kafka-bootstrap-servers", "broker:9092", + "--target-base-path", tempDir.toString(), + "--target-table", "orders_hudi", + "--table-type", "merge_on_read", + "--op", "INSERT", + "--record-key-field", "order_id", + "--partition-path-field", "order_date", + "--source-ordering-fields", "event_ts,seq_no", + "--instant-retry-times", "7", + "--instant-retry-interval", "2", + "--filter-dupes", + "--commit-on-errors", + "--metadata-enabled", + "--write-rate-limit", "500", + "--write-task-num", "6", + "--bucket-assign-num", "5", + "--index-bootstrap-num", "4", + "--source-avro-schema-path", "file:///tmp/source.avsc", + "--source-avro-schema", "{\"type\":\"record\",\"name\":\"order\",\"fields\":[]}", + "--compaction-tasks", "3", + "--clustering-tasks", "2", + "--hive-sync-enable", + "--hive-sync-db", "analytics", + "--hive-sync-table", "orders", + "--hoodie-conf", "hoodie.datasource.write.drop.partition.columns=true"); + + Configuration conf = FlinkStreamerConfig.toFlinkConfig(config); + + assertEquals(tempDir.toString(), conf.get(FlinkOptions.PATH)); + assertEquals("orders_hudi", conf.get(FlinkOptions.TABLE_NAME)); + assertEquals("MERGE_ON_READ", conf.get(FlinkOptions.TABLE_TYPE)); + assertEquals(WriteOperationType.INSERT.value(), conf.get(FlinkOptions.OPERATION)); + assertEquals("order_id", conf.get(FlinkOptions.RECORD_KEY_FIELD)); + assertEquals("order_date", conf.get(FlinkOptions.PARTITION_PATH_FIELD)); + assertEquals("event_ts,seq_no", conf.get(FlinkOptions.ORDERING_FIELDS)); + assertEquals(7, conf.get(FlinkOptions.RETRY_TIMES)); + assertEquals(2_000L, conf.get(FlinkOptions.RETRY_INTERVAL_MS)); + assertTrue(conf.get(FlinkOptions.PRE_COMBINE)); + assertTrue(conf.get(FlinkOptions.IGNORE_FAILED)); + assertTrue(conf.get(FlinkOptions.METADATA_ENABLED)); + assertEquals(500L, conf.get(FlinkOptions.WRITE_RATE_LIMIT)); + assertEquals(6, conf.get(FlinkOptions.WRITE_TASKS)); + assertEquals(5, conf.get(FlinkOptions.BUCKET_ASSIGN_TASKS)); + assertEquals(4, conf.get(FlinkOptions.INDEX_BOOTSTRAP_TASKS)); + assertEquals("file:///tmp/source.avsc", conf.get(FlinkOptions.SOURCE_AVRO_SCHEMA_PATH)); + assertEquals(3, conf.get(FlinkOptions.COMPACTION_TASKS)); + assertEquals(2, conf.get(FlinkOptions.CLUSTERING_TASKS)); + assertTrue(conf.get(FlinkOptions.HIVE_SYNC_ENABLED)); + assertEquals("analytics", conf.get(FlinkOptions.HIVE_SYNC_DB)); + assertEquals("orders", conf.get(FlinkOptions.HIVE_SYNC_TABLE)); + assertEquals("true", + conf.getString("hoodie.datasource.write.drop.partition.columns", null)); + } + + @Test + void testCustomKeyGeneratorTakesPrecedence() { + FlinkStreamerConfig config = parseRequiredOptions( + "--keygen-class", "org.example.CustomKeyGenerator", + "--keygen-type", "COMPLEX"); + + Configuration conf = FlinkStreamerConfig.toFlinkConfig(config); + + assertEquals("org.example.CustomKeyGenerator", conf.get(FlinkOptions.KEYGEN_CLASS_NAME)); + assertFalse(conf.contains(FlinkOptions.KEYGEN_TYPE)); + } + + @Test + void testRequiredOptionsAndNumericValuesAreValidated() { + FlinkStreamerConfig missingRequired = new FlinkStreamerConfig(); + assertThrows(ParameterException.class, + () -> JCommander.newBuilder().addObject(missingRequired).build().parse("--kafka-topic", "orders")); + + FlinkStreamerConfig invalidRetry = parseRequiredOptions("--instant-retry-times", "not-a-number"); + assertThrows(NumberFormatException.class, () -> FlinkStreamerConfig.toFlinkConfig(invalidRetry)); + } + + private FlinkStreamerConfig parseRequiredOptions(String... additionalArgs) { + String[] requiredArgs = { + "--kafka-topic", "orders", + "--kafka-group-id", "flink-writers", + "--kafka-bootstrap-servers", "broker:9092", + "--target-base-path", tempDir.toString(), + "--target-table", "orders_hudi", + "--table-type", "copy_on_write" + }; + String[] args = new String[requiredArgs.length + additionalArgs.length]; + System.arraycopy(requiredArgs, 0, args, 0, requiredArgs.length); + System.arraycopy(additionalArgs, 0, args, requiredArgs.length, additionalArgs.length); + return parse(args); + } + + private static FlinkStreamerConfig parse(String... args) { + FlinkStreamerConfig config = new FlinkStreamerConfig(); + JCommander.newBuilder().addObject(config).build().parse(args); + return config; + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestHoodieFlinkStreamer.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestHoodieFlinkStreamer.java new file mode 100644 index 0000000000000..f848faa0d6ee2 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/streamer/TestHoodieFlinkStreamer.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.hudi.streamer; + +import org.apache.hudi.client.model.HoodieFlinkInternalRow; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.configuration.OptionsInference; +import org.apache.hudi.configuration.OptionsResolver; +import org.apache.hudi.sink.transform.Transformer; +import org.apache.hudi.sink.utils.Pipelines; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.StreamerUtils; + +import org.apache.flink.api.common.ExecutionConfig; +import org.apache.flink.configuration.CheckpointingOptions; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.configuration.StateBackendOptions; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.CheckpointConfig; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests the argument and pipeline wiring in {@link HoodieFlinkStreamer}. + */ +class TestHoodieFlinkStreamer { + + private static final String SOURCE_SCHEMA = + "{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"}]}"; + + @Test + void testAppendPipelineWiringWithTransformer() throws Exception { + StreamExecutionEnvironment env = mockEnvironment(); + DataStream source = mock(DataStream.class); + DataStream transformed = mock(DataStream.class); + DataStream pipeline = mock(DataStream.class); + Transformer transformer = mock(Transformer.class); + when(transformer.apply(source)).thenReturn(transformed); + AtomicReference envConf = new AtomicReference<>(); + + // Static mocks intercept config inference and resolution so this test only exercises entry-point wiring. + try (MockedStatic environments = mockStatic(StreamExecutionEnvironment.class); + MockedStatic streamerUtils = mockStatic(StreamerUtils.class); + MockedStatic streamerUtil = mockStatic(StreamerUtil.class, CALLS_REAL_METHODS); + MockedStatic inference = mockStatic(OptionsInference.class); + MockedStatic resolver = mockStatic(OptionsResolver.class); + MockedStatic pipelines = mockStatic(Pipelines.class)) { + environments.when(() -> StreamExecutionEnvironment.getExecutionEnvironment(any(Configuration.class))) + .thenAnswer(invocation -> { + envConf.set(invocation.getArgument(0)); + return env; + }); + streamerUtils.when(() -> StreamerUtils.createKafkaStream( + same(env), any(RowType.class), eq("orders"), any())).thenReturn(source); + streamerUtil.when(() -> StreamerUtil.createTransformer(anyList())).thenReturn(Option.of(transformer)); + resolver.when(() -> OptionsResolver.isAppendMode(any())).thenReturn(true); + resolver.when(() -> OptionsResolver.needsAsyncClustering(any())).thenReturn(true); + pipelines.when(() -> Pipelines.append(any(), any(RowType.class), same(transformed))).thenReturn(pipeline); + + HoodieFlinkStreamer.main(args( + "--table-type", "COPY_ON_WRITE", + "--op", "INSERT", + "--transformer-class", "org.example.Transformer", + "--flink-checkpoint-path", "file:///tmp/checkpoints")); + + assertEquals("HashMapStateBackend", envConf.get().get(StateBackendOptions.STATE_BACKEND)); + assertEquals("filesystem", envConf.get().get(CheckpointingOptions.CHECKPOINT_STORAGE)); + assertEquals("file:///tmp/checkpoints", + envConf.get().get(CheckpointingOptions.CHECKPOINTS_DIRECTORY)); + pipelines.verify(() -> Pipelines.cluster(any(), any(RowType.class), same(pipeline))); + verify(env).execute("orders_hudi"); + } + } + + @Test + void testAppendPipelineFallbackWiring() throws Exception { + StreamExecutionEnvironment env = mockEnvironment(); + DataStream source = mock(DataStream.class); + DataStream pipeline = mock(DataStream.class); + + try (MockedStatic environments = mockStatic(StreamExecutionEnvironment.class); + MockedStatic streamerUtils = mockStatic(StreamerUtils.class); + MockedStatic inference = mockStatic(OptionsInference.class); + MockedStatic resolver = mockStatic(OptionsResolver.class); + MockedStatic pipelines = mockStatic(Pipelines.class)) { + environments.when(() -> StreamExecutionEnvironment.getExecutionEnvironment(any(Configuration.class))) + .thenReturn(env); + streamerUtils.when(() -> StreamerUtils.createKafkaStream( + same(env), any(RowType.class), eq("orders"), any())).thenReturn(source); + resolver.when(() -> OptionsResolver.isAppendMode(any())).thenReturn(true); + resolver.when(() -> OptionsResolver.needsAsyncClustering(any())).thenReturn(false); + pipelines.when(() -> Pipelines.append(any(), any(RowType.class), same(source))).thenReturn(pipeline); + + resolver.when(() -> OptionsResolver.isLazyFailedWritesCleaning(any())).thenReturn(true); + HoodieFlinkStreamer.main(args("--table-type", "COPY_ON_WRITE", "--op", "INSERT")); + pipelines.verify(() -> Pipelines.clean(any(), same(pipeline))); + + resolver.when(() -> OptionsResolver.isLazyFailedWritesCleaning(any())).thenReturn(false); + HoodieFlinkStreamer.main(args("--table-type", "COPY_ON_WRITE", "--op", "INSERT")); + pipelines.verify(() -> Pipelines.dummySink(same(pipeline))); + } + } + + @Test + void testUpsertPipelineWiringWithCompaction() throws Exception { + StreamExecutionEnvironment env = mockEnvironment(); + DataStream source = mock(DataStream.class); + DataStream bootstrapped = mock(DataStream.class); + DataStream pipeline = mock(DataStream.class); + + // Static mocks intercept config inference and resolution so this test only exercises entry-point wiring. + try (MockedStatic environments = mockStatic(StreamExecutionEnvironment.class); + MockedStatic streamerUtils = mockStatic(StreamerUtils.class); + MockedStatic inference = mockStatic(OptionsInference.class); + MockedStatic resolver = mockStatic(OptionsResolver.class); + MockedStatic pipelines = mockStatic(Pipelines.class)) { + environments.when(() -> StreamExecutionEnvironment.getExecutionEnvironment(any(Configuration.class))) + .thenReturn(env); + streamerUtils.when(() -> StreamerUtils.createKafkaStream( + same(env), any(RowType.class), eq("orders"), any())).thenReturn(source); + resolver.when(() -> OptionsResolver.isAppendMode(any())).thenReturn(false); + resolver.when(() -> OptionsResolver.needsAsyncCompaction(any())).thenReturn(true); + pipelines.when(() -> Pipelines.bootstrap(any(), any(RowType.class), same(source))) + .thenReturn(bootstrapped); + pipelines.when(() -> Pipelines.hoodieStreamWrite(any(), any(RowType.class), same(bootstrapped))) + .thenReturn(pipeline); + + HoodieFlinkStreamer.main(args("--table-type", "MERGE_ON_READ", "--op", "UPSERT")); + + pipelines.verify(() -> Pipelines.compact(any(), same(pipeline))); + verify(env).execute("orders_hudi"); + } + } + + @Test + void testUpsertPipelineFallbackWiring() throws Exception { + StreamExecutionEnvironment env = mockEnvironment(); + DataStream source = mock(DataStream.class); + DataStream bootstrapped = mock(DataStream.class); + DataStream pipeline = mock(DataStream.class); + + try (MockedStatic environments = mockStatic(StreamExecutionEnvironment.class); + MockedStatic streamerUtils = mockStatic(StreamerUtils.class); + MockedStatic inference = mockStatic(OptionsInference.class); + MockedStatic resolver = mockStatic(OptionsResolver.class); + MockedStatic pipelines = mockStatic(Pipelines.class)) { + environments.when(() -> StreamExecutionEnvironment.getExecutionEnvironment(any(Configuration.class))) + .thenReturn(env); + streamerUtils.when(() -> StreamerUtils.createKafkaStream( + same(env), any(RowType.class), eq("orders"), any())).thenReturn(source); + resolver.when(() -> OptionsResolver.isAppendMode(any())).thenReturn(false); + resolver.when(() -> OptionsResolver.needsAsyncCompaction(any())).thenReturn(false); + pipelines.when(() -> Pipelines.bootstrap(any(), any(RowType.class), same(source))) + .thenReturn(bootstrapped); + pipelines.when(() -> Pipelines.hoodieStreamWrite(any(), any(RowType.class), same(bootstrapped))) + .thenReturn(pipeline); + + resolver.when(() -> OptionsResolver.needsAsyncCleaning(any())).thenReturn(true); + HoodieFlinkStreamer.main(args("--table-type", "MERGE_ON_READ", "--op", "UPSERT")); + pipelines.verify(() -> Pipelines.clean(any(), same(pipeline))); + + resolver.when(() -> OptionsResolver.needsAsyncCleaning(any())).thenReturn(false); + HoodieFlinkStreamer.main(args("--table-type", "MERGE_ON_READ", "--op", "UPSERT")); + pipelines.verify(() -> Pipelines.dummySink(same(pipeline))); + } + } + + private static StreamExecutionEnvironment mockEnvironment() { + StreamExecutionEnvironment env = mock(StreamExecutionEnvironment.class); + CheckpointConfig checkpointConfig = mock(CheckpointConfig.class); + ExecutionConfig executionConfig = mock(ExecutionConfig.class); + when(env.getCheckpointConfig()).thenReturn(checkpointConfig); + when(env.getConfig()).thenReturn(executionConfig); + when(checkpointConfig.getCheckpointTimeout()).thenReturn(600_000L); + return env; + } + + private static String[] args(String... additionalArgs) { + String[] requiredArgs = { + "--kafka-topic", "orders", + "--kafka-group-id", "flink-writers", + "--kafka-bootstrap-servers", "broker:9092", + "--target-base-path", "file:///tmp/orders", + "--target-table", "orders_hudi", + "--source-avro-schema", SOURCE_SCHEMA + }; + String[] args = new String[requiredArgs.length + additionalArgs.length]; + System.arraycopy(requiredArgs, 0, args, 0, requiredArgs.length); + System.arraycopy(additionalArgs, 0, args, requiredArgs.length, additionalArgs.length); + return args; + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestBlobWrite.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestBlobWrite.java new file mode 100644 index 0000000000000..a66c9f0347658 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestBlobWrite.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table; + +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.schema.HoodieSchemaUtils; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.util.HoodieSchemaConverter; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.FlinkMiniCluster; + +import org.apache.flink.table.api.TableEnvironment; +import org.apache.flink.table.api.TableResult; +import org.apache.flink.table.api.config.ExecutionConfigOptions; +import org.apache.flink.table.catalog.ResolvedSchema; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.types.Row; +import org.apache.flink.util.CollectionUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.io.File; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * IT case for writing out-of-line (OOL) BLOB columns through the Flink writer and reading them back. + * + *

    Verifies that the Hudi {@link HoodieSchema.Blob} structure round-trips through the Flink + * write/read pipeline (both COW and MOR), that updates land through the MOR Avro log path, and that + * the stored table schema keeps the BLOB logical type instead of degrading the column to a generic + * Flink/Avro record. + */ +@ExtendWith(FlinkMiniCluster.class) +public class ITTestBlobWrite { + + @TempDir + File tempFile; + + private static final String BLOB_COLUMN = "blob_col"; + + private static final String BLOB_COLUMN_DDL = + " " + BLOB_COLUMN + " ROW<\n" + + " `type` STRING NOT NULL,\n" + + " `data` BYTES,\n" + + " `reference` ROW<\n" + + " external_path STRING NOT NULL,\n" + + " `offset` BIGINT,\n" + + " `length` BIGINT,\n" + + " managed BOOLEAN NOT NULL\n" + + " >\n" + + " >,\n"; + + /** + * Regression for {@code HoodieSchemaConverter#isBlobStructure}: Flink SQL {@code CREATE TABLE} + * may declare nested {@code ROW} fields as {@code NOT NULL}, but + * {@link ResolvedSchema#toPhysicalRowDataType()} does not always preserve those constraints in + * the {@link RowType}. Schema inference must still recognize the BLOB shape (same path as + * {@code HoodieTableFactory#inferAvroSchema}), otherwise {@code blob_col} would degrade to a + * generic RECORD in the committed Hoodie schema. + */ + @Test + public void testFlinkSqlDdlPhysicalRowTypeStillMapsToHoodieBlob() { + TableEnvironment tableEnv = batchEnv(); + final String probeTable = "flink_blob_ddl_probe"; + String createProbeDdl = + "CREATE TABLE " + + probeTable + + " (\n" + + " id BIGINT,\n" + + " name STRING,\n" + + BLOB_COLUMN_DDL + + " ts BIGINT\n" + + ") WITH ('connector'='blackhole')"; + tableEnv.executeSql(createProbeDdl); + + ResolvedSchema resolved = tableEnv.from(probeTable).getResolvedSchema(); + RowType physical = (RowType) resolved.toPhysicalRowDataType().getLogicalType(); + // DDL uses NOT NULL on nested BLOB ROW fields where the canonical Hoodie BLOB shape is + // stricter. Flink's physical RowType from ResolvedSchema#toPhysicalRowDataType() may or may + // not preserve those flags across versions (see Flink table config / release notes linked in + // the PR). We do not assert which fields widen — only that Hudi still recognizes the column as + // a BLOB (regression for HoodieSchemaConverter#isBlobStructure). + + HoodieSchema recordSchema = + HoodieSchemaConverter.convertToSchema( + physical, HoodieSchemaUtils.getRecordQualifiedName(probeTable)); + HoodieSchemaField blobField = + recordSchema + .getField(BLOB_COLUMN) + .orElseThrow(() -> new AssertionError("blob_col missing from converted HoodieSchema")); + assertTrue( + blobField.schema().isBlobField(), + "Physical RowType from Flink SQL DDL must still map to Hoodie BLOB, got: " + + blobField.schema()); + + tableEnv.executeSql("DROP TABLE " + probeTable); + } + + private void createTable(TableEnvironment tableEnv, String tablePath, HoodieTableType tableType) { + String createTableDdl = String.format( + "CREATE TABLE blob_table (\n" + + " id BIGINT,\n" + + " name STRING,\n" + + BLOB_COLUMN_DDL + + " ts BIGINT,\n" + + " PRIMARY KEY (id) NOT ENFORCED\n" + + ") WITH (\n" + + " 'connector' = 'hudi',\n" + + " 'path' = '%s',\n" + + " 'table.type' = '%s',\n" + + " 'ordering.fields' = 'ts'\n" + + ");", + tablePath, tableType.name()); + tableEnv.executeSql(createTableDdl); + } + + @ParameterizedTest + @EnumSource(value = HoodieTableType.class) + public void testWriteAndReadOutOfLineBlob(HoodieTableType tableType) throws Exception { + TableEnvironment tableEnv = batchEnv(); + String tablePath = new File(tempFile, "blob_table").getAbsolutePath(); + createTable(tableEnv, tablePath, tableType); + + // First batch: insert two OOL blob references. + execInsert(tableEnv, + "INSERT INTO blob_table VALUES\n" + + "(1, 'doc-1', ROW('OUT_OF_LINE', CAST(NULL AS BYTES), " + + "ROW('file1.bin', CAST(0 AS BIGINT), CAST(100 AS BIGINT), false)), 1000),\n" + + "(2, 'doc-2', ROW('OUT_OF_LINE', CAST(NULL AS BYTES), " + + "ROW('file1.bin', CAST(100 AS BIGINT), CAST(200 AS BIGINT), false)), 2000)"); + + List rows = readOrdered(tableEnv); + assertEquals(2, rows.size()); + assertOutOfLineRow(rows.get(0), 1L, "doc-1", "file1.bin", 0L, 100L, 1000L); + assertOutOfLineRow(rows.get(1), 2L, "doc-2", "file1.bin", 100L, 200L, 2000L); + + // Second batch: upsert the same keys with new references. For MOR this exercises the Avro + // log write path (RowData -> Avro), including the BLOB enum `type` field. + execInsert(tableEnv, + "INSERT INTO blob_table VALUES\n" + + "(1, 'doc-1', ROW('OUT_OF_LINE', CAST(NULL AS BYTES), " + + "ROW('file2.bin', CAST(500 AS BIGINT), CAST(300 AS BIGINT), false)), 3000),\n" + + "(2, 'doc-2', ROW('OUT_OF_LINE', CAST(NULL AS BYTES), " + + "ROW('file2.bin', CAST(800 AS BIGINT), CAST(400 AS BIGINT), false)), 4000)"); + + List updated = readOrdered(tableEnv); + assertEquals(2, updated.size()); + assertOutOfLineRow(updated.get(0), 1L, "doc-1", "file2.bin", 500L, 300L, 3000L); + assertOutOfLineRow(updated.get(1), 2L, "doc-2", "file2.bin", 800L, 400L, 4000L); + + // The stored table schema must keep the BLOB logical type, not a generic record. + assertBlobTypePreserved(tablePath); + } + + private static List readOrdered(TableEnvironment tableEnv) { + return CollectionUtil.iteratorToList( + tableEnv.executeSql("select id, name, blob_col, ts from blob_table order by id").collect()); + } + + private static void assertOutOfLineRow(Row row, long id, String name, String path, + long offset, long length, long ts) { + assertEquals(id, row.getField(0)); + assertEquals(name, row.getField(1)); + Row blob = (Row) row.getField(2); + assertNotNull(blob, "blob struct must be populated"); + assertEquals("OUT_OF_LINE", blob.getField(0)); + assertNull(blob.getField(1), "inline data must be null for OUT_OF_LINE blob"); + Row reference = (Row) blob.getField(2); + assertNotNull(reference, "reference must be populated for OUT_OF_LINE blob"); + assertEquals(path, reference.getField(0)); + assertEquals(offset, reference.getField(1)); + assertEquals(length, reference.getField(2)); + assertEquals(false, reference.getField(3)); + assertEquals(ts, row.getField(3)); + } + + private static void assertBlobTypePreserved(String tablePath) throws Exception { + HoodieTableMetaClient metaClient = + StreamerUtil.createMetaClient(tablePath, new org.apache.hadoop.conf.Configuration()); + HoodieSchema tableSchema = new TableSchemaResolver(metaClient).getTableSchema(); + HoodieSchemaField blobField = tableSchema.getField(BLOB_COLUMN) + .orElseThrow(() -> new AssertionError("blob_col field missing from table schema")); + assertTrue(blobField.schema().isBlobField(), + "blob_col must keep the BLOB logical type, found: " + blobField.schema()); + } + + private static TableEnvironment batchEnv() { + TableEnvironment tableEnv = org.apache.hudi.utils.TestTableEnvs.getBatchTableEnv(); + tableEnv.getConfig().getConfiguration() + .set(ExecutionConfigOptions.TABLE_EXEC_RESOURCE_DEFAULT_PARALLELISM, 1); + return tableEnv; + } + + private static void execInsert(TableEnvironment tableEnv, String insertSql) throws Exception { + TableResult result = tableEnv.executeSql(insertSql); + result.getJobClient().get().getJobExecutionResult().get(120, TimeUnit.SECONDS); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestDynamicBucketStreamWrite.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestDynamicBucketStreamWrite.java index 83b5b7f73cdfe..6fb0dceb6fa93 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestDynamicBucketStreamWrite.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestDynamicBucketStreamWrite.java @@ -166,8 +166,13 @@ void testInsertOverwrite(HoodieTableType tableType) { @ParameterizedTest @EnumSource(value = HoodieTableType.class) void testBucketScalesUpWithContinuousWrites(HoodieTableType tableType) { + Map smallBucketOptions = Map.of( + HoodieCompactionConfig.COPY_ON_WRITE_INSERT_SPLIT_SIZE.key(), "1", + FlinkOptions.WRITE_PARQUET_MAX_FILE_SIZE.key(), "1", + HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "1", + HoodieCompactionConfig.COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), String.valueOf(1024 * 1024)); streamTableEnv.executeSql(getTableDDL( - "t1", tableType, Collections.singletonMap(HoodieCompactionConfig.COPY_ON_WRITE_INSERT_SPLIT_SIZE.key(), "1"), true)); + "t1", tableType, smallBucketOptions, true)); execInsertSql(streamTableEnv, "insert into t1 values\n" + "('id1','Danny',23,TIMESTAMP '1970-01-01 00:00:01','par_scale'),\n" diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java index 46b9353e0ec24..b48afb7d96b81 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java @@ -21,7 +21,6 @@ import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.model.DefaultHoodieRecordPayload; -import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableConfig; @@ -29,16 +28,21 @@ import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.cdc.HoodieCDCSupplementalLoggingMode; import org.apache.hudi.common.table.marker.MarkerType; +import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.TimelineUtils; +import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.bucket.partition.PartitionBucketIndexUtils; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.sink.buffer.BufferMemoryType; import org.apache.hudi.sink.buffer.BufferType; import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; import org.apache.hudi.table.catalog.HoodieCatalogTestUtils; import org.apache.hudi.table.catalog.HoodieHiveCatalog; import org.apache.hudi.util.StreamerUtil; @@ -65,7 +69,6 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.types.Row; import org.apache.flink.util.CollectionUtil; -import org.apache.flink.util.ExceptionUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -76,6 +79,8 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; @@ -91,8 +96,11 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -113,6 +121,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertLinesMatch; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -121,6 +130,15 @@ */ @ExtendWith(FlinkMiniCluster.class) public class ITTestHoodieDataSource { + private static final Logger LOG = LoggerFactory.getLogger(ITTestHoodieDataSource.class); + + // A streaming read collected via CollectTableSink is terminated by a forced SuccessException once it + // reaches its expected row count. A benign teardown race (see isAcceptableTerminalFailure) can instead + // close the source stream mid-read and terminate the job before all rows are emitted, leaving an + // incomplete result. Re-reading from the same (already committed) table is idempotent, so retry a few + // times before giving up. See submitAndFetchWithRetry. + private static final int MAX_STREAM_READ_ATTEMPTS = 3; + private TableEnvironment streamTableEnv; private TableEnvironment batchTableEnv; @@ -555,7 +573,7 @@ void testStreamReadWithDeletes() throws Exception { + " 'connector' = '" + CollectSinkTableFactory.FACTORY_ID + "',\n" + " 'sink-expected-row-num' = '2'" + ")"; - List result = execSelectSqlWithExpectedNum(streamTableEnv, "select name, sum(age) from t1 group by name", sinkDDL); + List result = submitAndFetchWithRetry(streamTableEnv, "select name, sum(age) from t1 group by name", sinkDDL, 2); final String expected = "[+I(+I[Danny, 24]), +I(+I[Stephen, 34])]"; assertRowsEquals(result, expected, true); } @@ -714,6 +732,280 @@ void testStreamReadMorTableWithCompactionPlan(boolean useSourceV2) throws Except assertRowsEquals(rows, TestData.DATA_SET_SOURCE_INSERT); } + /** + * Regression test for HUDI: data loss in stream read from earliest when + * {@code read.streaming.skip_compaction = true} on a MOR table with completed + * compaction commits. Covers the streaming earliest full table scan branch in + * {@link org.apache.hudi.source.IncrementalInputSplits#inputSplits(HoodieTableMetaClient, String, boolean)}. + * + *

    Triggering condition: + *

      + *
    • {@code read.start-commit = earliest} (no instant range -> full table scan path);
    • + *
    • {@code read.streaming.skip_compaction = true} (active timeline filtered out compaction);
    • + *
    • MOR table with at least one completed compaction commit that produced a + * new base file from existing log files.
    • + *
    + * + *

    Construction: + *

      + *
    1. Offline write {@code DATA_SET_INSERT} (8 records, ids 1..8) and then + * {@code DATA_SET_UPDATE_INSERT} (8 records, where ids 1..5 update existing keys + * and ids 9..11 are new) via {@link TestData#writeDataAsBatch}, which deterministically + * triggers an inline compaction once {@code COMPACTION_DELTA_COMMITS = 1} + + * {@code COMPACTION_ASYNC_ENABLED = true} are set. After this step the table has + * both a base file (from compaction) and log files written by the UPDATE batch.
    2. + *
    3. Streaming read from earliest with {@code skip_compaction = true} and wait until + * the expected number of merged rows are received. Without the fix, the FS view used + * in the earliest full-table-scan branch is built from a compaction-filtered + * timeline, file slice boundaries are wrongly computed, log files are missed + * and the read will never reach the expected row count (the test would time out).
    4. + *
    + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testStreamReadMorTableWithCompactionFromEarliest(boolean useSourceV2) throws Exception { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.TABLE_NAME, "t1"); + conf.set(FlinkOptions.TABLE_TYPE, MERGE_ON_READ.name()); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()); + conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2); + // mandatory for writeDataAsBatch#inlineCompaction to actually run a compaction + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + + // Step 1: offline-write two batches with deterministic inline compaction in between. + TestData.writeDataAsBatch(TestData.DATA_SET_INSERT, conf); + TestData.writeDataAsBatch(TestData.DATA_SET_UPDATE_INSERT, conf); + + // Step 2: streaming read from earliest with skip_compaction = true. + String hoodieTableDDL = sql("t1") + .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) + .options(getDefaultKeys()) + .option(FlinkOptions.TABLE_TYPE, MERGE_ON_READ) + .option(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()) + .option(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2) + .option(FlinkOptions.READ_AS_STREAMING, true) + .option(FlinkOptions.READ_START_COMMIT, FlinkOptions.START_COMMIT_EARLIEST) + .option(FlinkOptions.READ_STREAMING_CHECK_INTERVAL, 2) + .option(FlinkOptions.READ_SOURCE_V2_ENABLED, useSourceV2) + // skip compaction instant -> active timeline drops compaction commit + .option(FlinkOptions.READ_STREAMING_SKIP_COMPACT, true) + .end(); + streamTableEnv.executeSql(hoodieTableDDL); + + // After the UPDATE batch, the merged result must contain all up-to-date records: + // - 5 updated records (id1..id5 from DATA_SET_UPDATE_INSERT) + // - 3 carried-over records (id6, id7, id8 from DATA_SET_INSERT, not touched by UPDATE) + // - 3 newly inserted records (id9, id10, id11 from DATA_SET_UPDATE_INSERT) + // i.e. 11 records in total. Without the fix the streaming read would never reach + // expectedNum = 11 and the test would time out via the CollectSink. + final int expectedNum = 11; + List rows = execSelectSqlWithExpectedNum(streamTableEnv, "select * from t1", expectedNum); + assertEquals(expectedNum, rows.size(), + "Expect 11 up-to-date records to be visible after earliest streaming read" + + " with skip_compaction on a MOR table that has a completed compaction commit" + + ", actual rows: " + rows); + } + + /** + * Regression test for HUDI: data loss in batch read from earliest when + * {@code read.streaming.skip_compaction = true} on a MOR table with completed + * compaction commits. Covers the batch full-table-scan branch in + * {@link org.apache.hudi.source.IncrementalInputSplits#inputSplits(HoodieTableMetaClient, boolean)}. + * + *

    This complements {@link #testStreamReadMorTableWithCompactionFromEarliest(boolean)} + * which only exercises the streaming code path. Without the fix, building the + * {@link org.apache.hudi.common.table.view.HoodieTableFileSystemView} with a + * compaction-filtered timeline would mis-classify file slice boundaries and + * lose log files. + */ + @Test + void testBatchReadMorTableWithCompactionFromEarliest() throws Exception { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.TABLE_NAME, "t1"); + conf.set(FlinkOptions.TABLE_TYPE, MERGE_ON_READ.name()); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()); + conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2); + // mandatory for writeDataAsBatch#inlineCompaction to actually run a compaction + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + + // Offline-write two batches against overlapping record keys, the 2nd write triggers + // an inline compaction that merges existing log files into a new base file - exactly + // the scenario that exposes the buggy file-slice classification when skip_compaction + // is enabled. + TestData.writeDataAsBatch(TestData.DATA_SET_INSERT, conf); + TestData.writeDataAsBatch(TestData.DATA_SET_UPDATE_INSERT, conf); + + String hoodieTableDDL = sql("t1") + .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) + .options(getDefaultKeys()) + .option(FlinkOptions.TABLE_TYPE, MERGE_ON_READ) + .option(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()) + .option(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2) + .option(FlinkOptions.READ_START_COMMIT, FlinkOptions.START_COMMIT_EARLIEST) + // skip compaction instant -> active timeline drops compaction commit + .option(FlinkOptions.READ_STREAMING_SKIP_COMPACT, true) + .end(); + batchTableEnv.executeSql(hoodieTableDDL); + + List result = CollectionUtil.iteratorToList( + batchTableEnv.executeSql("select * from t1").collect()); + // After update, the merged result must contain all up-to-date records: + // - 5 updated records (id1..id5 from DATA_SET_UPDATE_INSERT) + // - 3 carried-over records (id6, id7, id8 from DATA_SET_INSERT, not touched by UPDATE) + // - 3 newly inserted records (id9, id10, id11 from DATA_SET_UPDATE_INSERT) + // i.e. 11 records in total. Without the fix, log files belonging to the file slice prior + // to the inline compaction would be silently dropped by the file system view because + // the active timeline filtered out the compaction commit, and the result size would be + // smaller than 11. + assertEquals(11, result.size(), + "Expect all up-to-date records to be visible after earliest + skip_compaction batch read" + + ", actual rows: " + result); + } + + /** + * Regression test for HUDI: data loss when the start commit has been archived + * and {@code read.streaming.skip_compaction = true} on a MOR table. + * Covers the batch "fallback to full table scan" branch in + * {@link org.apache.hudi.source.IncrementalInputSplits#inputSplits(HoodieTableMetaClient, boolean)} + * which is reached when {@code hasArchivedInstants == true}. + * + *

    Construction: + *

      + *
    1. Write 10 delta-commit batches of {@code (id1,id2), (id3,id4), ...} on a MOR table + * so that each batch only inserts new keys (clear, predictable per-commit semantics).
    2. + *
    3. Trigger one completed compaction commit by issuing an extra UPDATE batch on + * {@code id1..id4} with {@code COMPACTION_DELTA_COMMITS = 1} via + * {@link TestData#writeDataAsBatch} (which explicitly calls {@code inlineCompaction()}). + * This creates exactly the file-slice boundary that the buggy FS view would + * mis-classify.
    4. + *
    5. Pick the LAST archived delta-commit as {@code read.start-commit} (filtered by + * {@code action = deltacommit} to exclude any archived compaction {@code commit} + * instants). This is deterministic regardless of how many delta commits were + * archived by the cleaner+archiver and routes the reader through the + * "archived start commit -> fullTableScan" branch.
    6. + *
    7. Read with {@code skip_compaction = true} and assert on the SET of record-keys + * in the result (not just on count). The expected key set is derived dynamically + * from the timeline: every delta_commit whose completion time is >= the chosen + * start_commit contributes its written ids, plus id1..id4 from the UPDATE batch + * are always present because the UPDATE is the latest write. Without the fix, + * log files of the file slice that straddles the compaction commit are silently + * dropped, so some of these ids would be missing.
    8. + *
    + */ + @Test + void testBatchReadMorTableWithCompactionStartCommitArchived() throws Exception { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.TABLE_NAME, "t1"); + conf.set(FlinkOptions.RECORD_KEY_FIELD, "uuid"); + conf.set(FlinkOptions.ORDERING_FIELDS, "ts"); + conf.set(FlinkOptions.TABLE_TYPE, MERGE_ON_READ.name()); + conf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()); + conf.set(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2); + // aggressive archival to force older instants out of the active timeline + conf.set(FlinkOptions.ARCHIVE_MIN_COMMITS, 4); + conf.set(FlinkOptions.ARCHIVE_MAX_COMMITS, 5); + conf.set(FlinkOptions.CLEAN_RETAIN_COMMITS, 3); + conf.setString("hoodie.commits.archival.batch", "1"); + + // Step 1: write 10 batches of 2 new records each -> 10 delta_commit instants, 20 distinct keys. + for (int i = 0; i < 20; i += 2) { + List dataset = TestData.dataSetInsert(i + 1, i + 2); + TestData.writeData(dataset, conf); + } + + // Step 2: trigger at least one completed compaction commit by issuing one more delta_commit + // that UPDATES the very first record keys (id1..id4) and enabling COMPACTION_DELTA_COMMITS=1. + // The update writes new log files for the file group that contains id1..id4, and the inline + // compaction merges them into a new base file -> a real compaction file-slice boundary. + // NOTE: use writeDataAsBatch (which explicitly calls inlineCompaction()), since the plain + // writeData helper does not run the compaction even with COMPACTION_DELTA_COMMITS=1. + conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true); + conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + TestData.writeDataAsBatch(TestData.dataSetInsert(1, 2, 3, 4), conf); + + // Step 3: list the full timeline in one shot to map start_commit -> expected id set. + // Delta_commit instants are strictly monotonically increasing, so the sorted list of all + // delta_commits across active + archived timelines gives a 1:1 mapping to the 10 batches + // written in Step 1: the k-th delta_commit wrote id_{2k+1} and id_{2k+2}. + HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient( + new HadoopStorageConfiguration(HadoopConfigurations.getHadoopConf(new Configuration())), + tempFile.getAbsolutePath()); + // Use the merged (archived + active) timeline to capture all delta_commits, + // even those that may have been archived by the aggressive archival settings. + List batchInstantTimes = TimelineUtils.getTimeline(metaClient, true) + .getCommitsTimeline().filterCompletedInstants() + .filter(instant -> HoodieTimeline.DELTA_COMMIT_ACTION.equals(instant.getAction())) + .getInstantsAsStream().map(HoodieInstant::requestedTime).collect(Collectors.toList()); + // Step 1 produced exactly 10 delta_commits; the 11th (if present) is from Step 2 UPDATE. + // Keep only the first 10 to build the batch-index -> id mapping. + assertTrue(batchInstantTimes.size() >= 10, + "Expected at least 10 delta_commits from Step 1, got " + batchInstantTimes.size()); + batchInstantTimes = batchInstantTimes.subList(0, 10); + + // Step 4: pick the LAST archived delta_commit that belongs to Step 1's batches as + // start commit. This avoids any drift caused by archival ordering or by compaction + // `commit` instants being interleaved with delta_commits in the archived timeline, + // and also ignores the Step 2 UPDATE batch in case it also got archived. + Set step1InstantTimeSet = new TreeSet<>(batchInstantTimes); + List archivedDeltaCommits = metaClient.getArchivedTimeline().getCommitsTimeline() + .filterCompletedInstants() + .filter(instant -> HoodieTimeline.DELTA_COMMIT_ACTION.equals(instant.getAction())) + .filter(instant -> step1InstantTimeSet.contains(instant.requestedTime())) + .getInstants(); + // make sure archival actually happened on Step 1's batches, otherwise the test premise + // (the reader hits the archived start commit + fullTableScan branch) does not hold. + assertTrue(!archivedDeltaCommits.isEmpty(), + "archival did not happen as expected on Step 1's batches, archived delta commits = " + + archivedDeltaCommits + ", Step 1 batch instant times = " + batchInstantTimes); + HoodieInstant startInstant = archivedDeltaCommits.get(archivedDeltaCommits.size() - 1); + String archivedStartInstant = startInstant.requestedTime(); + + // The expected key set: every Step 1 batch whose instant time is >= start_commit contributes + // its 2 ids; plus id1..id4 from the Step 2 UPDATE batch (always the latest write, never + // excluded since its completion time is the largest). + int firstIncludedBatchIdx = batchInstantTimes.indexOf(archivedStartInstant); + assertTrue(firstIncludedBatchIdx >= 0, + "chosen start_commit " + archivedStartInstant + " is not one of the Step 1 batch instant times " + batchInstantTimes); + Set expectedIds = new TreeSet<>(); + for (int i = firstIncludedBatchIdx; i < batchInstantTimes.size(); i++) { + expectedIds.add("id" + (2 * i + 1)); + expectedIds.add("id" + (2 * i + 2)); + } + // UPDATE batch ids — always present in the merged view because the UPDATE is the latest write. + expectedIds.add("id1"); + expectedIds.add("id2"); + expectedIds.add("id3"); + expectedIds.add("id4"); + + String hoodieTableDDL = sql("t1") + .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) + .options(getDefaultKeys()) + .option(FlinkOptions.TABLE_TYPE, MERGE_ON_READ) + .option(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.BUCKET.name()) + .option(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS, 2) + .option(FlinkOptions.READ_START_COMMIT, archivedStartInstant) + .option(FlinkOptions.READ_STREAMING_SKIP_COMPACT, true) + .end(); + batchTableEnv.executeSql(hoodieTableDDL); + + List result = CollectionUtil.iteratorToList( + batchTableEnv.executeSql("select uuid from t1").collect()); + Set actualIds = new TreeSet<>(); + for (Row r : result) { + actualIds.add(r.getField(0).toString()); + } + // Without the fix, the FS view used to construct file slices for the fallback full-table-scan + // branch is built from a compaction-filtered timeline, so log files of the file slice that + // straddles the compaction commit are silently dropped and some ids would be missing from + // {@code actualIds}. With the fix, every expected id must be present. + assertEquals(expectedIds, actualIds, + "Expected id set " + expectedIds + " but got " + actualIds + + " when reading from archived start commit " + archivedStartInstant + + " with skip_compaction = true on a MOR table that has a completed compaction commit"); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void testStreamReadMorTableWithBucketIndex(boolean partitioned) throws Exception { @@ -908,9 +1200,22 @@ void testLookupJoin(HoodieTableType tableType, String cacheType, boolean async) + " join t1/*+ OPTIONS('lookup.join.cache.ttl'='2 day', 'lookup.async'='" + async + "'," + " 'lookup.join.cache.type'='" + cacheType + "') */ " + " FOR SYSTEM_TIME AS OF o.proc_time AS b on o.uuid = b.uuid"; - execInsertSql(tableEnv, sql); - List result = CollectionUtil.iterableToList( - () -> tableEnv.sqlQuery("select * from t2").execute().collect()); + + // The lookup function loads the dimension table lazily on the first probe row, so a teardown / + // commit-visibility race can occasionally make the join emit no rows. Re-running the upsert into + // the uuid-keyed table t2 is idempotent, so retry until the expected rows materialize. + final int expectedNum = TestData.DATA_SET_SOURCE_INSERT.size(); + List result = Collections.emptyList(); + for (int attempt = 1; attempt <= MAX_STREAM_READ_ATTEMPTS; attempt++) { + execInsertSql(tableEnv, sql); + result = CollectionUtil.iterableToList( + () -> tableEnv.sqlQuery("select * from t2").execute().collect()); + if (result.size() >= expectedNum) { + break; + } + LOG.warn("testLookupJoin collected {} of {} rows on attempt {}/{}; a teardown race produced an " + + "empty lookup join. Retrying.", result.size(), expectedNum, attempt, MAX_STREAM_READ_ATTEMPTS); + } assertRowsEquals(result, TestData.DATA_SET_SOURCE_INSERT); } @@ -1258,8 +1563,8 @@ void testWriteNonPartitionedTable(ExecMode execMode, HoodieTableType tableType) } @ParameterizedTest - @EnumSource(value = HoodieIndex.IndexType.class, names = {"FLINK_STATE", "GLOBAL_RECORD_LEVEL_INDEX"}) - void testWriteGlobalIndex(HoodieIndex.IndexType indexType) { + @MethodSource("indexAndBooleanParams") + void testWriteGlobalIndex(String indexType, boolean bootstrapEnabled) { // the source generates 4 commits String createSource = TestConfigurations.getFileSourceDDL( "source", "test_source_4.data", 4); @@ -1269,7 +1574,8 @@ void testWriteGlobalIndex(HoodieIndex.IndexType indexType) { .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) .options(getDefaultKeys()) .option(FlinkOptions.INDEX_GLOBAL_ENABLED, true) - .option(FlinkOptions.INDEX_TYPE, indexType.name()) + .option(FlinkOptions.INDEX_TYPE, indexType) + .option(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, bootstrapEnabled) .option(FlinkOptions.PRE_COMBINE, true) .end(); streamTableEnv.executeSql(hoodieTableDDL); @@ -1360,31 +1666,27 @@ void testBatchReadEmptyTablePath() throws Exception { } @Test - void testLanceFormatRejectedByFlink() { - // Lance base file format is only supported with the Spark engine. - // Flink should reject it early with a clear error on both read and write paths. - String createLanceTable = sql("lance_t1") + void testLanceFormatAppendOnlyWriteAndRead() { + String createHoodieTable = sql("lance_t1") .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) - .options(getDefaultKeys()) + .option(FlinkOptions.OPERATION, "insert") .option("hoodie.table.base.file.format", "LANCE") .end(); + batchTableEnv.executeSql(createHoodieTable); - // Creating the table itself succeeds (DDL is just metadata registration), - // but any attempt to read or write should fail. - // Flink wraps our HoodieValidationException in its own ValidationException. - batchTableEnv.executeSql(createLanceTable); + execInsertSql(batchTableEnv, "insert into lance_t1 values " + + "('id1', 'Alice', 23, TIMESTAMP '1970-01-01 00:00:01', 'par1')," + + "('id2', 'Bob', 31, TIMESTAMP '1970-01-01 00:00:02', 'par2')"); - // Source (read) path should throw - ValidationException readEx = assertThrows(ValidationException.class, - () -> execSelectSql(batchTableEnv, "select * from lance_t1"), - "Lance format should be rejected when reading via Flink"); - assertTrue(ExceptionUtils.findThrowableWithMessage(readEx, HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG).isPresent()); + List rows = CollectionUtil.iteratorToList( + batchTableEnv.executeSql("select uuid, name, age, ts, `partition` from lance_t1").collect()); + assertRowsEquals(rows, + "[+I[id1, Alice, 23, 1970-01-01T00:00:01, par1], " + + "+I[id2, Bob, 31, 1970-01-01T00:00:02, par2]]"); - // Sink (write) path should throw - ValidationException writeEx = assertThrows(ValidationException.class, - () -> execInsertSql(batchTableEnv, "insert into lance_t1 values ('id1', 'Alice', 23, TIMESTAMP '1970-01-01 00:00:01', 'par1')"), - "Lance format should be rejected when writing via Flink"); - assertTrue(ExceptionUtils.findThrowableWithMessage(writeEx, HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG).isPresent()); + List projectedRows = CollectionUtil.iteratorToList( + batchTableEnv.executeSql("select name, uuid from lance_t1").collect()); + assertRowsEquals(projectedRows, "[+I[Alice, id1], +I[Bob, id2]]"); } @ParameterizedTest @@ -2074,6 +2376,124 @@ void testParquetArrayMapOfRowTypes(String operation) { assertRowsEqualsUnordered(expected, result); } + @Test + void testParquetNestedRowExceedingReadBatch() { + // Regression for NestedColumnReader#readRow throwing ArrayIndexOutOfBoundsException when a COW + // base file holds more rows than the 2048-row vectorized read batch + // (RecordIterators.DEFAULT_BATCH_SIZE) and a nested ROW column is read. On a full, non-final + // batch the Dremel level stream carries a one-record lookahead, so NestedPositionUtil + // #calculateRowOffsets returns positionsCount = batchSize + 1 = 2049 while the materialized + // child column vectors are sized to their value count = 2048. The Hudi-specific null-row-collapse + // loop iterates to positionsCount and reads child.isNullAt(2048), one past a length-2048 vector. + // + // Two conditions are both required to surface it, and drove this schema and data: + // 1. The bad index is only reached through AbstractHeapVector#isNullAt, which short-circuits to + // false without touching isNull[] when the vector has no nulls. So a child vector must + // actually carry a null. Odd-id rows therefore store a present ROW with all-null children + // (row(null, ...)); the row stays present (its own isNullAt(2048) short-circuits) but the + // child leaf vectors get noNulls=false and overrun at the phantom index. Half the rows are + // null-children so the first full batch is guaranteed to contain them regardless of how + // bulk_insert orders keys. + // 2. The nullable leaves must be *direct* children of the collapsed row. A sub-row child would + // be renewed to positionsCount (length 2049) and not overrun, so the two nested rows are + // top-level columns: f_scalar row(f0 int, f1 varchar(10)) covers heap-vector children, and + // f_dec row(d decimal(10, 2)) covers a decimal child, whose ParquetDecimalVector is not an + // AbstractHeapVector and must be unwrapped by NestedColumnReader#vectorLength. + // See ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes for the collapse behaviour. + TableEnvironment tableEnv = batchTableEnv; + + // More rows than one 2048-row read batch, so the first batch is full and non-final -- that is + // what makes the level stream carry the trailing lookahead that overshoots the vectors. The + // rows are generated by cross joining two small VALUES lists rather than a single 2000+-row + // VALUES literal: Calcite plans the latter pathologically slowly (minutes to hours), while two + // ~50-element lists plan instantly and the row count is simply their product. + final int outer = 43; + final int inner = 50; + final int numRows = outer * inner; // 2150 > 2048 + + String hoodieTableDDL = sql("t1") + .field("f_int int") + .field("f_scalar row(f0 int, f1 varchar(10))") + .field("f_dec row(d decimal(10, 2))") + .pkField("f_int") + .noPartition() + .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) + .option(FlinkOptions.OPERATION, "bulk_insert") + // Single write task => all rows land in one base file, so one read split crosses the + // 2048-row batch boundary. + .option(FlinkOptions.WRITE_TASKS, 1) + .end(); + tableEnv.executeSql(hoodieTableDDL); + + // id = blk * inner + pos is unique over blk in [0, outer), pos in [0, inner) => 0 .. numRows-1. + // Both nested rows stay present; even ids get populated leaves, odd ids get all-null leaves + // (which the reader collapses back to a NULL row). Each ROW is cast to its named type so the + // query output type matches the sink column exactly. + String insert = "insert into t1 select\n" + + " g.id,\n" + + " cast(row(\n" + + " case when mod(g.id, 2) = 0 then g.id else cast(null as int) end,\n" + + " case when mod(g.id, 2) = 0 then concat('v', cast(g.id as varchar)) else cast(null as varchar(10)) end\n" + + " ) as row),\n" + + " cast(row(\n" + + " case when mod(g.id, 2) = 0 then cast(g.id as decimal(10, 2)) else cast(null as decimal(10, 2)) end\n" + + " ) as row)\n" + + "from (\n" + + " select blk.b * " + inner + " + pos.p as id\n" + + " from (values " + valuesList(outer) + ") as blk(b)\n" + + " cross join (values " + valuesList(inner) + ") as pos(p)\n" + + ") g"; + execInsertSql(tableEnv, insert); + + List result = CollectionUtil.iterableToList( + () -> tableEnv.sqlQuery("select * from t1").execute().collect()); + + // The read completes (no AIOOBE across the batch boundary) and every row is returned. Without + // the fix the vectorized read throws while materializing the first full batch, so this fails. + assertEquals(numRows, result.size()); + + // bulk_insert does not preserve order, so index by pk. + Map byId = new HashMap<>(); + for (Row r : result) { + byId.put((Integer) r.getField(0), r); + } + // Populated rows (even id) round-trip both nested rows -- one from the first (full) batch and + // one with a large id past the boundary. + assertPopulatedRow(byId.get(0), 0); + assertPopulatedRow(byId.get(numRows - 2), numRows - 2); + // All-null-children rows (odd id) collapse both nested rows back to NULL, including a large id. + assertCollapsedRow(byId.get(1)); + assertCollapsedRow(byId.get(numRows - 1)); + } + + /** Builds the VALUES row list {@code (0), (1), ..., (n-1)} for the generator cross join. */ + private static String valuesList(int n) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < n; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('(').append(i).append(')'); + } + return sb.toString(); + } + + /** Asserts the row keyed by an even {@code id} round-trips its populated nested rows. */ + private static void assertPopulatedRow(Row row, int id) { + assertNotNull(row, "row with pk " + id + " was not read back"); + Row scalar = (Row) row.getField(1); + assertEquals(id, scalar.getField(0)); + assertEquals("v" + id, scalar.getField(1)); + assertNotNull(((Row) row.getField(2)).getField(0)); // decimal leaf present, not null + } + + /** Asserts the row keyed by an odd {@code id} had both all-null nested rows collapsed to NULL. */ + private static void assertCollapsedRow(Row row) { + assertNotNull(row, "expected an odd-id row to be read back"); + assertNull(row.getField(1)); // f_scalar collapsed to null + assertNull(row.getField(2)); // f_dec collapsed to null + } + @ParameterizedTest @ValueSource(strings = {"insert", "upsert", "bulk_insert"}) void testParquetNullChildColumnsRowTypes(String operation) { @@ -2100,6 +2520,36 @@ void testParquetNullChildColumnsRowTypes(String operation) { assertRowsEquals(result, expected); } + @ParameterizedTest + @ValueSource(strings = {"insert", "upsert", "bulk_insert"}) + void testParquetDeeplyNestedRepeatedTypes(String operation) { + // Covers a ROW containing an ARRAY of ROW that itself contains a MAP, i.e. + // ROW>>>, where the MAP is a repeated field + // nested inside another repeated field (repetition level >= 2). + // See HUDI-18491 for the original bug report on this schema shape. + TableEnvironment tableEnv = batchTableEnv; + + String hoodieTableDDL = sql("t1") + .field("f_int int") + .field("f_row row(f_nested_array array)>)") + .pkField("f_int") + .noPartition() + .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) + .option(FlinkOptions.OPERATION, operation) + .end(); + tableEnv.executeSql(hoodieTableDDL); + + execInsertSql(tableEnv, TestSQL.DEEPLY_NESTED_REPEATED_TYPE_INSERT_T1); + + List result = CollectionUtil.iterableToList( + () -> tableEnv.sqlQuery("select * from t1").execute().collect()); + List expected = Arrays.asList( + row(1, row((Object) array(row(11, map("a", 1, "b", 2)), row(12, map("c", 3))))), + row(2, row((Object) array(row(21, map("d", 4))))), + row(3, row((Object) array(row(31, map("e", 5)), row(32, map("f", 6, "g", 7)))))); + assertRowsEqualsUnordered(expected, result); + } + @ParameterizedTest @ValueSource(strings = {"insert", "upsert", "bulk_insert"}) void testBuiltinFunctionWithCatalog(String operation) { @@ -3141,6 +3591,7 @@ void testRLIBootstrap() { .option(FlinkOptions.PATH, tempFile.getAbsolutePath()) .options(getDefaultKeys()) .option(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name()) + .option(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true) .option(FlinkOptions.READ_DATA_SKIPPING_ENABLED, true) .option(FlinkOptions.TABLE_TYPE, MERGE_ON_READ.name()) .end(); @@ -3442,6 +3893,18 @@ private static Stream indexAndPartitioningParams() { return Stream.of(data).map(Arguments::of); } + /** + * Return test params => (index type, boolean). + */ + private static Stream indexAndBooleanParams() { + Object[][] data = + new Object[][] { + {"FLINK_STATE", false}, + {"GLOBAL_RECORD_LEVEL_INDEX", false}, + {"GLOBAL_RECORD_LEVEL_INDEX", true}}; + return Stream.of(data).map(Arguments::of); + } + /** * Return test params => (index type, table type). */ @@ -3520,15 +3983,33 @@ private List execSelectSqlWithExpectedNum(TableEnvironment tEnv, String sel } else { sinkDDL = TestConfigurations.getCollectSinkDDLWithExpectedNum("sink", expectedNum); } - return execSelectSqlWithExpectedNum(tEnv, select, sinkDDL); + return submitAndFetchWithRetry(tEnv, select, sinkDDL, expectedNum); } /** - * Use CollectTableSink to collect results with expected row number. + * Submits a streaming select that collects into the {@link CollectSinkTableFactory} sink and returns + * the collected rows. + * + *

    The streaming job is terminated by a forced {@link CollectSinkTableFactory.SuccessException} once + * {@code expectedNum} rows are collected. On a slow CI shard the {@code await} window can elapse before + * the sink reaches {@code expectedNum} (a bare timeout - see {@link #isAwaitTimeout}), leaving a short + * result. Re-reading the already committed table is idempotent, so retry up to + * {@link #MAX_STREAM_READ_ATTEMPTS} times when the result is short; this keeps a slow shard from + * surfacing as a confusing row-count (or "Unexpected job failure") assertion failure. */ - private List execSelectSqlWithExpectedNum(TableEnvironment tEnv, String select, String sinkDDL) { - TableResult tableResult = submitSelectSql(tEnv, select, sinkDDL); - return fetchResultWithExpectedNum(tEnv, tableResult); + private List submitAndFetchWithRetry(TableEnvironment tEnv, String select, String sinkDDL, int expectedNum) { + List rows = Collections.emptyList(); + for (int attempt = 1; attempt <= MAX_STREAM_READ_ATTEMPTS; attempt++) { + TableResult tableResult = submitSelectSql(tEnv, select, sinkDDL); + rows = fetchResultWithExpectedNum(tEnv, tableResult); + if (expectedNum <= 0 || rows.size() >= expectedNum) { + return rows; + } + LOG.warn("Streaming read collected {} of {} expected rows on attempt {}/{}; a tolerated teardown " + + "race ended the job before the read completed. Retrying. select=[{}]", + rows.size(), expectedNum, attempt, MAX_STREAM_READ_ATTEMPTS, select); + } + return rows; } private TableResult submitSelectSql(TableEnvironment tEnv, String select, String sinkDDL) { @@ -3552,14 +4033,70 @@ private List execSelectSql(TableEnvironment tEnv, String select, long timeo private List fetchResultWithExpectedNum(TableEnvironment tEnv, TableResult tableResult) { try { // wait the continuous streaming query to be terminated by forced exception with expected row number - // and max waiting timeout is 30s - tableResult.await(30, TimeUnit.SECONDS); + // and max waiting timeout is 60s (kept generous so a slow CI shard does not time out before the + // sink collects its rows; a bare timeout is still handled as a retryable short read below) + tableResult.await(60, TimeUnit.SECONDS); } catch (Throwable e) { - ExceptionUtils.assertThrowable(e, CollectSinkTableFactory.SuccessException.class); + // The only acceptable terminal cause is the sink reaching its expected row count and throwing + // SuccessException to terminate the streaming job (the happy path). The Source V2 read path now + // reads and closes each split's I/O on a single (split-fetcher) thread, so the former teardown + // races (a closed Parquet stream / a closed CDC iterator surfacing on the task thread) can no + // longer happen; any other terminal failure is a real error and fails the test. + // + // A bare await-window TimeoutException is not a terminal failure at all - the sink simply had not + // reached expectedNum yet (typically CI-load slowness), so the job is still running. Cancel it and + // let submitAndFetchWithRetry re-submit a fresh job, rather than treating a slow shard as a hard + // failure. + if (isAwaitTimeout(e)) { + // Cancel the still-running job (best-effort, bounded) so it cannot keep writing to the shared + // CollectSinkTableFactory.RESULT after the retry's re-submit clears it. + tableResult.getJobClient().ifPresent(jobClient -> { + try { + jobClient.cancel().get(30, TimeUnit.SECONDS); + } catch (Exception ignored) { + // best-effort cancel; the subsequent re-submit clears RESULT and starts a fresh job + } + }); + LOG.warn("Streaming read did not reach the expected row count within the await window; " + + "cancelled the job and will retry. Collected {} rows so far.", + CollectSinkTableFactory.RESULT.values().stream().mapToInt(List::size).sum()); + } else if (!isSuccessException(e)) { + throw new AssertionError("Unexpected job failure", e); + } } tEnv.executeSql("DROP TABLE IF EXISTS sink"); return CollectSinkTableFactory.RESULT.values().stream() .flatMap(Collection::stream) .collect(Collectors.toList()); } + + /** + * Whether {@code e} is a bare {@link TimeoutException} thrown directly by + * {@link org.apache.flink.table.api.TableResult#await(long, TimeUnit)} - i.e. the await window elapsed + * before the sink reached its expected row count and threw + * {@link CollectSinkTableFactory.SuccessException}, leaving the job still running (never terminated), + * so the caller cancels it and retries the read rather than swallowing it. + * + *

    Only the top-level exception is inspected, never the cause chain: {@code await} throws its own + * timeout bare, whereas a genuine job failure arrives wrapped in an {@link ExecutionException} that + * may itself embed a {@link TimeoutException} (checkpoint expiry, RPC timeout). Walking the chain + * would misclassify such a real failure as a slow shard - cancel it, retry, and finally report a + * row-count mismatch with the true cause discarded. + */ + private static boolean isAwaitTimeout(Throwable e) { + return e instanceof TimeoutException; + } + + /** + * Whether {@code e} (or any of its causes) is the normal {@link CollectSinkTableFactory.SuccessException} + * terminator (the happy path), as opposed to one of the tolerated teardown-race symptoms. + */ + private static boolean isSuccessException(Throwable e) { + for (Throwable cur = e; cur != null; cur = cur.getCause()) { + if (cur instanceof CollectSinkTableFactory.SuccessException) { + return true; + } + } + return false; + } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestVariantCrossEngineCompatibility.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestVariantCrossEngineCompatibility.java index 9aade5503b4cc..e7f9ed3766f1a 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestVariantCrossEngineCompatibility.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestVariantCrossEngineCompatibility.java @@ -18,6 +18,8 @@ package org.apache.hudi.table; +import org.apache.hudi.adapter.DataTypeAdapter; +import org.apache.hudi.adapter.DataTypeAdapterTestUtils; import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.utils.FlinkMiniCluster; @@ -39,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; + /** * Integration test for cross-engine compatibility - verifying that Flink can read Variant tables written by Spark 4.0. */ @@ -50,19 +53,15 @@ public class ITTestVariantCrossEngineCompatibility { /** * Helper method to verify that Flink can read Spark 4.0 Variant tables. - * Variant data is represented as ROW in Flink. */ private void verifyFlinkCanReadSparkVariantTable(String tablePath, String tableType, String testDescription) throws Exception { TableEnvironment tableEnv = TestTableEnvs.getBatchTableEnv(); - // Create a Hudi table pointing to the Spark-written data - // In Flink, Variant is represented as ROW - // NOTE: value is a reserved keyword String createTableDdl = String.format( "CREATE TABLE variant_table (" + " id INT," + " name STRING," - + " v ROW," + + " v VARIANT," + " ts BIGINT," + " PRIMARY KEY (id) NOT ENFORCED" + ") WITH (" @@ -74,7 +73,6 @@ private void verifyFlinkCanReadSparkVariantTable(String tablePath, String tableT tableEnv.executeSql(createTableDdl); - // Query the table to verify Flink can read the data TableResult result = tableEnv.executeSql("SELECT id, name, v, ts FROM variant_table ORDER BY id"); List rows = CollectionUtil.iteratorToList(result.collect()); @@ -86,29 +84,27 @@ private void verifyFlinkCanReadSparkVariantTable(String tablePath, String tableT assertEquals("row1", row.getField(1), "Second column should be name=row1"); assertEquals(1000L, row.getField(3), "Fourth column should be ts=1000"); - // Verify the variant column is readable as a ROW with binary fields - Row variantRow = (Row) row.getField(2); - assertNotNull(variantRow, "Variant column should not be null"); - - byte[] metadataBytes = (byte[]) variantRow.getField(0); - byte[] valueBytes = (byte[]) variantRow.getField(1); + // Verify the variant column is readable as a native Flink Variant. + Object variantObject = row.getField(2); + assertNotNull(variantObject, "Variant column should not be null"); + DataTypeAdapterTestUtils.assertAsBinaryVariant(variantObject); // Expected byte values from Spark 4.0 Variant representation: {"updated": true, "new_field": 123} byte[] expectedValueBytes = new byte[]{0x02, 0x02, 0x01, 0x00, 0x01, 0x00, 0x03, 0x04, 0x0C, 0x7B}; byte[] expectedMetadataBytes = new byte[]{0x01, 0x02, 0x00, 0x07, 0x10, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x6E, 0x65, 0x77, 0x5F, 0x66, 0x69, 0x65, 0x6C, 0x64}; - assertArrayEquals(expectedValueBytes, valueBytes, + assertArrayEquals(expectedValueBytes, DataTypeAdapter.getVariantValue(variantObject), String.format("Variant value bytes mismatch (%s). Expected: %s, Got: %s", testDescription, Arrays.toString(StringUtils.encodeHex(expectedValueBytes)), - Arrays.toString(StringUtils.encodeHex(valueBytes)))); + Arrays.toString(StringUtils.encodeHex(DataTypeAdapter.getVariantValue(variantObject))))); - assertArrayEquals(expectedMetadataBytes, metadataBytes, + assertArrayEquals(expectedMetadataBytes, DataTypeAdapter.getVariantMetadata(variantObject), String.format("Variant metadata bytes mismatch (%s). Expected: %s, Got: %s", testDescription, Arrays.toString(StringUtils.encodeHex(expectedMetadataBytes)), - Arrays.toString(StringUtils.encodeHex(metadataBytes)))); + Arrays.toString(StringUtils.encodeHex(DataTypeAdapter.getVariantMetadata(variantObject))))); tableEnv.executeSql("DROP TABLE variant_table"); } @@ -139,4 +135,4 @@ public void testFlinkReadSparkVariantMORTableWithSpark() throws Exception { String morSparkPath = morSparkTargetDir.resolve("variant_mor_spark").toString(); verifyFlinkCanReadSparkVariantTable(morSparkPath, "MERGE_ON_READ", "MOR table with SPARK record type"); } -} \ No newline at end of file +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieFileGroupReaderOnFlink.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieFileGroupReaderOnFlink.java index 7a8ce8dcd80a4..84e1136bc2965 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieFileGroupReaderOnFlink.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieFileGroupReaderOnFlink.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.config.RecordMergeMode; import org.apache.hudi.common.engine.HoodieReaderContext; +import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.schema.HoodieSchema; @@ -57,6 +58,7 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.StringData; import org.apache.flink.table.runtime.typeutils.RowDataSerializer; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -85,6 +87,12 @@ public class TestHoodieFileGroupReaderOnFlink extends TestHoodieFileGroupReaderB private Configuration conf; private Option instantRangeOpt = Option.empty(); + @BeforeAll + public static void setUpClass() { + // add the lance format when composition type is supported + supportedFileFormats = Collections.singletonList(HoodieFileFormat.PARQUET); + } + @BeforeEach public void setup() { conf = new Configuration(); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java index f594e9575860c..a85d3d29b6aed 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/TestHoodieTableFactory.java @@ -18,10 +18,10 @@ package org.apache.hudi.table; +import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.DefaultHoodieRecordPayload; import org.apache.hudi.common.model.EventTimeAvroPayload; -import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.exception.HoodieValidationException; @@ -785,31 +785,80 @@ void testSetupWriteOptionsForSink() { (HoodieTableSink) new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(this.conf)); Configuration conf2 = tableSink2.getConf(); assertThat(conf2.get(FlinkOptions.PRE_COMBINE), is(false)); + + // Global RLI setup should not enable index bootstrap implicitly. + Configuration globalRLIConf = new Configuration(this.conf); + globalRLIConf.set(FlinkOptions.OPERATION, "upsert"); + globalRLIConf.set(FlinkOptions.INDEX_TYPE, HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name()); + globalRLIConf.set(FlinkOptions.METADATA_ENABLED, true); + globalRLIConf.set(FlinkOptions.INDEX_GLOBAL_ENABLED, true); + HoodieTableSink globalRLISink = + (HoodieTableSink) new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(globalRLIConf)); + Configuration globalRLIResolvedConf = globalRLISink.getConf(); + assertThat(globalRLIResolvedConf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED), is(false)); + + globalRLIConf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); + HoodieTableSink globalRLIWithBootstrapSink = + (HoodieTableSink) new HoodieTableFactory().createDynamicTableSink(MockContext.getInstance(globalRLIConf)); + Configuration globalRLIWithBootstrapResolvedConf = globalRLIWithBootstrapSink.getConf(); + assertThat(globalRLIWithBootstrapResolvedConf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED), is(true)); } @Test - void testLanceFormatNotSupportedByFlink() { - // Lance base file format is only supported with the Spark engine. - // Both source and sink should reject it with a clear error message. - this.conf.setString("hoodie.table.base.file.format", "LANCE"); - ResolvedSchema schema = SchemaBuilder.instance() + void testLanceFormatSupportedForAppendOnlyTables() { + Configuration lanceConf = new Configuration(); + lanceConf.set(FlinkOptions.PATH, new File(tempFile, "lance").getAbsolutePath()); + lanceConf.set(FlinkOptions.TABLE_NAME, "lance_t1"); + lanceConf.set(FlinkOptions.OPERATION, "insert"); + lanceConf.setString("hoodie.table.base.file.format", "LANCE"); + ResolvedSchema appendOnlySchema = SchemaBuilder.instance() .field("f0", DataTypes.INT().notNull()) .field("f1", DataTypes.VARCHAR(20)) .field("f2", DataTypes.TIMESTAMP(3)) .field("ts", DataTypes.TIMESTAMP(3)) + .build(); + final MockContext appendOnlyContext = MockContext.getInstance(lanceConf, appendOnlySchema, "f2"); + + assertDoesNotThrow(() -> new HoodieTableFactory().createDynamicTableSink(appendOnlyContext)); + + Configuration morConf = new Configuration(lanceConf); + morConf.set(FlinkOptions.TABLE_TYPE, FlinkOptions.TABLE_TYPE_MERGE_ON_READ); + final MockContext morContext = MockContext.getInstance(morConf, appendOnlySchema, "f2"); + HoodieValidationException morEx = assertThrows(HoodieValidationException.class, + () -> new HoodieTableFactory().createDynamicTableSink(morContext)); + assertThat(morEx.getMessage(), is("Flink Lance base-file support is only available for COPY_ON_WRITE append-only tables.")); + + Configuration upsertConf = new Configuration(lanceConf); + upsertConf.set(FlinkOptions.OPERATION, "upsert"); + final MockContext upsertContext = MockContext.getInstance(upsertConf, appendOnlySchema, "f2"); + HoodieValidationException operationEx = assertThrows(HoodieValidationException.class, + () -> new HoodieTableFactory().createDynamicTableSink(upsertContext)); + assertThat(operationEx.getMessage(), is("Flink Lance base-file writes require append-only INSERT mode. Set '" + + FlinkOptions.OPERATION.key() + "' = 'insert'.")); + + Configuration schemaEvolutionConf = new Configuration(lanceConf); + schemaEvolutionConf.setString(HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key(), "true"); + final MockContext schemaEvolutionContext = MockContext.getInstance(schemaEvolutionConf, appendOnlySchema, "f2"); + HoodieValidationException schemaEvolutionEx = assertThrows(HoodieValidationException.class, + () -> new HoodieTableFactory().createDynamicTableSink(schemaEvolutionContext)); + assertThat(schemaEvolutionEx.getMessage(), is("Flink Lance base-file support does not support schema evolution. Set '" + + HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key() + "' = 'false'.")); + + ResolvedSchema primaryKeySchema = SchemaBuilder.instance() + .field("f0", DataTypes.INT().notNull()) + .field("f1", DataTypes.VARCHAR(20)) .primaryKey("f0") .build(); - final MockContext context = MockContext.getInstance(this.conf, schema, "f2"); - - // Source path should throw - HoodieValidationException sourceEx = assertThrows(HoodieValidationException.class, - () -> new HoodieTableFactory().createDynamicTableSource(context)); - assertThat(sourceEx.getMessage(), is(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG)); + final MockContext primaryKeyContext = MockContext.getInstance(lanceConf, primaryKeySchema, "f1"); + HoodieValidationException primaryKeyEx = assertThrows(HoodieValidationException.class, + () -> new HoodieTableFactory().createDynamicTableSink(primaryKeyContext)); + assertThat(primaryKeyEx.getMessage(), is("Flink Lance base-file support is only available for append-only tables without primary keys.")); - // Sink path should throw + lanceConf.set(FlinkOptions.RECORD_KEY_FIELD, "f0"); + final MockContext keyedContext = MockContext.getInstance(lanceConf, appendOnlySchema, "f2"); HoodieValidationException sinkEx = assertThrows(HoodieValidationException.class, - () -> new HoodieTableFactory().createDynamicTableSink(context)); - assertThat(sinkEx.getMessage(), is(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG)); + () -> new HoodieTableFactory().createDynamicTableSink(keyedContext)); + assertThat(sinkEx.getMessage(), is("Flink Lance base-file support is only available for append-only tables without primary keys.")); } // ------------------------------------------------------------------------- diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java index 9934d8a4e1605..2edc2f1255a59 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java @@ -20,6 +20,7 @@ import org.apache.hudi.common.model.DefaultHoodieRecordPayload; import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.model.HoodieReplaceCommitMetadata; import org.apache.hudi.common.model.PartitionBucketIndexHashingConfig; import org.apache.hudi.common.schema.HoodieSchema; @@ -45,7 +46,6 @@ import org.apache.hudi.utils.TestConfigurations; import org.apache.hudi.utils.TestData; -import org.apache.flink.calcite.shaded.com.google.common.collect.Lists; import org.apache.flink.configuration.Configuration; import org.apache.flink.core.fs.Path; import org.apache.flink.table.api.DataTypes; @@ -282,7 +282,7 @@ public void testCreateTable() throws Exception { final ResolvedCatalogTable singleKeyMultiplePartitionTable = new ResolvedCatalogTable( CatalogUtils.createCatalogTable( Schema.newBuilder().fromResolvedSchema(CREATE_TABLE_SCHEMA).build(), - Lists.newArrayList("par1", "par2"), + Arrays.asList("par1", "par2"), EXPECTED_OPTIONS, "test"), CREATE_TABLE_SCHEMA @@ -300,7 +300,7 @@ public void testCreateTable() throws Exception { final ResolvedCatalogTable multipleKeySinglePartitionTable = new ResolvedCatalogTable( CatalogUtils.createCatalogTable( Schema.newBuilder().fromResolvedSchema(CREATE_MULTI_KEY_TABLE_SCHEMA).build(), - Lists.newArrayList("par1"), + Collections.singletonList("par1"), EXPECTED_OPTIONS, "test"), CREATE_TABLE_SCHEMA @@ -380,6 +380,35 @@ public void testCreateNonAppendTableWithoutRecordKey() { assertEquals("Primary key definition is missing", exception.getMessage()); } + @Test + public void testCreateAppendOnlyLanceTableWithoutPrimaryKey() throws Exception { + ObjectPath tablePath = new ObjectPath(TEST_DEFAULT_DATABASE, "tb_lance_append_only"); + Map lanceOptions = new HashMap<>(EXPECTED_OPTIONS); + lanceOptions.put(FlinkOptions.TABLE_TYPE.key(), FlinkOptions.TABLE_TYPE_COPY_ON_WRITE); + lanceOptions.put(FlinkOptions.OPERATION.key(), "insert"); + lanceOptions.put(FlinkOptions.PRE_COMBINE.key(), "false"); + lanceOptions.put(HoodieTableConfig.BASE_FILE_FORMAT.key(), HoodieFileFormat.LANCE.name()); + ResolvedSchema appendOnlySchema = new ResolvedSchema(CREATE_COLUMNS, Collections.emptyList(), null); + ResolvedCatalogTable lanceTable = new ResolvedCatalogTable( + CatalogUtils.createCatalogTable( + Schema.newBuilder().fromResolvedSchema(appendOnlySchema).build(), + Arrays.asList("partition"), + lanceOptions, + "test_lance_append_only"), + appendOnlySchema + ); + + catalog.createTable(tablePath, lanceTable, false); + + assertTrue(catalog.tableExists(tablePath)); + CatalogBaseTable actualTable = catalog.getTable(tablePath); + assertFalse(actualTable.getOptions().containsKey(TableOptionProperties.PK_COLUMNS)); + HoodieTableMetaClient metaClient = createMetaClient( + new HadoopStorageConfiguration(HadoopConfigurations.getHadoopConf(new Configuration())), + catalog.inferTablePath(catalogPathStr, tablePath)); + assertThat(metaClient.getTableConfig().getBaseFileFormat(), is(HoodieFileFormat.LANCE)); + } + @Test void testCreateTableWithPartitionBucketIndex() throws TableAlreadyExistException, DatabaseNotExistException, IOException { String rule = "regex"; diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieHiveCatalog.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieHiveCatalog.java index 7f67a0281e5cd..6f778b9bc2974 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieHiveCatalog.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieHiveCatalog.java @@ -43,7 +43,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.flink.calcite.shaded.com.google.common.collect.Lists; import org.apache.flink.table.api.DataTypes; import org.apache.flink.table.api.Schema; import org.apache.flink.table.catalog.AbstractCatalog; @@ -71,6 +70,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -126,7 +126,7 @@ public class TestHoodieHiveCatalog extends BaseTestHoodieCatalog { .column("par2", DataTypes.STRING()) .primaryKey("uuid") .build(); - List multiPartitions = Lists.newArrayList("par1", "par2"); + List multiPartitions = Arrays.asList("par1", "par2"); private static HoodieHiveCatalog hoodieCatalog; private final ObjectPath tablePath = new ObjectPath(TEST_DEFAULT_DATABASE, "test"); @@ -276,7 +276,7 @@ public void testCreateAndGetHoodieTable(HoodieTableType tableType) throws Except assertEquals(keyGeneratorClassName, NonpartitionedAvroKeyGenerator.class.getName()); // validate the order of partition fields in the multi-partition table - List multiPartitions = Lists.newArrayList("par2", "par1"); + List multiPartitions = Arrays.asList("par2", "par1"); ObjectPath multiPartitionsTablePath = new ObjectPath("default", "tb_mp_" + System.currentTimeMillis()); CatalogTable multiPartitionsTable = CatalogUtils.createCatalogTable(singleKeyMultiPartitionTableSchema, multiPartitions, options, "multi-partition hudi table"); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestFlinkRowDataReaderContext.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestFlinkRowDataReaderContext.java index 189e297024fb4..751f63d6de75a 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestFlinkRowDataReaderContext.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestFlinkRowDataReaderContext.java @@ -28,7 +28,6 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.source.ExpressionPredicates; import org.apache.hudi.storage.StorageConfiguration; -import org.apache.hudi.storage.StoragePath; import org.apache.flink.table.data.GenericRowData; import org.apache.flink.table.data.RowData; @@ -44,7 +43,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -134,14 +132,6 @@ void testConstructEngineRecordWithNullUpdate() { assertTrue(result.getBoolean(2)); } - @Test - void testLanceFormatThrowsInGetFileRecordIterator() { - StoragePath lancePath = new StoragePath("/tmp/test-table/partition/file.lance"); - UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, - () -> readerContext.getFileRecordIterator(lancePath, 0, 100, SCHEMA, SCHEMA, null)); - assertEquals(HoodieFileFormat.LANCE_SPARK_ONLY_ERROR_MSG, ex.getMessage()); - } - private GenericRowData createBaseRow(int id, String name, boolean active) { return GenericRowData.of(id, StringData.fromString(name), active); } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java new file mode 100644 index 0000000000000..6787713449f68 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.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.hudi.table.lookup; + +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.util.StreamerUtil; +import org.apache.hudi.utils.TestConfigurations; +import org.apache.hudi.utils.TestData; + +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.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link HoodieLookupFunction}. + */ +class TestHoodieLookupFunction { + + @TempDir + File tempFile; + + @Test + void testNextLoadTimeAdvancesWhenNoCompletedCommit() throws Exception { + Configuration conf = getConf(); + StreamerUtil.initTableIfNotExists(conf); + + CountingLookupTableReader reader = new CountingLookupTableReader(Collections.emptyList(), conf); + HoodieLookupFunction function = newLookupFunction(reader, conf); + function.open(null); + + long beforeLoad = System.currentTimeMillis(); + try { + function.lookup(lookupKey()); + + assertEquals(0, reader.openCount, "The reader should not open when no completed commit exists"); + assertTrue(getNextLoadTime(function) >= beforeLoad + Duration.ofDays(1).toMillis(), + "The next lookup reload check should be delayed by the configured TTL"); + } finally { + function.close(); + } + } + + @Test + void testLookupCacheDoesNotReloadWhenCompletedCommitHasNotChanged() throws Exception { + Configuration conf = getConf(); + TestData.writeData(TestData.DATA_SET_SINGLE_INSERT, conf); + + CountingLookupTableReader reader = new CountingLookupTableReader(TestData.DATA_SET_SINGLE_INSERT, conf); + HoodieLookupFunction function = newLookupFunction(reader, conf); + function.open(null); + + try { + Collection matchedRows = function.lookup(lookupKey()); + assertNotNull(matchedRows, "The first lookup should find the inserted row"); + assertEquals(1, matchedRows.size(), "The first lookup should load the table into cache"); + assertEquals(1, reader.openCount, "The first lookup should open the reader once"); + + // Force the next lookup through the reload branch so the unchanged-commit guard is exercised. + setNextLoadTime(function, 0L); + function.lookup(lookupKey()); + + assertEquals(1, reader.openCount, "The same completed commit should not reload table data"); + assertTrue(getNextLoadTime(function) > System.currentTimeMillis(), + "The next lookup reload check should be delayed after detecting an unchanged commit"); + } finally { + function.close(); + } + } + + @Test + void testReaderIsClosedWhenCacheReloadFails() throws Exception { + Configuration conf = getConf(); + TestData.writeData(TestData.DATA_SET_SINGLE_INSERT, conf); + + FailingLookupTableReader reader = new FailingLookupTableReader(conf); + HoodieLookupFunction function = newLookupFunction(reader, conf); + function.open(null); + + Thread.currentThread().interrupt(); + try { + assertThrows(RuntimeException.class, () -> function.lookup(lookupKey())); + assertEquals(1, reader.closeCount, "The failed reload attempt should close the reader"); + } finally { + Thread.interrupted(); + function.close(); + } + } + + private HoodieLookupFunction newLookupFunction(HoodieLookupTableReader reader, Configuration conf) { + return new HoodieLookupFunction( + reader, + TestConfigurations.ROW_TYPE, + new int[] {0}, + Duration.ofDays(1), + conf); + } + + private Configuration getConf() { + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.set(FlinkOptions.LOOKUP_JOIN_CACHE_TTL, Duration.ofDays(1)); + return conf; + } + + private static RowData lookupKey() { + return GenericRowData.of(StringData.fromString("id1")); + } + + private static long getNextLoadTime(HoodieLookupFunction function) throws Exception { + Field field = HoodieLookupFunction.class.getDeclaredField("nextLoadTime"); + field.setAccessible(true); + return field.getLong(function); + } + + private static void setNextLoadTime(HoodieLookupFunction function, long nextLoadTime) throws Exception { + Field field = HoodieLookupFunction.class.getDeclaredField("nextLoadTime"); + field.setAccessible(true); + field.setLong(function, nextLoadTime); + } + + private static class CountingLookupTableReader extends HoodieLookupTableReader { + private final List rows; + private int openCount; + private int nextIndex; + + private CountingLookupTableReader(List rows, Configuration conf) { + super(() -> null, conf); + this.rows = rows; + } + + @Override + public void open() { + openCount++; + nextIndex = 0; + } + + @Override + public RowData read(RowData reuse) { + if (nextIndex >= rows.size()) { + return null; + } + return rows.get(nextIndex++); + } + + @Override + public void close() throws IOException { + // no-op + } + } + + private static class FailingLookupTableReader extends HoodieLookupTableReader { + private int closeCount; + + private FailingLookupTableReader(Configuration conf) { + super(() -> null, conf); + } + + @Override + public void open() { + // no-op + } + + @Override + public RowData read(RowData reuse) throws IOException { + throw new IOException("expected"); + } + + @Override + public void close() { + closeCount++; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupTableReader.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupTableReader.java new file mode 100644 index 0000000000000..35c650d34d0ab --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupTableReader.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.hudi.table.lookup; + +import org.apache.hudi.exception.HoodieIOException; + +import org.apache.flink.api.common.io.RichInputFormat; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.core.io.InputSplit; +import org.apache.flink.table.data.RowData; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link HoodieLookupTableReader}. + */ +class TestHoodieLookupTableReader { + + @Test + @SuppressWarnings("unchecked") + void testOpenRollsBackPartiallyOpenedInputFormat() throws Exception { + RichInputFormat inputFormat = mock(RichInputFormat.class); + InputSplit inputSplit = mock(InputSplit.class); + when(inputFormat.createInputSplits(1)).thenReturn(new InputSplit[] {inputSplit}); + IOException openException = new IOException("expected open failure"); + doThrow(openException).when(inputFormat).open(inputSplit); + + HoodieLookupTableReader reader = + new HoodieLookupTableReader(() -> inputFormat, new Configuration()); + + assertSame(openException, assertThrows(IOException.class, reader::open)); + verify(inputFormat).close(); + verify(inputFormat).closeInputFormat(); + + reader.close(); + verify(inputFormat, times(1)).close(); + verify(inputFormat, times(1)).closeInputFormat(); + } + + @Test + @SuppressWarnings("unchecked") + void testOpenPreservesFailureWhenRuntimeRollbackFails() throws Exception { + RichInputFormat inputFormat = mock(RichInputFormat.class); + InputSplit inputSplit = mock(InputSplit.class); + when(inputFormat.createInputSplits(1)).thenReturn(new InputSplit[] {inputSplit}); + IOException openException = new IOException("expected open failure"); + HoodieIOException splitCloseException = + new HoodieIOException("expected runtime split close failure"); + doThrow(openException).when(inputFormat).open(inputSplit); + doThrow(splitCloseException).when(inputFormat).close(); + + HoodieLookupTableReader reader = + new HoodieLookupTableReader(() -> inputFormat, new Configuration()); + + IOException exception = assertThrows(IOException.class, reader::open); + assertSame(openException, exception); + assertEquals(1, exception.getSuppressed().length); + assertSame(splitCloseException, exception.getSuppressed()[0]); + verify(inputFormat).closeInputFormat(); + } + + @Test + @SuppressWarnings("unchecked") + void testCloseReleasesInputFormatWhenRuntimeSplitCloseFails() throws Exception { + RichInputFormat inputFormat = mock(RichInputFormat.class); + InputSplit inputSplit = mock(InputSplit.class); + when(inputFormat.createInputSplits(1)).thenReturn(new InputSplit[] {inputSplit}); + HoodieIOException splitCloseException = + new HoodieIOException("expected runtime split close failure"); + IOException formatCloseException = new IOException("expected format close failure"); + doThrow(splitCloseException).when(inputFormat).close(); + doThrow(formatCloseException).when(inputFormat).closeInputFormat(); + + HoodieLookupTableReader reader = + new HoodieLookupTableReader(() -> inputFormat, new Configuration()); + reader.open(); + + HoodieIOException exception = assertThrows(HoodieIOException.class, reader::close); + assertSame(splitCloseException, exception); + assertEquals(1, exception.getSuppressed().length); + assertSame(formatCloseException, exception.getSuppressed()[0]); + verify(inputFormat).closeInputFormat(); + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestFlinkWriteClients.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestFlinkWriteClients.java index 0762bd66838d0..4bc9db0850de7 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestFlinkWriteClients.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestFlinkWriteClients.java @@ -37,6 +37,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.marker.MarkerType; +import org.apache.hudi.config.HoodieArchivalConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.io.FileGroupReaderBasedMergeHandle; @@ -120,6 +121,18 @@ void testUserConfiguredGlobalRecordIndexMinFileGroupCountIsNotOverridden() { assertEquals(12, writeConfig.getGlobalRecordLevelIndexMinFileGroupCount()); } + @Test + void testUserConfiguredMigrationCommitArchivalBatchSizeIsPropagated() { + // A raw hoodie.* property set on the Flink configuration must be propagated to the write config + // (and therefore reach the upgrade handler that reads it during the v7 -> v8 LSM timeline migration). + conf.setString(HoodieArchivalConfig.MIGRATION_COMMITS_ARCHIVAL_BATCH_SIZE.key(), "123"); + HoodieWriteConfig writeConfig = FlinkWriteClients.getHoodieClientConfig(conf, false, false); + assertEquals(123, writeConfig.getMigrationCommitArchivalBatchSize()); + // The regular archival batch size must stay independent at its own default. + assertEquals(Integer.parseInt(HoodieArchivalConfig.COMMITS_ARCHIVAL_BATCH_SIZE.defaultValue()), + writeConfig.getCommitArchivalBatchSize()); + } + @ParameterizedTest @ValueSource(strings = {"", "DIRECT", "TIMELINE_SERVER_BASED"}) void testMarkerType(String markerType) throws Exception { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestRowDataToAvroConverters.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestRowDataToAvroConverters.java index 185c5830616c3..d7c68b7e318d4 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestRowDataToAvroConverters.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestRowDataToAvroConverters.java @@ -18,15 +18,22 @@ package org.apache.hudi.utils; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.util.HoodieSchemaConverter; import org.apache.hudi.util.RowDataToAvroConverters; +import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; +import org.apache.avro.util.Utf8; import org.apache.flink.formats.common.TimestampFormat; import org.apache.flink.formats.json.JsonToRowDataConverters; import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonProcessingException; import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.StringData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.RowType; import org.junit.jupiter.api.Assertions; @@ -36,6 +43,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import static org.apache.flink.table.api.DataTypes.FIELD; import static org.apache.flink.table.api.DataTypes.ROW; @@ -81,4 +89,116 @@ void testRowDataToAvroStringToRowDataWithUtcTimezone() throws JsonProcessingExce Assertions.assertEquals("2021-03-30 08:44:29", formatter.format(LocalDateTime.ofInstant(Instant.ofEpochMilli((Long) avroRecord.get(0)), ZoneId.of("UTC+1")))); Assertions.assertEquals("2021-03-30 15:44:29", formatter.format(LocalDateTime.ofInstant(Instant.ofEpochMilli((Long) avroRecord.get(0)), ZoneId.of("Asia/Shanghai")))); } + + @Test + void testRowDataToAvroBlobTypeFieldWritesEnumSymbol() { + // Flink models the BLOB `type` discriminator as STRING, but its Avro encoding is an ENUM + // (blob_storage_type). The converter must emit a GenericData.EnumSymbol, not a plain Utf8, + // otherwise Avro log-block writes (MOR) fail with "value OUT_OF_LINE (a Utf8) is not a + // blob_storage_type". + DataType blobRow = DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.TYPE, DataTypes.STRING().notNull()), + DataTypes.FIELD(HoodieSchema.Blob.INLINE_DATA_FIELD, DataTypes.BYTES().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE, DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, DataTypes.STRING().notNull()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, DataTypes.BOOLEAN().notNull()) + ).nullable())); + RowType rowType = (RowType) DataTypes.ROW(DataTypes.FIELD("blob_col", blobRow)).getLogicalType(); + + GenericRowData reference = new GenericRowData(4); + reference.setField(0, StringData.fromString("file1.bin")); + reference.setField(1, 0L); + reference.setField(2, 100L); + reference.setField(3, false); + + GenericRowData blob = new GenericRowData(3); + blob.setField(0, StringData.fromString(HoodieSchema.Blob.OUT_OF_LINE)); + blob.setField(1, null); + blob.setField(2, reference); + + GenericRowData top = new GenericRowData(1); + top.setField(0, blob); + + RowDataToAvroConverters.RowDataToAvroConverter converter = + RowDataToAvroConverters.createConverter(rowType); + GenericRecord avroRecord = + (GenericRecord) converter.convert(HoodieSchemaConverter.convertToSchema(rowType), top); + + GenericRecord blobRecord = (GenericRecord) avroRecord.get(0); + Object typeValue = blobRecord.get(HoodieSchema.Blob.TYPE); + Assertions.assertInstanceOf(GenericData.EnumSymbol.class, typeValue, + "BLOB `type` must be written as an Avro EnumSymbol, found: " + + (typeValue == null ? "null" : typeValue.getClass().getName())); + Assertions.assertEquals(HoodieSchema.Blob.OUT_OF_LINE, typeValue.toString()); + } + + /** + * A ROW whose field names match the BLOB structure but whose {@link HoodieSchema} carries a + * plain {@code STRING} (not {@code ENUM}) for the {@code type} field must write a plain + * {@link Utf8}, not a {@link GenericData.EnumSymbol}. + */ + @Test + void testBlobShapedRowWithPlainStringSchemaWritesUtf8() { + DataType blobShapedRow = DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.TYPE, DataTypes.STRING().notNull()), + DataTypes.FIELD(HoodieSchema.Blob.INLINE_DATA_FIELD, DataTypes.BYTES().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE, DataTypes.ROW( + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, DataTypes.STRING().notNull()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, DataTypes.BIGINT().nullable()), + DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, DataTypes.BOOLEAN().notNull()) + ).nullable())); + RowType outerRowType = (RowType) DataTypes.ROW( + DataTypes.FIELD("blob_col", blobShapedRow)).getLogicalType(); + + // Plain RECORD schema: field[0] is STRING (not ENUM) — mimics a non-BLOB record whose + // shape happens to match the BLOB structure. + HoodieSchema refSchema = HoodieSchema.createRecord("reference", null, null, Arrays.asList( + HoodieSchemaField.of(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, + HoodieSchema.create(HoodieSchemaType.STRING)), + HoodieSchemaField.of(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, + HoodieSchema.createNullable(HoodieSchemaType.LONG)), + HoodieSchemaField.of(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, + HoodieSchema.createNullable(HoodieSchemaType.LONG)), + HoodieSchemaField.of(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, + HoodieSchema.create(HoodieSchemaType.BOOLEAN)) + )); + HoodieSchema plainBlobShapedSchema = HoodieSchema.createRecord("blob_col", null, null, Arrays.asList( + HoodieSchemaField.of(HoodieSchema.Blob.TYPE, HoodieSchema.create(HoodieSchemaType.STRING)), + HoodieSchemaField.of(HoodieSchema.Blob.INLINE_DATA_FIELD, + HoodieSchema.createNullable(HoodieSchemaType.BYTES)), + HoodieSchemaField.of(HoodieSchema.Blob.EXTERNAL_REFERENCE, + HoodieSchema.createNullable(refSchema)) + )); + HoodieSchema outerSchema = HoodieSchema.createRecord("outer", null, null, Arrays.asList( + HoodieSchemaField.of("blob_col", plainBlobShapedSchema) + )); + + GenericRowData reference = new GenericRowData(4); + reference.setField(0, StringData.fromString("file1.bin")); + reference.setField(1, 0L); + reference.setField(2, 100L); + reference.setField(3, false); + + GenericRowData blobRow = new GenericRowData(3); + blobRow.setField(0, StringData.fromString("OUT_OF_LINE")); + blobRow.setField(1, null); + blobRow.setField(2, reference); + + GenericRowData top = new GenericRowData(1); + top.setField(0, blobRow); + + RowDataToAvroConverters.RowDataToAvroConverter converter = + RowDataToAvroConverters.createConverter(outerRowType); + GenericRecord avroRecord = (GenericRecord) converter.convert(outerSchema, top); + + GenericRecord blobRecord = (GenericRecord) avroRecord.get(0); + Object typeValue = blobRecord.get(HoodieSchema.Blob.TYPE); + Assertions.assertInstanceOf(Utf8.class, typeValue, + "STRING field must write as Utf8 (not EnumSymbol) when HoodieSchema is not ENUM; found: " + + (typeValue == null ? "null" : typeValue.getClass().getName())); + Assertions.assertEquals("OUT_OF_LINE", typeValue.toString()); + } } \ No newline at end of file diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestSQL.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestSQL.java index c7b38d81d90db..c4428b05f530c 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestSQL.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestSQL.java @@ -74,6 +74,11 @@ public class TestSQL { + "(2, row(2, cast(null as varchar))),\n" + "(3, row(cast(null as int), cast(null as varchar)))"; + public static final String DEEPLY_NESTED_REPEATED_TYPE_INSERT_T1 = "insert into t1 values\n" + + "(1, row(array[row(11, map['a', 1, 'b', 2]), row(12, map['c', 3])])),\n" + + "(2, row(array[row(21, map['d', 4])])),\n" + + "(3, row(array[row(31, map['e', 5]), row(32, map['f', 6, 'g', 7])]))"; + public static final String INSERT_DATE_PARTITION_T1 = "insert into t1 values\n" + "('id1','Danny',23,DATE '1970-01-01'),\n" + "('id2','Stephen',33,DATE '1970-01-01'),\n" diff --git a/hudi-flink-datasource/hudi-flink1.17.x/pom.xml b/hudi-flink-datasource/hudi-flink1.17.x/pom.xml index d7c8eef3d7c91..a4d2ae96f2977 100644 --- a/hudi-flink-datasource/hudi-flink1.17.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink1.17.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j diff --git a/hudi-flink-datasource/hudi-flink1.17.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink1.17.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..e8e31b341a180 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.17.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +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.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + private static final String VARIANT_UNSUPPORTED_MSG = + "VARIANT type is only supported in Flink 2.1+. " + + "Please upgrade your Flink version to use Variant columns."; + + public static Option variantParquetAnnotation() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Variant getVariant(RowData rowData, int pos) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static boolean isVariantType(LogicalType logicalType) { + return false; + } + + public static DataType createVariantType() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantMetadata(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantValue(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.17.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink1.17.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..ae2e4107d6ea7 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.17.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + throw new UnsupportedOperationException("Variant is not supported yet."); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/pom.xml b/hudi-flink-datasource/hudi-flink1.18.x/pom.xml index 4df05d62af7a0..025821f2217e7 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink1.18.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -127,12 +127,6 @@ ${flink1.18.version} provided - - org.apache.flink - flink-table-planner_2.12 - ${flink1.18.version} - provided - diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..e8e31b341a180 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +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.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + private static final String VARIANT_UNSUPPORTED_MSG = + "VARIANT type is only supported in Flink 2.1+. " + + "Please upgrade your Flink version to use Variant columns."; + + public static Option variantParquetAnnotation() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Variant getVariant(RowData rowData, int pos) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static boolean isVariantType(LogicalType logicalType) { + return false; + } + + public static DataType createVariantType() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantMetadata(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantValue(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java index 2bb5be1d9614e..5468dc86a25a6 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java @@ -7,7 +7,7 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,19 +19,18 @@ package org.apache.hudi.table.format.cow; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; import org.apache.hudi.table.format.cow.vector.HeapArrayVector; import org.apache.hudi.table.format.cow.vector.HeapDecimalVector; import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; -import org.apache.hudi.table.format.cow.vector.reader.ArrayColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.ArrayGroupReader; import org.apache.hudi.table.format.cow.vector.reader.EmptyColumnReader; import org.apache.hudi.table.format.cow.vector.reader.FixedLenBytesColumnReader; import org.apache.hudi.table.format.cow.vector.reader.Int64TimestampColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.MapColumnReader; +import org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader; import org.apache.hudi.table.format.cow.vector.reader.ParquetColumnarRowSplitReader; -import org.apache.hudi.table.format.cow.vector.reader.RowColumnReader; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.vector.reader.BooleanColumnReader; @@ -64,12 +63,14 @@ 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.LogicalTypeRoot; 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.TimestampType; +import org.apache.flink.util.FlinkRuntimeException; import org.apache.flink.util.Preconditions; +import org.apache.flink.util.StringUtils; + import org.apache.hadoop.conf.Configuration; import org.apache.parquet.ParquetRuntimeException; import org.apache.parquet.column.ColumnDescriptor; @@ -77,12 +78,18 @@ import org.apache.parquet.column.page.PageReader; import org.apache.parquet.filter.UnboundRecordFilter; import org.apache.parquet.filter2.predicate.FilterPredicate; +import org.apache.parquet.io.ColumnIO; +import org.apache.parquet.io.GroupColumnIO; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.PrimitiveColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.InvalidSchemaException; import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; +import javax.annotation.Nullable; + import java.io.IOException; import java.math.BigDecimal; import java.sql.Date; @@ -90,25 +97,38 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.apache.flink.table.utils.DateTimeUtils.toInternal; import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; import static org.apache.parquet.Preconditions.checkArgument; +import static org.apache.parquet.schema.Type.Repetition.REPEATED; +import static org.apache.parquet.schema.Type.Repetition.REQUIRED; /** * Util for generating {@link ParquetColumnarRowSplitReader}. * - *

    NOTE: reference from Flink release 1.11.2 {@code ParquetSplitReaderUtil}, modify to support INT64 - * based TIMESTAMP_MILLIS as ConvertedType, should remove when Flink supports that. + *

    Uses the Dremel-style nested reader ported from Apache Flink 2.1 (FLINK-35702). For primitive + * top-level columns we keep Hudi's specialized readers — {@link Int64TimestampColumnReader}, + * {@link FixedLenBytesColumnReader}, and the Hudi {@link HeapDecimalVector} — unchanged. For + * nested types (ARRAY / MAP / MULTISET / ROW) we build a {@link ParquetField} tree once per + * split via {@link #buildFieldsList(List, List, MessageColumnIO)} and delegate reading to + * {@link NestedColumnReader}. + * + *

    Schema evolution: missing top-level fields are still handled by the caller + * ({@link ParquetColumnarRowSplitReader} patches them with null vectors). Missing fields + * inside a Row are handled here — {@link #constructField} returns {@code null} for a + * child that isn't physically present, and the corresponding child in the pre-allocated vector + * is filled with nulls via {@link #createVectorFromConstant} so the Dremel assembler can + * passthrough the slot (see {@link NestedColumnReader#readToVector}). */ public class ParquetSplitReaderUtil { - /** - * Util for generating partitioned {@link ParquetColumnarRowSplitReader}. - */ + /** Util for generating partitioned {@link ParquetColumnarRowSplitReader}. */ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( boolean utcTimestamp, boolean caseSensitive, @@ -182,10 +202,13 @@ private static ColumnVector createVector( return readVector; } - private static ColumnVector createVectorFromConstant( - LogicalType type, - Object value, - int batchSize) { + /** + * Builds a constant-filled column vector for either a partition column (non-null value) or a + * missing-column slot (null value). Used both at the batch-generator level for partition + * injection and at the row-reader level for fields absent from the Parquet file. + */ + public static ColumnVector createVectorFromConstant( + LogicalType type, Object value, int batchSize) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -278,6 +301,7 @@ private static ColumnVector createVectorFromConstant( value == null ? null : toInternal((Date) value), batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: HeapTimestampVector tv = new HeapTimestampVector(batchSize); if (value == null) { tv.fillWithNulls(); @@ -286,46 +310,41 @@ private static ColumnVector createVectorFromConstant( } return tv; case ARRAY: - ArrayType arrayType = (ArrayType) type; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - HeapArrayGroupColumnVector arrayGroup = new HeapArrayGroupColumnVector(batchSize); - if (value == null) { - arrayGroup.fillWithNulls(); - return arrayGroup; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } - } else { - HeapArrayVector arrayVector = new HeapArrayVector(batchSize); - if (value == null) { - arrayVector.fillWithNulls(); - return arrayVector; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } + if (value != null) { + throw new UnsupportedOperationException("Unsupported create array with default value."); } + HeapArrayVector arrayVector = new HeapArrayVector(batchSize); + arrayVector.fillWithNulls(); + return arrayVector; case MAP: - HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); - if (value == null) { - mapVector.fillWithNulls(); - return mapVector; - } else { - throw new UnsupportedOperationException("Unsupported create map with default value."); + case MULTISET: + if (value != null) { + throw new UnsupportedOperationException( + "Unsupported create " + type.getTypeRoot() + " with default value."); } + HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); + mapVector.fillWithNulls(); + return mapVector; case ROW: - HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize); - if (value == null) { - rowVector.fillWithNulls(); - return rowVector; - } else { + if (value != null) { throw new UnsupportedOperationException("Unsupported create row with default value."); } + RowType rowType = (RowType) type; + WritableColumnVector[] childVectors = new WritableColumnVector[rowType.getFieldCount()]; + for (int i = 0; i < childVectors.length; i++) { + childVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); + } + HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize, childVectors); + rowVector.fillWithNulls(); + return rowVector; default: throw new UnsupportedOperationException("Unsupported type: " + type); } } - private static List filterDescriptors(int depth, Type type, List columns) throws ParquetRuntimeException { + private static List filterDescriptors( + int depth, Type type, List columns) throws ParquetRuntimeException { List filtered = new ArrayList<>(); for (ColumnDescriptor descriptor : columns) { if (depth >= descriptor.getPath().length) { @@ -339,24 +358,61 @@ private static List filterDescriptors(int depth, Type type, Li return filtered; } + /** + * Creates a {@link ColumnReader} for one top-level requested field. For primitive types the + * Hudi-specialized reader path is used. For nested types ({@code ARRAY}, {@code MAP}, + * {@code MULTISET}, {@code ROW}) the Dremel-style {@link NestedColumnReader} is used, driven by + * the supplied pre-built {@link ParquetField} tree. + * + * @param field the {@link ParquetField} tree for this column, built by + * {@link #buildFieldsList(List, List, MessageColumnIO)}. Required (non-null) for nested + * types; ignored for primitives. + */ + public static ColumnReader createColumnReader( + boolean utcTimestamp, + LogicalType fieldType, + Type physicalType, + List descriptors, + PageReadStore pages, + @Nullable ParquetField field) throws IOException { + switch (fieldType.getTypeRoot()) { + case ARRAY: + case MAP: + case MULTISET: + case ROW: + Preconditions.checkNotNull( + field, "ParquetField must be provided for nested type: %s", fieldType); + return new NestedColumnReader(utcTimestamp, pages, field); + default: + return createPrimitiveColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages); + } + } + + /** + * Backward-compat entry point kept for callers that don't project nested types and therefore + * never need a {@link ParquetField} tree. Forwards to the {@link ParquetField}-aware overload + * with a null field; nested types now go through that overload directly. + * + * @deprecated use {@link #createColumnReader(boolean, LogicalType, Type, List, PageReadStore, + * ParquetField)} so nested types take the Dremel path. + */ + @Deprecated public static ColumnReader createColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List descriptors, PageReadStore pages) throws IOException { - return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, - pages, 0); + return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages, null); } - private static ColumnReader createColumnReader( + private static ColumnReader createPrimitiveColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List columns, - PageReadStore pages, - int depth) throws IOException { - List descriptors = filterDescriptors(depth, physicalType, columns); + PageReadStore pages) throws IOException { + List descriptors = filterDescriptors(0, physicalType, columns); ColumnDescriptor descriptor = descriptors.get(0); PageReader pageReader = pages.getPageReader(descriptor); switch (fieldType.getTypeRoot()) { @@ -392,7 +448,9 @@ private static ColumnReader createColumnReader( case INT96: return new TimestampColumnReader(utcTimestamp, descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for TIMESTAMP: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } case DECIMAL: switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { @@ -403,106 +461,23 @@ private static ColumnReader createColumnReader( case BINARY: return new BytesColumnReader(descriptor, pageReader); case FIXED_LEN_BYTE_ARRAY: - return new FixedLenBytesColumnReader( - descriptor, pageReader); + return new FixedLenBytesColumnReader(descriptor, pageReader); default: - throw new AssertionError(); - } - case ARRAY: - ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new ArrayGroupReader(createColumnReader( - utcTimestamp, - arrayType.getElementType(), - elementType, - descriptors, - pages, - elementDepth)); - } else { - return new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - fieldType); - } - case MAP: - MapType mapType = (MapType) fieldType; - ArrayColumnReader keyReader = - new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - new ArrayType(mapType.getKeyType())); - ColumnReader valueReader; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueReader = new ArrayGroupReader(createColumnReader( - utcTimestamp, - mapType.getValueType(), - physicalType.asGroupType().getType(0).asGroupType().getType(1), // Get the value physical type - descriptors.subList(1, descriptors.size()), // remove the key descriptor - pages, - depth + 2)); // increase the depth by 2, because there's a key_value entry in the path - } else { - valueReader = new ArrayColumnReader( - descriptors.get(1), - pages.getPageReader(descriptors.get(1)), - utcTimestamp, - descriptors.get(1).getPrimitiveType(), - new ArrayType(mapType.getValueType())); + throw new AssertionError( + "Unexpected physical type for DECIMAL: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } - return new MapColumnReader(keyReader, valueReader); - case ROW: - RowType rowType = (RowType) fieldType; - GroupType groupType = physicalType.asGroupType(); - List fieldReaders = new ArrayList<>(); - for (int i = 0; i < rowType.getFieldCount(); i++) { - // schema evolution: read the parquet file with a new extended field name. - int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); - if (fieldIndex < 0) { - fieldReaders.add(new EmptyColumnReader()); - } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - fieldReaders.add( - createColumnReader( - utcTimestamp, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } else { - fieldReaders.add( - createColumnReader( - utcTimestamp, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } - } - } - return new RowColumnReader(fieldReaders); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + /** + * Creates the writable column vector that the reader will write into. The returned vector shape + * matches {@code fieldType}; for ROW types missing physical fields are slotted with null-filled + * vectors (sourced from {@link #createVectorFromConstant}) so that the Dremel assembler in + * {@link NestedColumnReader} can pass them through unchanged. + */ public static WritableColumnVector createWritableColumnVector( int batchSize, LogicalType fieldType, @@ -523,40 +498,48 @@ private static WritableColumnVector createWritableColumnVector( switch (fieldType.getTypeRoot()) { case BOOLEAN: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBooleanVector(batchSize); case TINYINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapByteVector(batchSize); case DOUBLE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDoubleVector(batchSize); case FLOAT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.FLOAT, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.FLOAT, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapFloatVector(batchSize); case INTEGER: case DATE: case TIME_WITHOUT_TIME_ZONE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapIntVector(batchSize); case BIGINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT64, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT64, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapLongVector(batchSize); case SMALLINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapShortVector(batchSize); case CHAR: case VARCHAR: case BINARY: case VARBINARY: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BINARY, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BINARY, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBytesVector(batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: case TIMESTAMP_WITH_LOCAL_TIME_ZONE: @@ -566,112 +549,64 @@ private static WritableColumnVector createWritableColumnVector( case DECIMAL: checkArgument( (typeName == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY - || typeName == PrimitiveType.PrimitiveTypeName.BINARY) + || typeName == PrimitiveType.PrimitiveTypeName.BINARY) && primitiveType.getOriginalType() == OriginalType.DECIMAL, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDecimalVector(batchSize); case ARRAY: ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - elementType, - descriptors, - elementDepth)); - } else { - return new HeapArrayVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - physicalType, - descriptors, - depth)); - } - case MAP: + return new HeapArrayVector( + batchSize, + createWritableColumnVector( + batchSize, arrayType.getElementType(), physicalType, descriptors, depth)); + case MAP: { MapType mapType = (MapType) fieldType; - GroupType repeatedType = physicalType.asGroupType().getType(0).asGroupType(); - // the map column has three level paths. - WritableColumnVector keyColumnVector = createWritableColumnVector( + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( batchSize, - new ArrayType(mapType.getKeyType().isNullable(), mapType.getKeyType()), - repeatedType.getType(0), - descriptors, - depth + 2); - WritableColumnVector valueColumnVector; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueColumnVector = new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - mapType.getValueType(), - repeatedType.getType(1).asGroupType(), - descriptors, - depth + 2)); - } else { - valueColumnVector = createWritableColumnVector( - batchSize, - new ArrayType(mapType.getValueType().isNullable(), mapType.getValueType()), - repeatedType.getType(1), - descriptors, - depth + 2); - } - return new HeapMapColumnVector(batchSize, keyColumnVector, valueColumnVector); + createWritableColumnVector( + batchSize, mapType.getKeyType(), repeatedType.getType(0), descriptors, depth + 2), + createWritableColumnVector( + batchSize, mapType.getValueType(), repeatedType.getType(1), descriptors, depth + 2)); + } + case MULTISET: { + MultisetType multisetType = (MultisetType) fieldType; + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( + batchSize, + createWritableColumnVector( + batchSize, + multisetType.getElementType(), + repeatedType.getType(0), + descriptors, + depth + 2), + createWritableColumnVector( + batchSize, + new IntType(false), + repeatedType.getType(1), + descriptors, + depth + 2)); + } case ROW: RowType rowType = (RowType) fieldType; GroupType groupType = physicalType.asGroupType(); WritableColumnVector[] columnVectors = new WritableColumnVector[rowType.getFieldCount()]; for (int i = 0; i < columnVectors.length; i++) { - // schema evolution: read the file with a new extended field name. int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); if (fieldIndex < 0) { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (groupType.getRepetition().equals(Type.Repetition.REPEATED) && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant( - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), null, batchSize); - } else { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); - } + // Schema evolution: logical field is absent from the Parquet file. Slot a null-filled + // vector of the correct shape; NestedColumnReader.readRow will pass it through when the + // matching ParquetField child is null. + columnVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = - createWritableColumnVector( - batchSize, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } else { - columnVectors[i] = - createWritableColumnVector( - batchSize, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } + columnVectors[i] = + createWritableColumnVector( + batchSize, + rowType.getTypeAt(i), + groupType.getType(fieldIndex), + descriptors, + depth + 1); } } return new HeapRowColumnVector(batchSize, columnVectors); @@ -681,56 +616,245 @@ private static WritableColumnVector createWritableColumnVector( } /** - * Returns the field index with given physical row type {@code groupType} and field name {@code fieldName}. - * - * @return The physical field index or -1 if the field does not exist + * Peels one {@code repeated group key_value} wrapper off a MAP / MULTISET physical type, matching + * Parquet's canonical 3-level map encoding. */ - private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { - // get index from fileSchema type, else, return -1 - return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + private static GroupType unwrapMapRepeatedType(Type physicalType) { + return physicalType.asGroupType().getType(0).asGroupType(); } + // ------------------------------------------------------------------------------------------ + // ParquetField tree construction (vendored from Apache Flink 2.1 ParquetSplitReaderUtil) + // + // The only Hudi-specific divergence is in `constructField`: the ROW branch tolerates children + // missing from the Parquet file by emitting a null ParquetField child (upstream throws). This + // matches the Hudi schema-evolution contract and is the companion to the null-child branch in + // `NestedColumnReader#readRow` and the null-vector slot in `createWritableColumnVector#ROW`. + // ------------------------------------------------------------------------------------------ + /** - * Check whether the given list type is a three-level list type. - *

    - * group (LIST) { - * repeated group list { - * element; - * } - * } - * - * @param type list type - * @return true if the list type is a three-level list type + * Builds {@link ParquetField} trees — one per top-level projected logical column — that feed + * {@link NestedColumnReader}. The returned list mirrors the input {@code children} positionally; + * primitive top-level fields produce {@code null} entries (callers don't need a tree for those). + */ + public static List buildFieldsList( + List children, List fieldNames, MessageColumnIO columnIO) { + List list = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + RowType.RowField child = children.get(i); + if (isNestedType(child.getType())) { + list.add(constructField(child, lookupColumnByName(columnIO, fieldNames.get(i)))); + } else { + list.add(null); + } + } + return list; + } + + private static boolean isNestedType(LogicalType type) { + return type instanceof RowType + || type instanceof ArrayType + || type instanceof MapType + || type instanceof MultisetType; + } + + @Nullable + private static ParquetField constructField(RowType.RowField rowField, ColumnIO columnIO) { + boolean required = columnIO.getType().getRepetition() == REQUIRED; + int repetitionLevel = columnIO.getRepetitionLevel(); + int definitionLevel = columnIO.getDefinitionLevel(); + LogicalType type = rowField.getType(); + String fieldName = rowField.getName(); + if (type instanceof RowType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + RowType rowType = (RowType) type; + List childFields = rowType.getFields(); + List fieldsList = new ArrayList<>(childFields.size()); + for (RowType.RowField childField : childFields) { + // Hudi schema evolution: a logical child may be absent from the Parquet file. In that + // case we emit a null ParquetField so that NestedColumnReader.readRow passes through the + // pre-filled null vector instead of recursing. + ColumnIO childIo = lookupColumnByNameOrNull(groupColumnIO, childField.getName()); + if (childIo == null) { + fieldsList.add(null); + } else { + fieldsList.add(constructField(childField, childIo)); + } + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(fieldsList)); + } + + if (type instanceof MapType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MapType mapType = (MapType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", mapType.getKeyType()), keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", mapType.getValueType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof MultisetType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MultisetType multisetType = (MultisetType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", multisetType.getElementType()), + keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", new IntType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof ArrayType) { + ArrayType arrayType = (ArrayType) type; + ColumnIO elementTypeColumnIO; + if (columnIO instanceof GroupColumnIO) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) { + while (!Objects.equals(groupColumnIO.getName(), fieldName)) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + elementTypeColumnIO = groupColumnIO; + } else { + if (arrayType.getElementType() instanceof RowType) { + elementTypeColumnIO = groupColumnIO; + } else { + elementTypeColumnIO = groupColumnIO.getChild(0); + } + } + } else if (columnIO instanceof PrimitiveColumnIO) { + elementTypeColumnIO = columnIO; + } else { + throw new FlinkRuntimeException(String.format("Unknown ColumnIO, %s", columnIO)); + } + + ParquetField elementField = + constructField( + new RowType.RowField("", arrayType.getElementType()), + getArrayElementColumn(elementTypeColumnIO)); + if (repetitionLevel == elementField.getRepetitionLevel()) { + repetitionLevel = columnIO.getParent().getRepetitionLevel(); + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.singletonList(elementField)); + } + + PrimitiveColumnIO primitiveColumnIO = (PrimitiveColumnIO) columnIO; + return new ParquetPrimitiveField( + type, required, primitiveColumnIO.getColumnDescriptor(), primitiveColumnIO.getId()); + } + + /** + * Parquet column names are case-insensitive in Flink's lookup. Matches upstream + * {@code ParquetSplitReaderUtil.lookupColumnByName}; throws when absent. */ - private static boolean isThreeLevelList(Type type) { - if (type.isPrimitive()) { - return false; + public static ColumnIO lookupColumnByName(GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = lookupColumnByNameOrNull(groupColumnIO, columnName); + if (columnIO != null) { + return columnIO; } - GroupType groupType = type.asGroupType(); - OriginalType originalType = groupType.getOriginalType(); - return originalType == OriginalType.LIST - && groupType.getType(0).getName().equals("list"); + throw new FlinkRuntimeException( + "Can not find column io for parquet reader. Column name: " + columnName); } /** - * Construct the error message when primitive type mismatches. - * - * @param primitiveType Primitive type - * @param fieldType Logical field type - * @return The error message + * Case-insensitive column lookup that returns {@code null} when no match is found — the + * Hudi-specific companion to {@link #lookupColumnByName}, used by {@link #constructField} to + * emit null {@link ParquetField} children for fields absent from the Parquet file. */ - private static String getPrimitiveTypeCheckFailureMessage(PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { - return String.format("Unexpected type exception. Primitive type: %s. Field type: %s.", primitiveType, fieldType.getTypeRoot().name()); + @Nullable + private static ColumnIO lookupColumnByNameOrNull( + GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = groupColumnIO.getChild(columnName); + if (columnIO != null) { + return columnIO; + } + for (int i = 0; i < groupColumnIO.getChildrenCount(); i++) { + if (groupColumnIO.getChild(i).getName().equalsIgnoreCase(columnName)) { + return groupColumnIO.getChild(i); + } + } + return null; + } + + public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO groupColumnIO) { + while (groupColumnIO.getChildrenCount() == 1) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + return groupColumnIO; + } + + public static ColumnIO getArrayElementColumn(ColumnIO columnIO) { + while (columnIO instanceof GroupColumnIO && !columnIO.getType().isRepetition(REPEATED)) { + columnIO = ((GroupColumnIO) columnIO).getChild(0); + } + + // Three-level list: skip the synthetic `element` / `list` wrapper when present. + if (columnIO instanceof GroupColumnIO + && columnIO.getType().getLogicalTypeAnnotation() == null + && ((GroupColumnIO) columnIO).getChildrenCount() == 1 + && !columnIO.getName().equals("array") + && !columnIO.getName().equals(columnIO.getParent().getName() + "_tuple")) { + return ((GroupColumnIO) columnIO).getChild(0); + } + return columnIO; } /** - * Construct the error message when original type mismatches. + * Returns the field index with given physical row type {@code groupType} and field name + * {@code fieldName}. * - * @param originalType Original type - * @param fieldType Logical field type - * @return The error message + * @return the physical field index or -1 if the field does not exist + */ + private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { + return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + } + + private static String getPrimitiveTypeCheckFailureMessage( + PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Primitive type: %s. Field type: %s.", + primitiveType, fieldType.getTypeRoot().name()); + } + + private static String getOriginalTypeCheckFailureMessage( + OriginalType originalType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Original type: %s. Field type: %s.", + originalType, fieldType.getTypeRoot().name()); + } + + /** + * Returns a synthetic null-column reader to fill missing top-level fields. Kept as a convenience + * for callers that need to mirror Hudi's original behaviour where a missing column produces an + * explicit null-valued reader rather than being omitted from the batch. */ - private static String getOriginalTypeCheckFailureMessage(OriginalType originalType, LogicalType fieldType) { - return String.format("Unexpected type exception. Original type: %s. Field type: %s.", originalType, fieldType.getTypeRoot().name()); + public static ColumnReader emptyColumnReader() { + return new EmptyColumnReader(); } } diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java index a1fa3c8fa8357..3f2f8976b69bf 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java @@ -87,8 +87,8 @@ public static RowPosition calculateRowOffsets( * * @param field field that contains array/map column message include max repetition level and * definition level. - * @param definitionLevels int array with each value's repetition level. - * @param repetitionLevels int array with each value's definition level. + * @param definitionLevels int array with each value's definition level. + * @param repetitionLevels int array with each value's repetition level. * @return {@link CollectionPosition} contains collections offset array, length array and isNull * array. */ diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java deleted file mode 100644 index 4c9275f3b0932..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java +++ /dev/null @@ -1,179 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupArrayData implements ArrayData { - - WritableColumnVector vector; - int rowId; - - public ColumnarGroupArrayData(WritableColumnVector vector, int rowId) { - this.vector = vector; - this.rowId = rowId; - } - - @Override - public int size() { - if (vector == null) { - return 0; - } - - if (vector instanceof HeapRowColumnVector) { - // assume all fields have the same size - if (((HeapRowColumnVector) vector).vectors == null || ((HeapRowColumnVector) vector).vectors.length == 0) { - return 0; - } - return ((HeapArrayVector) ((HeapRowColumnVector) vector).vectors[0]).getArray(rowId).size(); - } - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean isNullAt(int index) { - if (vector == null) { - return true; - } - - if (vector instanceof HeapRowColumnVector) { - return ((HeapRowColumnVector) vector).vectors == null; - } - - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean getBoolean(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte getByte(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short getShort(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int getInt(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long getLong(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float getFloat(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double getDouble(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public StringData getString(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public DecimalData getDecimal(int index, int precision, int scale) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public TimestampData getTimestamp(int index, int precision) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RawValueData getRawValue(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] getBinary(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public ArrayData getArray(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public MapData getMap(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RowData getRow(int index, int numFields) { - return new ColumnarGroupRowData((HeapRowColumnVector) vector, rowId, index); - } - - @Override - public boolean[] toBooleanArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] toByteArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short[] toShortArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int[] toIntArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long[] toLongArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float[] toFloatArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double[] toDoubleArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java deleted file mode 100644 index 69cb6feca13e4..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.MapData; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupMapData implements MapData { - - WritableColumnVector keyVector; - WritableColumnVector valueVector; - int rowId; - - public ColumnarGroupMapData(WritableColumnVector keyVector, WritableColumnVector valueVector, int rowId) { - this.keyVector = keyVector; - this.valueVector = valueVector; - this.rowId = rowId; - } - - @Override - public int size() { - if (keyVector == null) { - return 0; - } - - if (keyVector instanceof HeapArrayVector) { - return ((HeapArrayVector) keyVector).getArray(rowId).size(); - } - throw new UnsupportedOperationException(keyVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector"); - } - - @Override - public ArrayData keyArray() { - return ((HeapArrayVector) keyVector).getArray(rowId); - } - - @Override - public ArrayData valueArray() { - if (valueVector instanceof HeapArrayVector) { - return ((HeapArrayVector) valueVector).getArray(rowId); - } else if (valueVector instanceof HeapArrayGroupColumnVector) { - return ((HeapArrayGroupColumnVector) valueVector).getArray(rowId); - } - throw new UnsupportedOperationException(valueVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector, HeapArrayGroupColumnVector"); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java deleted file mode 100644 index 439c1880823f1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.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.hudi.table.format.cow.vector; - -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.RawValueData; -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.RowKind; - -public class ColumnarGroupRowData implements RowData { - - HeapRowColumnVector vector; - int rowId; - int index; - - public ColumnarGroupRowData(HeapRowColumnVector vector, int rowId, int index) { - this.vector = vector; - this.rowId = rowId; - this.index = index; - } - - @Override - public int getArity() { - return vector.vectors.length; - } - - @Override - public RowKind getRowKind() { - return RowKind.INSERT; - } - - @Override - public void setRowKind(RowKind rowKind) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public boolean isNullAt(int pos) { - return - vector.vectors[pos].isNullAt(rowId) - || ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).isNullAt(index); - } - - @Override - public boolean getBoolean(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBoolean(index); - } - - @Override - public byte getByte(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getByte(index); - } - - @Override - public short getShort(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getShort(index); - } - - @Override - public int getInt(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getInt(index); - } - - @Override - public long getLong(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getLong(index); - } - - @Override - public float getFloat(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getFloat(index); - } - - @Override - public double getDouble(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDouble(index); - } - - @Override - public StringData getString(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getString(index); - } - - @Override - public DecimalData getDecimal(int pos, int i1, int i2) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDecimal(index, i1, i2); - } - - @Override - public TimestampData getTimestamp(int pos, int i1) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getTimestamp(index, i1); - } - - @Override - public RawValueData getRawValue(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRawValue(index); - } - - @Override - public byte[] getBinary(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBinary(index); - } - - @Override - public ArrayData getArray(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getArray(index); - } - - @Override - public MapData getMap(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getMap(index); - } - - @Override - public RowData getRow(int pos, int numFields) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRow(index, numFields); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java deleted file mode 100644 index 3d7d8b1f0de0f..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java +++ /dev/null @@ -1,53 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.columnar.vector.ArrayColumnVector; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -/** - * This class represents a nullable heap row column vector. - */ -public class HeapArrayGroupColumnVector extends AbstractHeapVector - implements WritableColumnVector, ArrayColumnVector { - - public WritableColumnVector vector; - - public HeapArrayGroupColumnVector(int len) { - super(len); - } - - public HeapArrayGroupColumnVector(int len, WritableColumnVector vector) { - super(len); - this.vector = vector; - } - - @Override - public ArrayData getArray(int rowId) { - return new ColumnarGroupArrayData(vector, rowId); - } - - @Override - public void reset() { - super.reset(); - vector.reset(); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java index a0dced01e5e8d..2f21a323302f1 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java @@ -57,6 +57,37 @@ public int getLen() { return this.isNull.length; } + // --------------------------------------------------------------------------------------------- + // Flink 2.1-compatible accessors. Backed by the existing public {@code offsets}, {@code lengths} + // and {@code child} fields so legacy callers continue to work; the new {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + // future Flink-2.1-style caller use these accessors. + // --------------------------------------------------------------------------------------------- + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public ColumnVector getChild() { + return child; + } + + public void setChild(ColumnVector child) { + this.child = child; + } + @Override public ArrayData getArray(int i) { long offset = offsets[i]; diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java index 0d83f82baedf3..14aad22039e0a 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java @@ -20,29 +20,97 @@ import lombok.Getter; import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.columnar.ColumnarMapData; +import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.MapColumnVector; import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; /** * This class represents a nullable heap map column vector. + * + *

    Mirrors {@code org.apache.flink.table.data.columnar.vector.heap.HeapMapVector} from + * Flink 2.1 (FLINK-35702). One deliberate divergence from upstream is preserved for backward + * compatibility: the {@code keys} / {@code values} fields are typed + * {@link WritableColumnVector} rather than upstream's {@link ColumnVector}, so the existing + * Lombok-generated {@code getKeys()} / {@code getValues()} accessors keep their original + * signature. Callers wanting the Flink-2.1 contract (a {@code ColumnVector}) use + * {@link #getKeyColumnVector()} / {@link #getValueColumnVector()}. */ public class HeapMapColumnVector extends AbstractHeapVector implements WritableColumnVector, MapColumnVector { @Getter - private final WritableColumnVector keys; + private WritableColumnVector keys; @Getter - private final WritableColumnVector values; + private WritableColumnVector values; + + // --------------------------------------------------------------------------------------------- + // Flink 2.1 Dremel-style state. Populated by {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and + // consumed by {@link #getMap(int)}. + // --------------------------------------------------------------------------------------------- + private long[] offsets; + private long[] lengths; + private int size; public HeapMapColumnVector(int len, WritableColumnVector keys, WritableColumnVector values) { super(len); + this.offsets = new long[len]; + this.lengths = new long[len]; + this.keys = keys; + this.values = values; + } + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public void setKeys(WritableColumnVector keys) { this.keys = keys; + } + + public void setValues(WritableColumnVector values) { this.values = values; } + /** + * Returns the keys child vector typed as {@link ColumnVector}, matching the Flink 2.1 contract + * consumed by {@code NestedColumnReader}. Functionally equivalent to {@link #getKeys()}. + */ + public ColumnVector getKeyColumnVector() { + return keys; + } + + /** Counterpart of {@link #getKeyColumnVector()} for the values child vector. */ + public ColumnVector getValueColumnVector() { + return values; + } + @Override public MapData getMap(int rowId) { - return new ColumnarGroupMapData(keys, values, rowId); + long offset = offsets[rowId]; + long length = lengths[rowId]; + return new ColumnarMapData(keys, values, (int) offset, (int) length); } } diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java index ae194e4e6ab05..0c640ce92ee40 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java @@ -37,6 +37,21 @@ public HeapRowColumnVector(int len, WritableColumnVector... vectors) { this.vectors = vectors; } + /** + * Flink 2.1-compatible accessor for the children vectors. Backed by the existing public {@code + * vectors} field so legacy callers continue to work; the new {@link + * org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + * future Flink-2.1-style caller use this accessor. + */ + public WritableColumnVector[] getFields() { + return vectors; + } + + /** Counterpart of {@link #getFields()}. */ + public void setFields(WritableColumnVector[] fields) { + this.vectors = fields; + } + @Override public ColumnarRowData getRow(int i) { ColumnarRowData columnarRowData = new ColumnarRowData(new VectorizedColumnBatch(vectors)); diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java deleted file mode 100644 index d758f35078d8f..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java +++ /dev/null @@ -1,473 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayVector; -import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.TimestampData; -import org.apache.flink.table.data.columnar.vector.VectorizedColumnBatch; -import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.table.types.logical.ArrayType; -import org.apache.flink.table.types.logical.LogicalType; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Array {@link ColumnReader}. - */ -public class ArrayColumnReader extends BaseVectorizedColumnReader { - - // The value read in last time - private Object lastValue; - - // flag to indicate if there is no data in parquet data page - private boolean eof = false; - - // flag to indicate if it's the first time to read parquet data page with this instance - boolean isFirstRow = true; - - public ArrayColumnReader( - ColumnDescriptor descriptor, - PageReader pageReader, - boolean isUtcTimestamp, - Type type, - LogicalType logicalType) - throws IOException { - super(descriptor, pageReader, isUtcTimestamp, type, logicalType); - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayVector lcv = (HeapArrayVector) vector; - // before readBatch, initial the size of offsets & lengths as the default value, - // the actual size will be assigned in setChildrenInfo() after reading complete. - lcv.offsets = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - lcv.lengths = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - // Because the length of ListColumnVector.child can't be known now, - // the valueList will save all data for ListColumnVector temporary. - List valueList = new ArrayList<>(); - - LogicalType category = ((ArrayType) logicalType).getElementType(); - - // read the first row in parquet data page, this will be only happened once for this - // instance - if (isFirstRow) { - if (!fetchNextValue(category)) { - return; - } - isFirstRow = false; - } - - int index = collectDataFromParquetPage(readNumber, lcv, valueList, category); - - // Convert valueList to array for the ListColumnVector.child - fillColumnVector(category, lcv, valueList, index); - } - - /** - * Reads a single value from parquet page, puts it into lastValue. Returns a boolean indicating - * if there is more values to read (true). - * - * @param category - * @return boolean - * @throws IOException - */ - private boolean fetchNextValue(LogicalType category) throws IOException { - int left = readPageIfNeed(); - if (left > 0) { - // get the values of repetition and definitionLevel - readRepetitionAndDefinitionLevels(); - // read the data if it isn't null - if (definitionLevel == maxDefLevel) { - if (isCurrentPageDictionaryEncoded) { - lastValue = dataColumn.readValueDictionaryId(); - } else { - lastValue = readPrimitiveTypedRow(category); - } - } else { - lastValue = null; - } - return true; - } else { - eof = true; - return false; - } - } - - private int readPageIfNeed() throws IOException { - // Compute the number of values we want to read in this page. - int leftInPage = (int) (endOfPageValueCount - valuesRead); - if (leftInPage == 0) { - // no data left in current page, load data from new page - readPage(); - leftInPage = (int) (endOfPageValueCount - valuesRead); - } - return leftInPage; - } - - // Need to be in consistent with that VectorizedPrimitiveColumnReader#readBatchHelper - // TODO Reduce the duplicated code - private Object readPrimitiveTypedRow(LogicalType category) { - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dataColumn.readString(); - case BOOLEAN: - return dataColumn.readBoolean(); - case TIME_WITHOUT_TIME_ZONE: - case DATE: - case INTEGER: - return dataColumn.readInteger(); - case TINYINT: - return dataColumn.readTinyInt(); - case SMALLINT: - return dataColumn.readSmallInt(); - case BIGINT: - return dataColumn.readLong(); - case FLOAT: - return dataColumn.readFloat(); - case DOUBLE: - return dataColumn.readDouble(); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dataColumn.readInteger(); - case INT64: - return dataColumn.readLong(); - case BINARY: - case FIXED_LEN_BYTE_ARRAY: - return dataColumn.readString(); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dataColumn.readTimestamp(); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { - if (dictionaryValue == null) { - return null; - } - - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dictionary.readString(dictionaryValue); - case DATE: - case TIME_WITHOUT_TIME_ZONE: - case INTEGER: - return dictionary.readInteger(dictionaryValue); - case BOOLEAN: - return dictionary.readBoolean(dictionaryValue) ? 1 : 0; - case DOUBLE: - return dictionary.readDouble(dictionaryValue); - case FLOAT: - return dictionary.readFloat(dictionaryValue); - case TINYINT: - return dictionary.readTinyInt(dictionaryValue); - case SMALLINT: - return dictionary.readSmallInt(dictionaryValue); - case BIGINT: - return dictionary.readLong(dictionaryValue); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dictionary.readInteger(dictionaryValue); - case INT64: - return dictionary.readLong(dictionaryValue); - case FIXED_LEN_BYTE_ARRAY: - case BINARY: - return dictionary.readString(dictionaryValue); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dictionary.readTimestamp(dictionaryValue); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - /** - * Collects data from a parquet page and returns the final row index where it stopped. The - * returned index can be equal to or less than total. - * - * @param total maximum number of rows to collect - * @param lcv column vector to do initial setup in data collection time - * @param valueList collection of values that will be fed into the vector later - * @param category - * @return int - * @throws IOException - */ - private int collectDataFromParquetPage( - int total, HeapArrayVector lcv, List valueList, LogicalType category) - throws IOException { - int index = 0; - /* - * Here is a nested loop for collecting all values from a parquet page. - * A column of array type can be considered as a list of lists, so the two loops are as below: - * 1. The outer loop iterates on rows (index is a row index, so points to a row in the batch), e.g.: - * [0, 2, 3] <- index: 0 - * [NULL, 3, 4] <- index: 1 - * - * 2. The inner loop iterates on values within a row (sets all data from parquet data page - * for an element in ListColumnVector), so fetchNextValue returns values one-by-one: - * 0, 2, 3, NULL, 3, 4 - * - * As described below, the repetition level (repetitionLevel != 0) - * can be used to decide when we'll start to read values for the next list. - */ - while (!eof && index < total) { - // add element to ListColumnVector one by one - lcv.offsets[index] = valueList.size(); - /* - * Let's collect all values for a single list. - * Repetition level = 0 means that a new list started there in the parquet page, - * in that case, let's exit from the loop, and start to collect value for a new list. - */ - do { - /* - * Definition level = 0 when a NULL value was returned instead of a list - * (this is not the same as a NULL value in of a list). - */ - if (definitionLevel == 0) { - lcv.setNullAt(index); - } - valueList.add( - isCurrentPageDictionaryEncoded - ? dictionaryDecodeValue(category, (Integer) lastValue) - : lastValue); - } while (fetchNextValue(category) && (repetitionLevel != 0)); - - lcv.lengths[index] = valueList.size() - lcv.offsets[index]; - index++; - } - return index; - } - - /** - * The lengths & offsets will be initialized as default size (1024), it should be set to the - * actual size according to the element number. - */ - private void setChildrenInfo(HeapArrayVector lcv, int itemNum, int elementNum) { - lcv.setSize(itemNum); - long[] lcvLength = new long[elementNum]; - long[] lcvOffset = new long[elementNum]; - System.arraycopy(lcv.lengths, 0, lcvLength, 0, elementNum); - System.arraycopy(lcv.offsets, 0, lcvOffset, 0, elementNum); - lcv.lengths = lcvLength; - lcv.offsets = lcvOffset; - } - - private void fillColumnVector( - LogicalType category, HeapArrayVector lcv, List valueList, int elementNum) { - int total = valueList.size(); - setChildrenInfo(lcv, total, elementNum); - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - lcv.child = new HeapBytesVector(total); - ((HeapBytesVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (src == null) { - ((HeapBytesVector) lcv.child).setNullAt(i); - } else { - ((HeapBytesVector) lcv.child).appendBytes(i, src, 0, src.length); - } - } - break; - case BOOLEAN: - lcv.child = new HeapBooleanVector(total); - ((HeapBooleanVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapBooleanVector) lcv.child).setNullAt(i); - } else { - ((HeapBooleanVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TINYINT: - lcv.child = new HeapByteVector(total); - ((HeapByteVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapByteVector) lcv.child).setNullAt(i); - } else { - ((HeapByteVector) lcv.child).vector[i] = - (byte) ((List) valueList).get(i).intValue(); - } - } - break; - case SMALLINT: - lcv.child = new HeapShortVector(total); - ((HeapShortVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapShortVector) lcv.child).setNullAt(i); - } else { - ((HeapShortVector) lcv.child).vector[i] = - (short) ((List) valueList).get(i).intValue(); - } - } - break; - case INTEGER: - case DATE: - case TIME_WITHOUT_TIME_ZONE: - lcv.child = new HeapIntVector(total); - ((HeapIntVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) lcv.child).setNullAt(i); - } else { - ((HeapIntVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case FLOAT: - lcv.child = new HeapFloatVector(total); - ((HeapFloatVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapFloatVector) lcv.child).setNullAt(i); - } else { - ((HeapFloatVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case BIGINT: - lcv.child = new HeapLongVector(total); - ((HeapLongVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) lcv.child).setNullAt(i); - } else { - ((HeapLongVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case DOUBLE: - lcv.child = new HeapDoubleVector(total); - ((HeapDoubleVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapDoubleVector) lcv.child).setNullAt(i); - } else { - ((HeapDoubleVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - lcv.child = new HeapTimestampVector(total); - ((HeapTimestampVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapTimestampVector) lcv.child).setNullAt(i); - } else { - ((HeapTimestampVector) lcv.child) - .setTimestamp(i, ((List) valueList).get(i)); - } - } - break; - case DECIMAL: - PrimitiveType.PrimitiveTypeName primitiveTypeName = - descriptor.getPrimitiveType().getPrimitiveTypeName(); - switch (primitiveTypeName) { - case INT32: - lcv.child = new ParquetDecimalVector(new HeapIntVector(total)); - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).getVector()).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).getVector()) - .setNullAt(i); - } else { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).getVector()) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - case INT64: - lcv.child = new ParquetDecimalVector(new HeapLongVector(total)); - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).getVector()).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).getVector()) - .setNullAt(i); - } else { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).getVector()) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - default: - lcv.child = new ParquetDecimalVector(new HeapBytesVector(total)); - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).getVector()).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (valueList.get(i) == null) { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).getVector()) - .setNullAt(i); - } else { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).getVector()) - .appendBytes(i, src, 0, src.length); - } - } - break; - } - break; - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java deleted file mode 100644 index 437c186a93661..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java +++ /dev/null @@ -1,44 +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.hudi.table.format.cow.vector.reader; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; - -import java.io.IOException; - -/** - * Array of a Group type (Array, Map, Row, etc.) {@link ColumnReader}. - */ -public class ArrayGroupReader implements ColumnReader { - - private final ColumnReader fieldReader; - - public ArrayGroupReader(ColumnReader fieldReader) { - this.fieldReader = fieldReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayGroupColumnVector rowColumnVector = (HeapArrayGroupColumnVector) vector; - - fieldReader.readToVector(readNumber, rowColumnVector.vector); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java index 7c9fd994a0c25..700d7505fbc73 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java @@ -226,12 +226,7 @@ private void readPageV2(DataPageV2 page) { this.definitionLevelColumn = newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); try { - log.debug( - "page data size " - + page.getData().size() - + " bytes and " - + pageValueCount - + " records"); + log.debug("page data size {} bytes and {} records", page.getData().size(), pageValueCount); initDataReader( page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); } catch (IOException e) { diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java deleted file mode 100644 index ee65dd22c4369..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java +++ /dev/null @@ -1,56 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Map {@link ColumnReader}. - */ -public class MapColumnReader implements ColumnReader { - - private final ArrayColumnReader keyReader; - private final ColumnReader valueReader; - - public MapColumnReader( - ArrayColumnReader keyReader, ColumnReader valueReader) { - this.keyReader = keyReader; - this.valueReader = valueReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapMapColumnVector mapColumnVector = (HeapMapColumnVector) vector; - AbstractHeapVector keyArrayColumnVector = (AbstractHeapVector) (mapColumnVector.getKeys()); - keyReader.readToVector(readNumber, mapColumnVector.getKeys()); - valueReader.readToVector(readNumber, mapColumnVector.getValues()); - for (int i = 0; i < keyArrayColumnVector.getLen(); i++) { - if (keyArrayColumnVector.isNullAt(i)) { - mapColumnVector.setNullAt(i); - } - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java new file mode 100644 index 0000000000000..ac94292c3315f --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.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.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.NestedPositionUtil; +import org.apache.hudi.table.format.cow.vector.HeapArrayVector; +import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; +import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; + +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.ArrayType; +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.util.FlinkRuntimeException; +import org.apache.flink.util.Preconditions; + +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.page.PageReadStore; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ColumnReader used to read a {@code Group} type in Parquet ({@code Map}, {@code Array}, {@code + * Row}). Resolves nested structures using Dremel striping/assembly; see the + * striping and assembly algorithms from the Dremel paper. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedColumnReader}). Differences vs. upstream: + * + *

      + *
    • Uses Hudi-local {@code HeapRowColumnVector}/{@code HeapMapColumnVector}/{@code + * HeapArrayVector} instead of the Flink-private {@code HeapRowVector}/{@code + * HeapMapVector}/{@code HeapArrayVector}. + *
    • Supports Hudi's schema-evolution contract: a {@code ParquetGroupField} representing a + * {@link RowType} may contain {@code null} children — meaning the corresponding logical + * field is absent from the Parquet file. Those slots are passed through unchanged and do + * not contribute to the row's repetition/definition-level stream. + *
    + */ +public class NestedColumnReader implements ColumnReader { + + private final Map columnReaders; + private final boolean isUtcTimestamp; + + private final PageReadStore pages; + + private final ParquetField field; + + public NestedColumnReader(boolean isUtcTimestamp, PageReadStore pages, ParquetField field) { + this.isUtcTimestamp = isUtcTimestamp; + this.pages = pages; + this.field = field; + this.columnReaders = new HashMap<>(); + } + + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + readData(field, readNumber, vector, false); + } + + private Tuple2 readData( + ParquetField field, int readNumber, ColumnVector vector, boolean inside) throws IOException { + if (field.getType() instanceof RowType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof MapType || field.getType() instanceof MultisetType) { + return readMap((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof ArrayType) { + return readArray((ParquetGroupField) field, readNumber, vector, inside); + } else { + return readPrimitive((ParquetPrimitiveField) field, readNumber, vector); + } + } + + private Tuple2 readRow( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapRowColumnVector heapRowVector = (HeapRowColumnVector) vector; + LevelDelegation levelDelegation = null; + List children = field.getChildren(); + WritableColumnVector[] childrenVectors = heapRowVector.getFields(); + WritableColumnVector[] finalChildrenVectors = new WritableColumnVector[childrenVectors.length]; + for (int i = 0; i < children.size(); i++) { + ParquetField child = children.get(i); + if (child == null) { + // Schema-evolution: the logical field is not present in the Parquet file. The slot + // vector was pre-populated with nulls by ParquetSplitReaderUtil#createWritableColumnVector + // (ROW branch), but HeapRowColumnVector#reset() (invoked once per batch by + // ParquetColumnarRowSplitReader#nextBatch) cascades to the children and clears those null + // flags. Since an absent field is never re-read, re-apply the nulls here so the column stays + // NULL instead of reverting to the type's zero value. Skip contributing to the level stream. + childrenVectors[i].fillWithNulls(); + finalChildrenVectors[i] = childrenVectors[i]; + continue; + } + Tuple2 tuple = + readData(child, readNumber, childrenVectors[i], true); + levelDelegation = tuple.f0; + finalChildrenVectors[i] = tuple.f1; + } + if (levelDelegation == null) { + throw new FlinkRuntimeException( + String.format("Row field does not have any non-null children: %s.", field)); + } + + RowPosition rowPosition = + NestedPositionUtil.calculateRowOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If row was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + heapRowVector = new HeapRowColumnVector(rowPosition.getPositionsCount(), finalChildrenVectors); + } else { + heapRowVector.setFields(finalChildrenVectors); + } + + if (rowPosition.getIsNull() != null) { + setFieldNullFlag(rowPosition.getIsNull(), heapRowVector); + } + + // Hudi-specific: collapse a present row whose every child is null into a null row, so that a + // SQL value like `row(null, null)` round-trips to NULL on read. This was the behaviour of the + // legacy RowColumnReader (deleted alongside the Dremel rewire) and existing Hudi tables rely + // on it. Diverges from Flink 2.1, which would surface it as Row(null, null). Pinned by the + // integration test ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes. + // positionsCount comes from the Dremel definition/repetition level stream + // (NestedPositionUtil#calculateRowOffsets). On a full, non-final batch that stream carries a + // one-record lookahead (NestedPrimitiveColumnReader#readAndNewVector reads one value past the + // batch in its do/while, and #getLevelDelegation keeps that trailing level for the next batch), + // so positionsCount can be one larger than the materialized vector lengths. When inside==true + // the row vector is renewed to positionsCount but its children are sized to their value count; + // when inside==false the row vector keeps its batch capacity. Either way, iterating all the way + // to positionsCount can read one element past a shorter vector and throw + // ArrayIndexOutOfBoundsException. Clamp to the shortest vector this loop indexes -- the phantom + // trailing position is never surfaced downstream (ParquetColumnarRowSplitReader caps the batch + // at num). + int rowCount = Math.min(rowPosition.getPositionsCount(), heapRowVector.getLen()); + for (WritableColumnVector child : finalChildrenVectors) { + rowCount = Math.min(rowCount, vectorLength(child)); + } + for (int j = 0; j < rowCount; j++) { + if (heapRowVector.isNullAt(j)) { + continue; + } + boolean allChildrenNull = true; + for (WritableColumnVector child : finalChildrenVectors) { + if (!child.isNullAt(j)) { + allChildrenNull = false; + break; + } + } + if (allChildrenNull) { + heapRowVector.setNullAt(j); + } + } + return Tuple2.of(levelDelegation, heapRowVector); + } + + private Tuple2 readMap( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapMapColumnVector mapVector = (HeapMapColumnVector) vector; + mapVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 2, + "Maps must have two type parameters, found %s", + children.size()); + Tuple2 keyTuple = + readData(children.get(0), readNumber, mapVector.getKeyColumnVector(), true); + Tuple2 valueTuple = + readData(children.get(1), readNumber, mapVector.getValueColumnVector(), true); + + LevelDelegation levelDelegation = keyTuple.f0; + + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If map was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + mapVector = new HeapMapColumnVector(collectionPosition.getValueCount(), keyTuple.f1, valueTuple.f1); + } else { + mapVector.setKeys(keyTuple.f1); + mapVector.setValues(valueTuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), mapVector); + } + + mapVector.setLengths(collectionPosition.getLength()); + mapVector.setOffsets(collectionPosition.getOffsets()); + + return Tuple2.of(levelDelegation, mapVector); + } + + private Tuple2 readArray( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapArrayVector arrayVector = (HeapArrayVector) vector; + arrayVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 1, + "Arrays must have a single type parameter, found %s", + children.size()); + Tuple2 tuple = + readData(children.get(0), readNumber, arrayVector.getChild(), true); + + LevelDelegation levelDelegation = tuple.f0; + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If array was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + arrayVector = new HeapArrayVector(collectionPosition.getValueCount(), tuple.f1); + } else { + arrayVector.setChild(tuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), arrayVector); + } + arrayVector.setLengths(collectionPosition.getLength()); + arrayVector.setOffsets(collectionPosition.getOffsets()); + return Tuple2.of(levelDelegation, arrayVector); + } + + private Tuple2 readPrimitive( + ParquetPrimitiveField field, int readNumber, ColumnVector vector) throws IOException { + ColumnDescriptor descriptor = field.getDescriptor(); + NestedPrimitiveColumnReader reader = columnReaders.get(descriptor); + if (reader == null) { + reader = + new NestedPrimitiveColumnReader( + descriptor, + pages.getPageReader(descriptor), + isUtcTimestamp, + descriptor.getPrimitiveType(), + field.getType()); + columnReaders.put(descriptor, reader); + } + WritableColumnVector writableColumnVector = + reader.readAndNewVector(readNumber, (WritableColumnVector) vector); + return Tuple2.of(reader.getLevelDelegation(), writableColumnVector); + } + + /** + * The length of the {@code isNull}-backed storage that {@code vector} (a row child) is indexed + * against by the null-collapse loop in {@link #readRow}. Every row child is an {@link + * AbstractHeapVector} (nested rows/arrays/maps and all non-decimal primitives) or a {@link + * ParquetDecimalVector} wrapping one (DECIMAL leaves; see {@code + * NestedPrimitiveColumnReader#fillColumnVector}); unwrapping the latter yields an {@code + * AbstractHeapVector} in all cases. + */ + private static int vectorLength(ColumnVector vector) { + ColumnVector storage = + vector instanceof ParquetDecimalVector + ? ((ParquetDecimalVector) vector).getVector() + : vector; + return ((AbstractHeapVector) storage).getLen(); + } + + private static void setFieldNullFlag(boolean[] nullFlags, AbstractHeapVector vector) { + for (int index = 0; index < vector.getLen() && index < nullFlags.length; index++) { + if (nullFlags[index]) { + vector.setNullAt(index); + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java new file mode 100644 index 0000000000000..72809db1b2ceb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java @@ -0,0 +1,639 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.IntArrayList; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; + +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.LogicalType; + +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +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.DictionaryPage; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.parquet.column.ValuesType.DEFINITION_LEVEL; +import static org.apache.parquet.column.ValuesType.REPETITION_LEVEL; +import static org.apache.parquet.column.ValuesType.VALUES; + +/** + * Reader to read a single primitive leaf column that participates in a nested (Dremel) structure. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedPrimitiveColumnReader}). Only the package + * and the Hudi-local {@link ParquetDecimalVector} / {@link LevelDelegation} / {@link IntArrayList} + * imports are changed; the algorithm is untouched. The companion Hudi-specific {@code + * Int64TimestampColumnReader} / {@code FixedLenBytesColumnReader} behaviours stay at the leaf- + * reader creation boundary in {@code ParquetSplitReaderUtil}, not inside this class — keeping it + * a faithful copy of upstream. + */ +public class NestedPrimitiveColumnReader implements ColumnReader { + private static final Logger LOG = LoggerFactory.getLogger(NestedPrimitiveColumnReader.class); + + private final IntArrayList repetitionLevelList = new IntArrayList(0); + private final IntArrayList definitionLevelList = new IntArrayList(0); + + private final PageReader pageReader; + private final ColumnDescriptor descriptor; + private final Type type; + private final LogicalType logicalType; + + /** The dictionary, if this column has dictionary encoding. */ + private final ParquetDataColumnReader dictionary; + + /** Maximum definition level for this column. */ + private final int maxDefLevel; + + private boolean isUtcTimestamp; + + /** Total number of values read. */ + private long valuesRead; + + /** + * value that indicates the end of the current page. That is, if valuesRead == + * endOfPageValueCount, we are at the end of the page. + */ + private long endOfPageValueCount; + + /** If true, the current page is dictionary encoded. */ + private boolean isCurrentPageDictionaryEncoded; + + private int definitionLevel; + private int repetitionLevel; + + /** Repetition/Definition/Value readers. */ + private IntIterator repetitionLevelColumn; + + private IntIterator definitionLevelColumn; + private ParquetDataColumnReader dataColumn; + + /** Total values in the current page. */ + private int pageValueCount; + + // flag to indicate if there is no data in parquet data page + private boolean eof = false; + + private boolean isFirstRow = true; + + private Object lastValue; + + public NestedPrimitiveColumnReader( + ColumnDescriptor descriptor, + PageReader pageReader, + boolean isUtcTimestamp, + Type parquetType, + LogicalType logicalType) + throws IOException { + this.descriptor = descriptor; + this.type = parquetType; + this.pageReader = pageReader; + this.maxDefLevel = descriptor.getMaxDefinitionLevel(); + this.isUtcTimestamp = isUtcTimestamp; + this.logicalType = logicalType; + + DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); + if (dictionaryPage != null) { + try { + this.dictionary = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + parquetType.asPrimitiveType(), + dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } catch (IOException e) { + throw new IOException( + String.format("Could not decode the dictionary for %s", descriptor), e); + } + } else { + this.dictionary = null; + this.isCurrentPageDictionaryEncoded = false; + } + } + + // Not invoked directly; callers use readAndNewVector instead. + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + throw new UnsupportedOperationException("This function should not be called."); + } + + public WritableColumnVector readAndNewVector(int readNumber, WritableColumnVector vector) + throws IOException { + if (isFirstRow) { + if (!readValue()) { + return vector; + } + isFirstRow = false; + } + + // index to set value. + int index = 0; + int valueIndex = 0; + List valueList = new ArrayList<>(); + + // repeated type need two loops to read data. + while (!eof && index < readNumber) { + do { + valueList.add(lastValue); + valueIndex++; + } while (readValue() && (repetitionLevel != 0)); + index++; + } + + return fillColumnVector(valueIndex, valueList); + } + + public LevelDelegation getLevelDelegation() { + int[] repetition = repetitionLevelList.toArray(); + int[] definition = definitionLevelList.toArray(); + repetitionLevelList.clear(); + definitionLevelList.clear(); + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + return new LevelDelegation(repetition, definition); + } + + private boolean readValue() throws IOException { + int left = readPageIfNeed(); + if (left > 0) { + // get the values of repetition and definitionLevel + readAndSaveRepetitionAndDefinitionLevels(); + // read the data if it isn't null + if (definitionLevel == maxDefLevel) { + if (isCurrentPageDictionaryEncoded) { + int dictionaryId = dataColumn.readValueDictionaryId(); + lastValue = dictionaryDecodeValue(logicalType, dictionaryId); + } else { + lastValue = readPrimitiveTypedRow(logicalType); + } + } else { + lastValue = null; + } + return true; + } else { + eof = true; + return false; + } + } + + private void readAndSaveRepetitionAndDefinitionLevels() { + // get the values of repetition and definitionLevel + repetitionLevel = repetitionLevelColumn.nextInt(); + definitionLevel = definitionLevelColumn.nextInt(); + valuesRead++; + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + } + + private int readPageIfNeed() throws IOException { + // Compute the number of values we want to read in this page. + int leftInPage = (int) (endOfPageValueCount - valuesRead); + if (leftInPage == 0) { + // no data left in current page, load data from new page + readPage(); + leftInPage = (int) (endOfPageValueCount - valuesRead); + } + return leftInPage; + } + + private Object readPrimitiveTypedRow(LogicalType category) { + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dataColumn.readBytes(); + case BOOLEAN: + return dataColumn.readBoolean(); + case TIME_WITHOUT_TIME_ZONE: + case DATE: + case INTEGER: + return dataColumn.readInteger(); + case TINYINT: + return dataColumn.readTinyInt(); + case SMALLINT: + return dataColumn.readSmallInt(); + case BIGINT: + return dataColumn.readLong(); + case FLOAT: + return dataColumn.readFloat(); + case DOUBLE: + return dataColumn.readDouble(); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dataColumn.readInteger(); + case INT64: + return dataColumn.readLong(); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return dataColumn.readBytes(); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dataColumn.readTimestamp(); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { + if (dictionaryValue == null) { + return null; + } + + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dictionary.readBytes(dictionaryValue); + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTEGER: + return dictionary.readInteger(dictionaryValue); + case BOOLEAN: + return dictionary.readBoolean(dictionaryValue) ? 1 : 0; + case DOUBLE: + return dictionary.readDouble(dictionaryValue); + case FLOAT: + return dictionary.readFloat(dictionaryValue); + case TINYINT: + return dictionary.readTinyInt(dictionaryValue); + case SMALLINT: + return dictionary.readSmallInt(dictionaryValue); + case BIGINT: + return dictionary.readLong(dictionaryValue); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dictionary.readInteger(dictionaryValue); + case INT64: + return dictionary.readLong(dictionaryValue); + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return dictionary.readBytes(dictionaryValue); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dictionary.readTimestamp(dictionaryValue); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WritableColumnVector fillColumnVector(int total, List valueList) { + switch (logicalType.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + HeapBytesVector heapBytesVector = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (src == null) { + heapBytesVector.setNullAt(i); + } else { + heapBytesVector.appendBytes(i, src, 0, src.length); + } + } + return heapBytesVector; + case BOOLEAN: + HeapBooleanVector heapBooleanVector = new HeapBooleanVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapBooleanVector.setNullAt(i); + } else { + heapBooleanVector.vector[i] = ((List) valueList).get(i); + } + } + return heapBooleanVector; + case TINYINT: + HeapByteVector heapByteVector = new HeapByteVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapByteVector.setNullAt(i); + } else { + heapByteVector.vector[i] = (byte) ((List) valueList).get(i).intValue(); + } + } + return heapByteVector; + case SMALLINT: + HeapShortVector heapShortVector = new HeapShortVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapShortVector.setNullAt(i); + } else { + heapShortVector.vector[i] = (short) ((List) valueList).get(i).intValue(); + } + } + return heapShortVector; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + HeapIntVector heapIntVector = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapIntVector.setNullAt(i); + } else { + heapIntVector.vector[i] = ((List) valueList).get(i); + } + } + return heapIntVector; + case FLOAT: + HeapFloatVector heapFloatVector = new HeapFloatVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapFloatVector.setNullAt(i); + } else { + heapFloatVector.vector[i] = ((List) valueList).get(i); + } + } + return heapFloatVector; + case BIGINT: + HeapLongVector heapLongVector = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapLongVector.setNullAt(i); + } else { + heapLongVector.vector[i] = ((List) valueList).get(i); + } + } + return heapLongVector; + case DOUBLE: + HeapDoubleVector heapDoubleVector = new HeapDoubleVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapDoubleVector.setNullAt(i); + } else { + heapDoubleVector.vector[i] = ((List) valueList).get(i); + } + } + return heapDoubleVector; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + HeapTimestampVector heapTimestampVector = new HeapTimestampVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapTimestampVector.setNullAt(i); + } else { + heapTimestampVector.setTimestamp(i, ((List) valueList).get(i)); + } + } + return heapTimestampVector; + case DECIMAL: + PrimitiveType.PrimitiveTypeName primitiveTypeName = + descriptor.getPrimitiveType().getPrimitiveTypeName(); + switch (primitiveTypeName) { + case INT32: + HeapIntVector phiv = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phiv.setNullAt(i); + } else { + phiv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phiv); + case INT64: + HeapLongVector phlv = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phlv.setNullAt(i); + } else { + phlv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phlv); + default: + HeapBytesVector phbv = getHeapBytesVector(total, valueList); + return new ParquetDecimalVector(phbv); + } + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HeapBytesVector getHeapBytesVector(int total, List valueList) { + HeapBytesVector phbv = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (valueList.get(i) == null) { + phbv.setNullAt(i); + } else { + phbv.appendBytes(i, src, 0, src.length); + } + } + return phbv; + } + + protected void readPage() { + DataPage page = pageReader.readPage(); + + if (page == null) { + return; + } + + page.accept( + new DataPage.Visitor() { + @Override + public Void visit(DataPageV1 dataPageV1) { + readPageV1(dataPageV1); + return null; + } + + @Override + public Void visit(DataPageV2 dataPageV2) { + readPageV2(dataPageV2); + return null; + } + }); + } + + private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int valueCount) + throws IOException { + this.pageValueCount = valueCount; + this.endOfPageValueCount = valuesRead + pageValueCount; + if (dataEncoding.usesDictionary()) { + this.dataColumn = null; + if (dictionary == null) { + throw new IOException( + String.format( + "Could not read page in col %s because the dictionary was missing for encoding %s.", + descriptor, dataEncoding)); + } + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getDictionaryBasedValuesReader( + descriptor, VALUES, dictionary.getDictionary()), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } else { + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getValuesReader(descriptor, VALUES), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = false; + } + + try { + dataColumn.initFromPage(pageValueCount, in); + } catch (IOException e) { + throw new IOException(String.format("Could not read page in col %s.", descriptor), e); + } + } + + private void readPageV1(DataPageV1 page) { + ValuesReader rlReader = page.getRlEncoding().getValuesReader(descriptor, REPETITION_LEVEL); + ValuesReader dlReader = page.getDlEncoding().getValuesReader(descriptor, DEFINITION_LEVEL); + this.repetitionLevelColumn = new ValuesReaderIntIterator(rlReader); + this.definitionLevelColumn = new ValuesReaderIntIterator(dlReader); + try { + BytesInput bytes = page.getBytes(); + LOG.debug("Page size {} bytes and {} records.", bytes.size(), pageValueCount); + ByteBufferInputStream in = bytes.toInputStream(); + LOG.debug("Reading repetition levels at {}.", in.position()); + rlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading definition levels at {}.", in.position()); + dlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading data at {}.", in.position()); + initDataReader(page.getValueEncoding(), in, page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private void readPageV2(DataPageV2 page) { + this.pageValueCount = page.getValueCount(); + this.repetitionLevelColumn = + newRLEIterator(descriptor.getMaxRepetitionLevel(), page.getRepetitionLevels()); + this.definitionLevelColumn = + newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); + try { + LOG.debug( + "Page data size {} bytes and {} records.", page.getData().size(), pageValueCount); + initDataReader( + page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private IntIterator newRLEIterator(int maxLevel, BytesInput bytes) { + try { + if (maxLevel == 0) { + return new NullIntIterator(); + } + return new RLEIntIterator( + new RunLengthBitPackingHybridDecoder( + BytesUtils.getWidthFromMaxInt(maxLevel), + new ByteArrayInputStream(bytes.toByteArray()))); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read levels in page for col %s.", descriptor), e); + } + } + + /** Utility interface to abstract over different way to read ints with different encodings. */ + interface IntIterator { + int nextInt(); + } + + /** Reading int from {@link ValuesReader}. */ + protected static final class ValuesReaderIntIterator implements IntIterator { + ValuesReader delegate; + + public ValuesReaderIntIterator(ValuesReader delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + return delegate.readInteger(); + } + } + + /** Reading int from {@link RunLengthBitPackingHybridDecoder}. */ + protected static final class RLEIntIterator implements IntIterator { + RunLengthBitPackingHybridDecoder delegate; + + public RLEIntIterator(RunLengthBitPackingHybridDecoder delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + try { + return delegate.readInt(); + } catch (IOException e) { + throw new ParquetDecodingException(e); + } + } + } + + /** Reading zero always. */ + protected static final class NullIntIterator implements IntIterator { + @Override + public int nextInt() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java index 3572b117a6313..1826419db5d44 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java @@ -18,7 +18,9 @@ package org.apache.hudi.table.format.cow.vector.reader; +import org.apache.hudi.table.format.cow.ParquetSplitReaderUtil; import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; import org.apache.flink.formats.parquet.vector.reader.ColumnReader; import org.apache.flink.table.data.RowData; @@ -28,6 +30,7 @@ import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.util.FlinkRuntimeException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -39,6 +42,8 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.Type; @@ -46,6 +51,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,7 +59,6 @@ import java.util.Map; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createColumnReader; import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createWritableColumnVector; import static org.apache.parquet.filter2.compat.FilterCompat.get; import static org.apache.parquet.filter2.compat.RowGroupFilter.filterRowGroups; @@ -77,6 +82,14 @@ public class ParquetColumnarRowSplitReader implements Closeable { private final MessageType requestedSchema; + /** + * {@link ParquetField} tree per top-level requested column, used by + * {@link ParquetSplitReaderUtil#createColumnReader(boolean, LogicalType, Type, List, + * PageReadStore, ParquetField)} to drive the Dremel-style {@link NestedColumnReader} for + * nested types. Entries are {@code null} for primitive top-level fields. Built once per split. + */ + private final List requestedFields; + /** * The total number of rows this RecordReader will eventually read. The sum of the rows of all * the row groups. @@ -158,6 +171,20 @@ public ParquetColumnarRowSplitReader( checkSchema(); + // Build the ParquetField tree once per split (the Dremel-style nested reader reuses it across + // row groups). Only columns with nested logical type get a non-null entry — primitive columns + // still use Hudi's specialized ColumnReaders. + MessageColumnIO messageColumnIO = new ColumnIOFactory().getColumnIO(requestedSchema); + List requestedRowFields = new ArrayList<>(requestedTypes.length); + List requestedFieldNames = new ArrayList<>(requestedTypes.length); + for (int i = 0; i < requestedTypes.length; i++) { + String name = requestedSchema.getFieldName(i); + requestedRowFields.add(new RowType.RowField(name, requestedTypes[i])); + requestedFieldNames.add(name); + } + this.requestedFields = ParquetSplitReaderUtil.buildFieldsList( + requestedRowFields, requestedFieldNames, messageColumnIO); + this.writableVectors = createWritableVectors(); ColumnVector[] columnVectors = patchedVector(selectedFieldNames.length, createReadableVectors(), requestedIndices); this.columnarBatch = generator.generate(columnVectors); @@ -340,12 +367,13 @@ private void readNextRowGroup() throws IOException { List columns = requestedSchema.getColumns(); columnReaders = new ColumnReader[types.size()]; for (int i = 0; i < types.size(); ++i) { - columnReaders[i] = createColumnReader( + columnReaders[i] = ParquetSplitReaderUtil.createColumnReader( utcTimestamp, requestedTypes[i], types.get(i), columns, - pages); + pages, + requestedFields.get(i)); } totalCountLoadedSoFar += pages.getRowCount(); } diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java index fdfe5d6fa3a33..1abc6ed56c0db 100644 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java @@ -26,12 +26,16 @@ import org.apache.parquet.column.Dictionary; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; @@ -252,21 +256,115 @@ public TimestampData readTimestamp() { } } + /** + * Reader for Parquet INT64 timestamp values (MILLIS / MICROS / NANOS), i.e. the standard + * timestamp encoding defined by Parquet's + * {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and the legacy + * {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} annotations. + * (The older INT96 encoding is marked deprecated by the Parquet format spec — see + * + * LogicalTypes.md — but is still supported here via {@link TypesFromInt96PageReader} for + * backwards compatibility with files written by older Hive / Spark / Impala versions.) + * + *

    Used by {@link NestedPrimitiveColumnReader} when a TIMESTAMP column sits inside a + * {@code Row}, {@code Array} or {@code Map}; the top-level path continues to use + * {@link Int64TimestampColumnReader} for batched-vector efficiency. + */ + public static class TypesFromInt64PageReader extends DefaultParquetDataColumnReader { + private final boolean isUtcTimestamp; + private final ChronoUnit chronoUnit; + + public TypesFromInt64PageReader( + ValuesReader realReader, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(realReader); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + public TypesFromInt64PageReader( + Dictionary dict, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(dict); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + @Override + public TimestampData readTimestamp() { + return int64ToTimestamp(isUtcTimestamp, valuesReader.readLong(), chronoUnit); + } + + @Override + public TimestampData readTimestamp(int id) { + return int64ToTimestamp(isUtcTimestamp, dict.decodeToLong(id), chronoUnit); + } + } + private static ParquetDataColumnReader getDataColumnReaderByTypeHelper( boolean isDictionary, PrimitiveType parquetType, Dictionary dictionary, ValuesReader valuesReader, boolean isUtcTimestamp) { - if (parquetType.getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.INT96) { + PrimitiveType.PrimitiveTypeName typeName = parquetType.getPrimitiveTypeName(); + if (typeName == PrimitiveType.PrimitiveTypeName.INT96) { return isDictionary ? new TypesFromInt96PageReader(dictionary, isUtcTimestamp) : new TypesFromInt96PageReader(valuesReader, isUtcTimestamp); - } else { - return isDictionary - ? new DefaultParquetDataColumnReader(dictionary) - : new DefaultParquetDataColumnReader(valuesReader); } + if (typeName == PrimitiveType.PrimitiveTypeName.INT64) { + ChronoUnit unit = resolveInt64TimestampUnit(parquetType); + if (unit != null) { + return isDictionary + ? new TypesFromInt64PageReader(dictionary, isUtcTimestamp, unit) + : new TypesFromInt64PageReader(valuesReader, isUtcTimestamp, unit); + } + } + return isDictionary + ? new DefaultParquetDataColumnReader(dictionary) + : new DefaultParquetDataColumnReader(valuesReader); + } + + /** + * Returns the {@link ChronoUnit} for a Parquet INT64 TIMESTAMP column, or {@code null} if the + * column is a plain INT64 (not a timestamp). + * + *

    Supports both the modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and + * the legacy {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} + * encodings. + */ + private static ChronoUnit resolveInt64TimestampUnit(PrimitiveType parquetType) { + LogicalTypeAnnotation annotation = parquetType.getLogicalTypeAnnotation(); + if (annotation instanceof LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) { + LogicalTypeAnnotation.TimeUnit unit = + ((LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation).getUnit(); + switch (unit) { + case MILLIS: + return ChronoUnit.MILLIS; + case MICROS: + return ChronoUnit.MICROS; + case NANOS: + return ChronoUnit.NANOS; + default: + return null; + } + } + OriginalType originalType = parquetType.getOriginalType(); + if (originalType == OriginalType.TIMESTAMP_MILLIS) { + return ChronoUnit.MILLIS; + } + if (originalType == OriginalType.TIMESTAMP_MICROS) { + return ChronoUnit.MICROS; + } + return null; + } + + private static TimestampData int64ToTimestamp( + boolean isUtcTimestamp, long value, ChronoUnit unit) { + Instant instant = Instant.EPOCH.plus(value, unit); + if (isUtcTimestamp) { + return TimestampData.fromInstant(instant); + } + return TimestampData.fromTimestamp(Timestamp.from(instant)); } public static ParquetDataColumnReader getDataColumnReaderByTypeOnDictionary( @@ -281,10 +379,10 @@ public static ParquetDataColumnReader getDataColumnReaderByType( } private static TimestampData int96ToTimestamp( - boolean utcTimestamp, long nanosOfDay, int julianDay) { + boolean isUtcTimestamp, long nanosOfDay, int julianDay) { long millisecond = julianDayToMillis(julianDay) + (nanosOfDay / NANOS_PER_MILLISECOND); - if (utcTimestamp) { + if (isUtcTimestamp) { int nanoOfMillisecond = (int) (nanosOfDay % NANOS_PER_MILLISECOND); return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); } else { diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java b/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java deleted file mode 100644 index 79b50487f13c1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.18.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; -import java.util.List; - -/** - * Row {@link ColumnReader}. - */ -public class RowColumnReader implements ColumnReader { - - private final List fieldReaders; - - public RowColumnReader(List fieldReaders) { - this.fieldReaders = fieldReaders; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapRowColumnVector rowColumnVector = (HeapRowColumnVector) vector; - WritableColumnVector[] vectors = rowColumnVector.vectors; - // row vector null array - boolean[] isNulls = new boolean[readNumber]; - for (int i = 0; i < vectors.length; i++) { - fieldReaders.get(i).readToVector(readNumber, vectors[i]); - - for (int j = 0; j < readNumber; j++) { - if (i == 0) { - isNulls[j] = vectors[i].isNullAt(j); - } else { - isNulls[j] = isNulls[j] && vectors[i].isNullAt(j); - } - if (i == vectors.length - 1 && isNulls[j]) { - // rowColumnVector[j] is null only when all fields[j] of rowColumnVector[j] is - // null - rowColumnVector.setNullAt(j); - } - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..ae2e4107d6ea7 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + throw new UnsupportedOperationException("Variant is not supported yet."); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java new file mode 100644 index 0000000000000..7cb62824e8543 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.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.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests for the Flink 2.1-compatible accessors added on {@link HeapArrayVector}, + * {@link HeapMapColumnVector} and {@link HeapRowColumnVector} when vendoring Flink 2.1's + * nested-Parquet reader (FLINK-35702). + * + *

    The accessors are wrappers over the existing public fields so legacy callers continue to + * work. These tests exist solely to pin down that wrapper contract — runtime correctness of the + * Dremel-style read path is exercised end-to-end by integration tests in + * {@code ITTestHoodieDataSource} (testParquetComplexTypes / testParquetComplexNestedRowTypes / + * testParquetArrayMapOfRowTypes / testParquetNullChildColumnsRowTypes). + */ +class TestHeapColumnVectorAccessors { + + // ----------------------------------------------------------------------------------------------- + // HeapArrayVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapArrayVectorAccessorsReflectPublicFields() { + HeapIntVector child = new HeapIntVector(4); + HeapArrayVector vector = new HeapArrayVector(2, child); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector replacementChild = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setChild(replacementChild); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(replacementChild, vector.getChild()); + assertEquals(2, vector.getSize()); + + // Backing public fields are kept in sync — preserves backward compatibility. + assertSame(offsets, vector.offsets); + assertSame(lengths, vector.lengths); + assertSame(replacementChild, vector.child); + } + + // ----------------------------------------------------------------------------------------------- + // HeapMapColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapMapColumnVectorConstructorInitializesOffsetsAndLengths() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + + HeapMapColumnVector vector = new HeapMapColumnVector(3, keys, values); + + assertEquals(3, vector.getOffsets().length); + assertEquals(3, vector.getLengths().length); + } + + @Test + void heapMapColumnVectorAccessorsReflectInternalState() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + HeapMapColumnVector vector = new HeapMapColumnVector(2, keys, values); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector newKeys = new HeapLongVector(4); + HeapLongVector newValues = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setKeys(newKeys); + vector.setValues(newValues); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(newKeys, vector.getKeys()); + assertSame(newValues, vector.getValues()); + // The Flink-2.1-style ColumnVector accessors return the same underlying child. + assertSame(newKeys, vector.getKeyColumnVector()); + assertSame(newValues, vector.getValueColumnVector()); + assertEquals(2, vector.getSize()); + } + + // ----------------------------------------------------------------------------------------------- + // HeapRowColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapRowColumnVectorFieldsAccessorsReflectPublicVectors() { + HeapIntVector intField = new HeapIntVector(2); + HeapLongVector longField = new HeapLongVector(2); + HeapRowColumnVector vector = new HeapRowColumnVector(2, intField, longField); + + WritableColumnVector[] originalFields = vector.getFields(); + assertEquals(2, originalFields.length); + assertSame(intField, originalFields[0]); + assertSame(longField, originalFields[1]); + // Backing public field is kept in sync — preserves backward compatibility. + assertSame(originalFields, vector.vectors); + + HeapIntVector replacement = new HeapIntVector(2); + WritableColumnVector[] replacementFields = {replacement, longField}; + vector.setFields(replacementFields); + + assertSame(replacementFields, vector.getFields()); + assertSame(replacementFields, vector.vectors); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java new file mode 100644 index 0000000000000..9d6607d03febb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.18.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.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.hudi.table.format.cow.vector.reader; + +import org.apache.flink.table.data.TimestampData; + +import org.apache.parquet.column.Dictionary; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for the {@link ParquetDataColumnReaderFactory} INT64 timestamp dispatch added when + * vendoring Flink 2.1's nested-Parquet reader (FLINK-35702). + * + *

    The factory is exercised end-to-end by integration tests through + * {@link NestedPrimitiveColumnReader}; this unit test focuses on the small, deterministic piece + * that was added by this PR — selecting the right {@code ParquetDataColumnReader} for each + * supported INT64 TIMESTAMP encoding (modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} + * MILLIS / MICROS / NANOS plus the legacy {@link OriginalType} encodings) and decoding values + * using both the values-reader and dictionary code paths. + */ +class TestParquetDataColumnReaderFactory { + + // ----------------------------------------------------------------------------------------------- + // Type dispatch + // ----------------------------------------------------------------------------------------------- + + @Test + void valuesReaderDispatchInt96TimestampUsesInt96Reader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT96).named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt96PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64WithoutAnnotationUsesDefaultReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT64).named("plainLong"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMicrosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampNanosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMillisOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MILLIS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMicrosOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MICROS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt32DoesNotUseTimestampReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT32).named("i"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void dictionaryReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + // ----------------------------------------------------------------------------------------------- + // INT64 → TimestampData decoding (per ChronoUnit, both UTC and local-time-zone branches) + // ----------------------------------------------------------------------------------------------- + + @Test + void int64ReaderReadsTimestampMillisFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_123L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMillis), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + assertEquals(0, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMicrosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + long epochMicros = 1_700_000_000_123_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMicros), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMicros / 1_000L, ts.getMillisecond()); + // 456 microseconds remain → 456_000 nanoseconds within the millisecond + assertEquals(456_000, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampNanosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + long epochNanos = 1_700_000_000_123_456_789L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochNanos), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochNanos / 1_000_000L, ts.getMillisecond()); + assertEquals(456_789, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMillisFromDictionaryInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(epochMillis), true); + + TimestampData ts = reader.readTimestamp(0); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + } + + // ----------------------------------------------------------------------------------------------- + // Stubs (only the methods exercised by the dispatch + decoding tests above) + // ----------------------------------------------------------------------------------------------- + + /** Minimal {@link ValuesReader} returning a fixed long; other methods throw. */ + private static final class StubValuesReader extends ValuesReader { + private final long fixedLong; + + StubValuesReader() { + this(0L); + } + + StubValuesReader(long fixedLong) { + this.fixedLong = fixedLong; + } + + @Override + public long readLong() { + return fixedLong; + } + + @Override + public void skip() { + // unused + } + } + + /** Minimal {@link Dictionary} returning a fixed long for any id; other methods throw. */ + private static final class StubDictionary extends Dictionary { + private final long fixedLong; + + StubDictionary() { + this(0L); + } + + StubDictionary(long fixedLong) { + super(null); + this.fixedLong = fixedLong; + } + + @Override + public Binary decodeToBinary(int id) { + throw new UnsupportedOperationException(); + } + + @Override + public long decodeToLong(int id) { + return fixedLong; + } + + @Override + public int getMaxId() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/pom.xml b/hudi-flink-datasource/hudi-flink1.19.x/pom.xml index 5c29d042577e0..de712340cf5c3 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink1.19.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -127,12 +127,6 @@ ${flink1.19.version} provided - - org.apache.flink - flink-table-planner_2.12 - ${flink1.19.version} - provided - diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..e8e31b341a180 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +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.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + private static final String VARIANT_UNSUPPORTED_MSG = + "VARIANT type is only supported in Flink 2.1+. " + + "Please upgrade your Flink version to use Variant columns."; + + public static Option variantParquetAnnotation() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Variant getVariant(RowData rowData, int pos) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static boolean isVariantType(LogicalType logicalType) { + return false; + } + + public static DataType createVariantType() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantMetadata(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantValue(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java index ab05beb87459d..5468dc86a25a6 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java @@ -7,7 +7,7 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,19 +19,18 @@ package org.apache.hudi.table.format.cow; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; import org.apache.hudi.table.format.cow.vector.HeapArrayVector; import org.apache.hudi.table.format.cow.vector.HeapDecimalVector; import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; -import org.apache.hudi.table.format.cow.vector.reader.ArrayColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.ArrayGroupReader; import org.apache.hudi.table.format.cow.vector.reader.EmptyColumnReader; import org.apache.hudi.table.format.cow.vector.reader.FixedLenBytesColumnReader; import org.apache.hudi.table.format.cow.vector.reader.Int64TimestampColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.MapColumnReader; +import org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader; import org.apache.hudi.table.format.cow.vector.reader.ParquetColumnarRowSplitReader; -import org.apache.hudi.table.format.cow.vector.reader.RowColumnReader; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.vector.reader.BooleanColumnReader; @@ -64,12 +63,14 @@ 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.LogicalTypeRoot; 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.TimestampType; +import org.apache.flink.util.FlinkRuntimeException; import org.apache.flink.util.Preconditions; +import org.apache.flink.util.StringUtils; + import org.apache.hadoop.conf.Configuration; import org.apache.parquet.ParquetRuntimeException; import org.apache.parquet.column.ColumnDescriptor; @@ -77,12 +78,18 @@ import org.apache.parquet.column.page.PageReader; import org.apache.parquet.filter.UnboundRecordFilter; import org.apache.parquet.filter2.predicate.FilterPredicate; +import org.apache.parquet.io.ColumnIO; +import org.apache.parquet.io.GroupColumnIO; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.PrimitiveColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.InvalidSchemaException; import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; +import javax.annotation.Nullable; + import java.io.IOException; import java.math.BigDecimal; import java.sql.Date; @@ -90,25 +97,38 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.apache.flink.table.utils.DateTimeUtils.toInternal; import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; import static org.apache.parquet.Preconditions.checkArgument; +import static org.apache.parquet.schema.Type.Repetition.REPEATED; +import static org.apache.parquet.schema.Type.Repetition.REQUIRED; /** * Util for generating {@link ParquetColumnarRowSplitReader}. * - *

    NOTE: reference from Flink release 1.11.2 {@code ParquetSplitReaderUtil}, modify to support INT64 - * based TIMESTAMP_MILLIS as ConvertedType, should remove when Flink supports that. + *

    Uses the Dremel-style nested reader ported from Apache Flink 2.1 (FLINK-35702). For primitive + * top-level columns we keep Hudi's specialized readers — {@link Int64TimestampColumnReader}, + * {@link FixedLenBytesColumnReader}, and the Hudi {@link HeapDecimalVector} — unchanged. For + * nested types (ARRAY / MAP / MULTISET / ROW) we build a {@link ParquetField} tree once per + * split via {@link #buildFieldsList(List, List, MessageColumnIO)} and delegate reading to + * {@link NestedColumnReader}. + * + *

    Schema evolution: missing top-level fields are still handled by the caller + * ({@link ParquetColumnarRowSplitReader} patches them with null vectors). Missing fields + * inside a Row are handled here — {@link #constructField} returns {@code null} for a + * child that isn't physically present, and the corresponding child in the pre-allocated vector + * is filled with nulls via {@link #createVectorFromConstant} so the Dremel assembler can + * passthrough the slot (see {@link NestedColumnReader#readToVector}). */ public class ParquetSplitReaderUtil { - /** - * Util for generating partitioned {@link ParquetColumnarRowSplitReader}. - */ + /** Util for generating partitioned {@link ParquetColumnarRowSplitReader}. */ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( boolean utcTimestamp, boolean caseSensitive, @@ -125,7 +145,7 @@ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( UnboundRecordFilter recordFilter) throws IOException { ValidationUtils.checkState(Arrays.stream(selectedFields).noneMatch(x -> x == -1), - "One or more specified columns does not exist in the hudi table."); + "One or more specified columns does not exist in the hudi table."); List selNonPartNames = Arrays.stream(selectedFields) .mapToObj(i -> fullFieldNames[i]) @@ -182,10 +202,13 @@ private static ColumnVector createVector( return readVector; } - private static ColumnVector createVectorFromConstant( - LogicalType type, - Object value, - int batchSize) { + /** + * Builds a constant-filled column vector for either a partition column (non-null value) or a + * missing-column slot (null value). Used both at the batch-generator level for partition + * injection and at the row-reader level for fields absent from the Parquet file. + */ + public static ColumnVector createVectorFromConstant( + LogicalType type, Object value, int batchSize) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -278,6 +301,7 @@ private static ColumnVector createVectorFromConstant( value == null ? null : toInternal((Date) value), batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: HeapTimestampVector tv = new HeapTimestampVector(batchSize); if (value == null) { tv.fillWithNulls(); @@ -286,46 +310,41 @@ private static ColumnVector createVectorFromConstant( } return tv; case ARRAY: - ArrayType arrayType = (ArrayType) type; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - HeapArrayGroupColumnVector arrayGroup = new HeapArrayGroupColumnVector(batchSize); - if (value == null) { - arrayGroup.fillWithNulls(); - return arrayGroup; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } - } else { - HeapArrayVector arrayVector = new HeapArrayVector(batchSize); - if (value == null) { - arrayVector.fillWithNulls(); - return arrayVector; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } + if (value != null) { + throw new UnsupportedOperationException("Unsupported create array with default value."); } + HeapArrayVector arrayVector = new HeapArrayVector(batchSize); + arrayVector.fillWithNulls(); + return arrayVector; case MAP: - HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); - if (value == null) { - mapVector.fillWithNulls(); - return mapVector; - } else { - throw new UnsupportedOperationException("Unsupported create map with default value."); + case MULTISET: + if (value != null) { + throw new UnsupportedOperationException( + "Unsupported create " + type.getTypeRoot() + " with default value."); } + HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); + mapVector.fillWithNulls(); + return mapVector; case ROW: - HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize); - if (value == null) { - rowVector.fillWithNulls(); - return rowVector; - } else { + if (value != null) { throw new UnsupportedOperationException("Unsupported create row with default value."); } + RowType rowType = (RowType) type; + WritableColumnVector[] childVectors = new WritableColumnVector[rowType.getFieldCount()]; + for (int i = 0; i < childVectors.length; i++) { + childVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); + } + HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize, childVectors); + rowVector.fillWithNulls(); + return rowVector; default: throw new UnsupportedOperationException("Unsupported type: " + type); } } - private static List filterDescriptors(int depth, Type type, List columns) throws ParquetRuntimeException { + private static List filterDescriptors( + int depth, Type type, List columns) throws ParquetRuntimeException { List filtered = new ArrayList<>(); for (ColumnDescriptor descriptor : columns) { if (depth >= descriptor.getPath().length) { @@ -339,24 +358,61 @@ private static List filterDescriptors(int depth, Type type, Li return filtered; } + /** + * Creates a {@link ColumnReader} for one top-level requested field. For primitive types the + * Hudi-specialized reader path is used. For nested types ({@code ARRAY}, {@code MAP}, + * {@code MULTISET}, {@code ROW}) the Dremel-style {@link NestedColumnReader} is used, driven by + * the supplied pre-built {@link ParquetField} tree. + * + * @param field the {@link ParquetField} tree for this column, built by + * {@link #buildFieldsList(List, List, MessageColumnIO)}. Required (non-null) for nested + * types; ignored for primitives. + */ + public static ColumnReader createColumnReader( + boolean utcTimestamp, + LogicalType fieldType, + Type physicalType, + List descriptors, + PageReadStore pages, + @Nullable ParquetField field) throws IOException { + switch (fieldType.getTypeRoot()) { + case ARRAY: + case MAP: + case MULTISET: + case ROW: + Preconditions.checkNotNull( + field, "ParquetField must be provided for nested type: %s", fieldType); + return new NestedColumnReader(utcTimestamp, pages, field); + default: + return createPrimitiveColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages); + } + } + + /** + * Backward-compat entry point kept for callers that don't project nested types and therefore + * never need a {@link ParquetField} tree. Forwards to the {@link ParquetField}-aware overload + * with a null field; nested types now go through that overload directly. + * + * @deprecated use {@link #createColumnReader(boolean, LogicalType, Type, List, PageReadStore, + * ParquetField)} so nested types take the Dremel path. + */ + @Deprecated public static ColumnReader createColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List descriptors, PageReadStore pages) throws IOException { - return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, - pages, 0); + return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages, null); } - private static ColumnReader createColumnReader( + private static ColumnReader createPrimitiveColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List columns, - PageReadStore pages, - int depth) throws IOException { - List descriptors = filterDescriptors(depth, physicalType, columns); + PageReadStore pages) throws IOException { + List descriptors = filterDescriptors(0, physicalType, columns); ColumnDescriptor descriptor = descriptors.get(0); PageReader pageReader = pages.getPageReader(descriptor); switch (fieldType.getTypeRoot()) { @@ -392,7 +448,9 @@ private static ColumnReader createColumnReader( case INT96: return new TimestampColumnReader(utcTimestamp, descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for TIMESTAMP: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } case DECIMAL: switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { @@ -403,106 +461,23 @@ private static ColumnReader createColumnReader( case BINARY: return new BytesColumnReader(descriptor, pageReader); case FIXED_LEN_BYTE_ARRAY: - return new FixedLenBytesColumnReader( - descriptor, pageReader); + return new FixedLenBytesColumnReader(descriptor, pageReader); default: - throw new AssertionError(); - } - case ARRAY: - ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new ArrayGroupReader(createColumnReader( - utcTimestamp, - arrayType.getElementType(), - elementType, - descriptors, - pages, - elementDepth)); - } else { - return new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - fieldType); - } - case MAP: - MapType mapType = (MapType) fieldType; - ArrayColumnReader keyReader = - new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - new ArrayType(mapType.getKeyType())); - ColumnReader valueReader; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueReader = new ArrayGroupReader(createColumnReader( - utcTimestamp, - mapType.getValueType(), - physicalType.asGroupType().getType(0).asGroupType().getType(1), // Get the value physical type - descriptors.subList(1, descriptors.size()), // remove the key descriptor - pages, - depth + 2)); // increase the depth by 2, because there's a key_value entry in the path - } else { - valueReader = new ArrayColumnReader( - descriptors.get(1), - pages.getPageReader(descriptors.get(1)), - utcTimestamp, - descriptors.get(1).getPrimitiveType(), - new ArrayType(mapType.getValueType())); + throw new AssertionError( + "Unexpected physical type for DECIMAL: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } - return new MapColumnReader(keyReader, valueReader); - case ROW: - RowType rowType = (RowType) fieldType; - GroupType groupType = physicalType.asGroupType(); - List fieldReaders = new ArrayList<>(); - for (int i = 0; i < rowType.getFieldCount(); i++) { - // schema evolution: read the parquet file with a new extended field name. - int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); - if (fieldIndex < 0) { - fieldReaders.add(new EmptyColumnReader()); - } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - fieldReaders.add( - createColumnReader( - utcTimestamp, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } else { - fieldReaders.add( - createColumnReader( - utcTimestamp, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } - } - } - return new RowColumnReader(fieldReaders); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + /** + * Creates the writable column vector that the reader will write into. The returned vector shape + * matches {@code fieldType}; for ROW types missing physical fields are slotted with null-filled + * vectors (sourced from {@link #createVectorFromConstant}) so that the Dremel assembler in + * {@link NestedColumnReader} can pass them through unchanged. + */ public static WritableColumnVector createWritableColumnVector( int batchSize, LogicalType fieldType, @@ -523,40 +498,48 @@ private static WritableColumnVector createWritableColumnVector( switch (fieldType.getTypeRoot()) { case BOOLEAN: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBooleanVector(batchSize); case TINYINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapByteVector(batchSize); case DOUBLE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDoubleVector(batchSize); case FLOAT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.FLOAT, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.FLOAT, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapFloatVector(batchSize); case INTEGER: case DATE: case TIME_WITHOUT_TIME_ZONE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapIntVector(batchSize); case BIGINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT64, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT64, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapLongVector(batchSize); case SMALLINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapShortVector(batchSize); case CHAR: case VARCHAR: case BINARY: case VARBINARY: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BINARY, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BINARY, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBytesVector(batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: case TIMESTAMP_WITH_LOCAL_TIME_ZONE: @@ -566,112 +549,64 @@ private static WritableColumnVector createWritableColumnVector( case DECIMAL: checkArgument( (typeName == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY - || typeName == PrimitiveType.PrimitiveTypeName.BINARY) + || typeName == PrimitiveType.PrimitiveTypeName.BINARY) && primitiveType.getOriginalType() == OriginalType.DECIMAL, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDecimalVector(batchSize); case ARRAY: ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - elementType, - descriptors, - elementDepth)); - } else { - return new HeapArrayVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - physicalType, - descriptors, - depth)); - } - case MAP: + return new HeapArrayVector( + batchSize, + createWritableColumnVector( + batchSize, arrayType.getElementType(), physicalType, descriptors, depth)); + case MAP: { MapType mapType = (MapType) fieldType; - GroupType repeatedType = physicalType.asGroupType().getType(0).asGroupType(); - // the map column has three level paths. - WritableColumnVector keyColumnVector = createWritableColumnVector( + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( batchSize, - new ArrayType(mapType.getKeyType().isNullable(), mapType.getKeyType()), - repeatedType.getType(0), - descriptors, - depth + 2); - WritableColumnVector valueColumnVector; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueColumnVector = new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - mapType.getValueType(), - repeatedType.getType(1).asGroupType(), - descriptors, - depth + 2)); - } else { - valueColumnVector = createWritableColumnVector( - batchSize, - new ArrayType(mapType.getValueType().isNullable(), mapType.getValueType()), - repeatedType.getType(1), - descriptors, - depth + 2); - } - return new HeapMapColumnVector(batchSize, keyColumnVector, valueColumnVector); + createWritableColumnVector( + batchSize, mapType.getKeyType(), repeatedType.getType(0), descriptors, depth + 2), + createWritableColumnVector( + batchSize, mapType.getValueType(), repeatedType.getType(1), descriptors, depth + 2)); + } + case MULTISET: { + MultisetType multisetType = (MultisetType) fieldType; + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( + batchSize, + createWritableColumnVector( + batchSize, + multisetType.getElementType(), + repeatedType.getType(0), + descriptors, + depth + 2), + createWritableColumnVector( + batchSize, + new IntType(false), + repeatedType.getType(1), + descriptors, + depth + 2)); + } case ROW: RowType rowType = (RowType) fieldType; GroupType groupType = physicalType.asGroupType(); WritableColumnVector[] columnVectors = new WritableColumnVector[rowType.getFieldCount()]; for (int i = 0; i < columnVectors.length; i++) { - // schema evolution: read the file with a new extended field name. int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); if (fieldIndex < 0) { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (groupType.getRepetition().equals(Type.Repetition.REPEATED) && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant( - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), null, batchSize); - } else { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); - } + // Schema evolution: logical field is absent from the Parquet file. Slot a null-filled + // vector of the correct shape; NestedColumnReader.readRow will pass it through when the + // matching ParquetField child is null. + columnVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = - createWritableColumnVector( - batchSize, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } else { - columnVectors[i] = - createWritableColumnVector( - batchSize, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } + columnVectors[i] = + createWritableColumnVector( + batchSize, + rowType.getTypeAt(i), + groupType.getType(fieldIndex), + descriptors, + depth + 1); } } return new HeapRowColumnVector(batchSize, columnVectors); @@ -681,56 +616,245 @@ private static WritableColumnVector createWritableColumnVector( } /** - * Returns the field index with given physical row type {@code groupType} and field name {@code fieldName}. - * - * @return The physical field index or -1 if the field does not exist + * Peels one {@code repeated group key_value} wrapper off a MAP / MULTISET physical type, matching + * Parquet's canonical 3-level map encoding. */ - private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { - // get index from fileSchema type, else, return -1 - return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + private static GroupType unwrapMapRepeatedType(Type physicalType) { + return physicalType.asGroupType().getType(0).asGroupType(); } + // ------------------------------------------------------------------------------------------ + // ParquetField tree construction (vendored from Apache Flink 2.1 ParquetSplitReaderUtil) + // + // The only Hudi-specific divergence is in `constructField`: the ROW branch tolerates children + // missing from the Parquet file by emitting a null ParquetField child (upstream throws). This + // matches the Hudi schema-evolution contract and is the companion to the null-child branch in + // `NestedColumnReader#readRow` and the null-vector slot in `createWritableColumnVector#ROW`. + // ------------------------------------------------------------------------------------------ + /** - * Check whether the given list type is a three-level list type. - *

    - * group (LIST) { - * repeated group list { - * element; - * } - * } - * - * @param type list type - * @return true if the list type is a three-level list type + * Builds {@link ParquetField} trees — one per top-level projected logical column — that feed + * {@link NestedColumnReader}. The returned list mirrors the input {@code children} positionally; + * primitive top-level fields produce {@code null} entries (callers don't need a tree for those). + */ + public static List buildFieldsList( + List children, List fieldNames, MessageColumnIO columnIO) { + List list = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + RowType.RowField child = children.get(i); + if (isNestedType(child.getType())) { + list.add(constructField(child, lookupColumnByName(columnIO, fieldNames.get(i)))); + } else { + list.add(null); + } + } + return list; + } + + private static boolean isNestedType(LogicalType type) { + return type instanceof RowType + || type instanceof ArrayType + || type instanceof MapType + || type instanceof MultisetType; + } + + @Nullable + private static ParquetField constructField(RowType.RowField rowField, ColumnIO columnIO) { + boolean required = columnIO.getType().getRepetition() == REQUIRED; + int repetitionLevel = columnIO.getRepetitionLevel(); + int definitionLevel = columnIO.getDefinitionLevel(); + LogicalType type = rowField.getType(); + String fieldName = rowField.getName(); + if (type instanceof RowType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + RowType rowType = (RowType) type; + List childFields = rowType.getFields(); + List fieldsList = new ArrayList<>(childFields.size()); + for (RowType.RowField childField : childFields) { + // Hudi schema evolution: a logical child may be absent from the Parquet file. In that + // case we emit a null ParquetField so that NestedColumnReader.readRow passes through the + // pre-filled null vector instead of recursing. + ColumnIO childIo = lookupColumnByNameOrNull(groupColumnIO, childField.getName()); + if (childIo == null) { + fieldsList.add(null); + } else { + fieldsList.add(constructField(childField, childIo)); + } + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(fieldsList)); + } + + if (type instanceof MapType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MapType mapType = (MapType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", mapType.getKeyType()), keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", mapType.getValueType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof MultisetType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MultisetType multisetType = (MultisetType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", multisetType.getElementType()), + keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", new IntType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof ArrayType) { + ArrayType arrayType = (ArrayType) type; + ColumnIO elementTypeColumnIO; + if (columnIO instanceof GroupColumnIO) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) { + while (!Objects.equals(groupColumnIO.getName(), fieldName)) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + elementTypeColumnIO = groupColumnIO; + } else { + if (arrayType.getElementType() instanceof RowType) { + elementTypeColumnIO = groupColumnIO; + } else { + elementTypeColumnIO = groupColumnIO.getChild(0); + } + } + } else if (columnIO instanceof PrimitiveColumnIO) { + elementTypeColumnIO = columnIO; + } else { + throw new FlinkRuntimeException(String.format("Unknown ColumnIO, %s", columnIO)); + } + + ParquetField elementField = + constructField( + new RowType.RowField("", arrayType.getElementType()), + getArrayElementColumn(elementTypeColumnIO)); + if (repetitionLevel == elementField.getRepetitionLevel()) { + repetitionLevel = columnIO.getParent().getRepetitionLevel(); + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.singletonList(elementField)); + } + + PrimitiveColumnIO primitiveColumnIO = (PrimitiveColumnIO) columnIO; + return new ParquetPrimitiveField( + type, required, primitiveColumnIO.getColumnDescriptor(), primitiveColumnIO.getId()); + } + + /** + * Parquet column names are case-insensitive in Flink's lookup. Matches upstream + * {@code ParquetSplitReaderUtil.lookupColumnByName}; throws when absent. */ - private static boolean isThreeLevelList(Type type) { - if (type.isPrimitive()) { - return false; + public static ColumnIO lookupColumnByName(GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = lookupColumnByNameOrNull(groupColumnIO, columnName); + if (columnIO != null) { + return columnIO; } - GroupType groupType = type.asGroupType(); - OriginalType originalType = groupType.getOriginalType(); - return originalType == OriginalType.LIST - && groupType.getType(0).getName().equals("list"); + throw new FlinkRuntimeException( + "Can not find column io for parquet reader. Column name: " + columnName); } /** - * Construct the error message when primitive type mismatches. - * - * @param primitiveType Primitive type - * @param fieldType Logical field type - * @return The error message + * Case-insensitive column lookup that returns {@code null} when no match is found — the + * Hudi-specific companion to {@link #lookupColumnByName}, used by {@link #constructField} to + * emit null {@link ParquetField} children for fields absent from the Parquet file. */ - private static String getPrimitiveTypeCheckFailureMessage(PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { - return String.format("Unexpected type exception. Primitive type: %s. Field type: %s.", primitiveType, fieldType.getTypeRoot().name()); + @Nullable + private static ColumnIO lookupColumnByNameOrNull( + GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = groupColumnIO.getChild(columnName); + if (columnIO != null) { + return columnIO; + } + for (int i = 0; i < groupColumnIO.getChildrenCount(); i++) { + if (groupColumnIO.getChild(i).getName().equalsIgnoreCase(columnName)) { + return groupColumnIO.getChild(i); + } + } + return null; + } + + public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO groupColumnIO) { + while (groupColumnIO.getChildrenCount() == 1) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + return groupColumnIO; + } + + public static ColumnIO getArrayElementColumn(ColumnIO columnIO) { + while (columnIO instanceof GroupColumnIO && !columnIO.getType().isRepetition(REPEATED)) { + columnIO = ((GroupColumnIO) columnIO).getChild(0); + } + + // Three-level list: skip the synthetic `element` / `list` wrapper when present. + if (columnIO instanceof GroupColumnIO + && columnIO.getType().getLogicalTypeAnnotation() == null + && ((GroupColumnIO) columnIO).getChildrenCount() == 1 + && !columnIO.getName().equals("array") + && !columnIO.getName().equals(columnIO.getParent().getName() + "_tuple")) { + return ((GroupColumnIO) columnIO).getChild(0); + } + return columnIO; } /** - * Construct the error message when original type mismatches. + * Returns the field index with given physical row type {@code groupType} and field name + * {@code fieldName}. * - * @param originalType Original type - * @param fieldType Logical field type - * @return The error message + * @return the physical field index or -1 if the field does not exist + */ + private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { + return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + } + + private static String getPrimitiveTypeCheckFailureMessage( + PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Primitive type: %s. Field type: %s.", + primitiveType, fieldType.getTypeRoot().name()); + } + + private static String getOriginalTypeCheckFailureMessage( + OriginalType originalType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Original type: %s. Field type: %s.", + originalType, fieldType.getTypeRoot().name()); + } + + /** + * Returns a synthetic null-column reader to fill missing top-level fields. Kept as a convenience + * for callers that need to mirror Hudi's original behaviour where a missing column produces an + * explicit null-valued reader rather than being omitted from the batch. */ - private static String getOriginalTypeCheckFailureMessage(OriginalType originalType, LogicalType fieldType) { - return String.format("Unexpected type exception. Original type: %s. Field type: %s.", originalType, fieldType.getTypeRoot().name()); + public static ColumnReader emptyColumnReader() { + return new EmptyColumnReader(); } } diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java new file mode 100644 index 0000000000000..d51d7ee754b8a --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of booleans. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.runtime.util.BooleanArrayList}) because Flink 1.18 does not ship this helper. + */ +public class BooleanArrayList { + private int size; + private boolean[] array; + + public BooleanArrayList(int capacity) { + this.size = 0; + this.array = new boolean[capacity]; + } + + public int size() { + return size; + } + + public boolean add(boolean element) { + grow(size + 1); + array[size++] = element; + return true; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public boolean[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final boolean[] t = new boolean[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java new file mode 100644 index 0000000000000..4787dbb5b9ddb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; +import java.util.NoSuchElementException; + +/** + * Minimal implementation of an array-backed list of ints. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.IntArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class IntArrayList { + + private int size; + private int[] array; + + public IntArrayList(final int capacity) { + this.size = 0; + this.array = new int[capacity]; + } + + public int size() { + return size; + } + + public boolean add(final int number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public int removeLast() { + if (size == 0) { + throw new NoSuchElementException(); + } + --size; + return array[size]; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return size == 0; + } + + private void grow(final int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final int[] t = new int[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } + + public int[] toArray() { + return Arrays.copyOf(array, size); + } + + public static final IntArrayList EMPTY = + new IntArrayList(0) { + + @Override + public boolean add(int number) { + throw new UnsupportedOperationException(); + } + + @Override + public int removeLast() { + throw new UnsupportedOperationException(); + } + }; +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java new file mode 100644 index 0000000000000..a51291f9d8441 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of longs. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.LongArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class LongArrayList { + + private int size; + private long[] array; + + public LongArrayList(int capacity) { + this.size = 0; + this.array = new long[capacity]; + } + + public int size() { + return size; + } + + public boolean add(long number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public long removeLong(int index) { + if (index >= size) { + throw new IndexOutOfBoundsException( + "Index (" + index + ") is greater than or equal to list size (" + size + ")"); + } + final long old = array[index]; + size--; + if (index != size) { + System.arraycopy(array, index + 1, array, index, size - index); + } + return old; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public long[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final long[] t = new long[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java new file mode 100644 index 0000000000000..3f2f8976b69bf --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.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.hudi.table.format.cow.utils; + +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; + +import static java.lang.String.format; + +/** + * Utils to calculate nested type position. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.utils.NestedPositionUtil}). + */ +public class NestedPositionUtil { + + /** + * Calculate row offsets according to column's max repetition level, definition level, value's + * repetition level and definition level. Each row has three situation: + *

  • Row is not defined,because it's optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Row is null + *
  • Row is defined and not empty. + * + * @param field field that contains the row column message include max repetition level and + * definition level. + * @param fieldRepetitionLevels int array with each value's repetition level. + * @param fieldDefinitionLevels int array with each value's definition level. + * @return {@link RowPosition} contains collections row count and isNull array. + */ + public static RowPosition calculateRowOffsets( + ParquetField field, int[] fieldDefinitionLevels, int[] fieldRepetitionLevels) { + int rowDefinitionLevel = field.getDefinitionLevel(); + int rowRepetitionLevel = field.getRepetitionLevel(); + int nullValuesCount = 0; + BooleanArrayList nullRowFlags = new BooleanArrayList(0); + for (int i = 0; i < fieldDefinitionLevels.length; i++) { + // If a row's last field is an array, the repetition levels for the array's items will + // be larger than the parent row's repetition level, so we need to skip those values. + if (fieldRepetitionLevels[i] > rowRepetitionLevel) { + continue; + } + + if (fieldDefinitionLevels[i] >= rowDefinitionLevel) { + // current row is defined and not empty + nullRowFlags.add(false); + } else { + // current row is null + nullRowFlags.add(true); + nullValuesCount++; + } + } + if (nullValuesCount == 0) { + return new RowPosition(null, fieldDefinitionLevels.length); + } + return new RowPosition(nullRowFlags.toArray(), nullRowFlags.size()); + } + + /** + * Calculate the collection's offsets according to column's max repetition level, definition + * level, value's repetition level and definition level. Each collection (Array or Map) has four + * situation: + *
  • Collection is not defined, because optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Collection is null + *
  • Collection is defined but empty + *
  • Collection is defined and not empty. In this case offset value is increased by the number + * of elements in that collection + * + * @param field field that contains array/map column message include max repetition level and + * definition level. + * @param definitionLevels int array with each value's definition level. + * @param repetitionLevels int array with each value's repetition level. + * @return {@link CollectionPosition} contains collections offset array, length array and isNull + * array. + */ + public static CollectionPosition calculateCollectionOffsets( + ParquetField field, int[] definitionLevels, int[] repetitionLevels) { + int collectionDefinitionLevel = field.getDefinitionLevel(); + int collectionRepetitionLevel = field.getRepetitionLevel() + 1; + int offset = 0; + int valueCount = 0; + LongArrayList offsets = new LongArrayList(0); + offsets.add(offset); + BooleanArrayList emptyCollectionFlags = new BooleanArrayList(0); + BooleanArrayList nullCollectionFlags = new BooleanArrayList(0); + int nullValuesCount = 0; + for (int i = 0; + i < definitionLevels.length; + i = getNextCollectionStartIndex(repetitionLevels, collectionRepetitionLevel, i)) { + valueCount++; + if (definitionLevels[i] >= collectionDefinitionLevel - 1) { + boolean isNull = + isOptionalFieldValueNull(definitionLevels[i], collectionDefinitionLevel); + nullCollectionFlags.add(isNull); + nullValuesCount += isNull ? 1 : 0; + // definitionLevels[i] > collectionDefinitionLevel => Collection is defined and not + // empty + // definitionLevels[i] == collectionDefinitionLevel => Collection is defined but + // empty + if (definitionLevels[i] > collectionDefinitionLevel) { + emptyCollectionFlags.add(false); + offset += getCollectionSize(repetitionLevels, collectionRepetitionLevel, i + 1); + } else if (definitionLevels[i] == collectionDefinitionLevel) { + offset++; + emptyCollectionFlags.add(true); + } else { + offset++; + emptyCollectionFlags.add(false); + } + offsets.add(offset); + } else { + // when definitionLevels[i] < collectionDefinitionLevel - 1, it means the collection + // is + // not defined, but we need to regard it as null to avoid getting value wrong. + nullCollectionFlags.add(true); + nullValuesCount++; + offsets.add(++offset); + emptyCollectionFlags.add(false); + } + } + long[] offsetsArray = offsets.toArray(); + long[] length = calculateLengthByOffsets(emptyCollectionFlags.toArray(), offsetsArray); + if (nullValuesCount == 0) { + return new CollectionPosition(null, offsetsArray, length, valueCount); + } + return new CollectionPosition( + nullCollectionFlags.toArray(), offsetsArray, length, valueCount); + } + + public static boolean isOptionalFieldValueNull(int definitionLevel, int maxDefinitionLevel) { + return definitionLevel == maxDefinitionLevel - 1; + } + + public static long[] calculateLengthByOffsets( + boolean[] collectionIsEmpty, long[] arrayOffsets) { + LongArrayList lengthList = new LongArrayList(arrayOffsets.length); + for (int i = 0; i < arrayOffsets.length - 1; i++) { + long offset = arrayOffsets[i]; + long length = arrayOffsets[i + 1] - offset; + if (length < 0) { + throw new IllegalArgumentException( + format( + "Offset is not monotonically ascending. offsets[%s]=%s, offsets[%s]=%s", + i, arrayOffsets[i], i + 1, arrayOffsets[i + 1])); + } + if (collectionIsEmpty[i]) { + length = 0; + } + lengthList.add(length); + } + return lengthList.toArray(); + } + + private static int getNextCollectionStartIndex( + int[] repetitionLevels, int maxRepetitionLevel, int elementIndex) { + do { + elementIndex++; + } while (hasMoreElements(repetitionLevels, elementIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, elementIndex)); + return elementIndex; + } + + /** This method is only called for non-empty collections. */ + private static int getCollectionSize( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + int size = 1; + while (hasMoreElements(repetitionLevels, nextIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, nextIndex)) { + // Collection elements cannot only be primitive, but also can have nested structure + // Counting only elements which belong to current collection, skipping inner elements of + // nested collections/structs + if (repetitionLevels[nextIndex] <= maxRepetitionLevel) { + size++; + } + nextIndex++; + } + return size; + } + + private static boolean isNotCollectionBeginningMarker( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + return repetitionLevels[nextIndex] >= maxRepetitionLevel; + } + + private static boolean hasMoreElements(int[] repetitionLevels, int nextIndex) { + return nextIndex < repetitionLevels.length; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java deleted file mode 100644 index 4c9275f3b0932..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java +++ /dev/null @@ -1,179 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupArrayData implements ArrayData { - - WritableColumnVector vector; - int rowId; - - public ColumnarGroupArrayData(WritableColumnVector vector, int rowId) { - this.vector = vector; - this.rowId = rowId; - } - - @Override - public int size() { - if (vector == null) { - return 0; - } - - if (vector instanceof HeapRowColumnVector) { - // assume all fields have the same size - if (((HeapRowColumnVector) vector).vectors == null || ((HeapRowColumnVector) vector).vectors.length == 0) { - return 0; - } - return ((HeapArrayVector) ((HeapRowColumnVector) vector).vectors[0]).getArray(rowId).size(); - } - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean isNullAt(int index) { - if (vector == null) { - return true; - } - - if (vector instanceof HeapRowColumnVector) { - return ((HeapRowColumnVector) vector).vectors == null; - } - - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean getBoolean(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte getByte(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short getShort(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int getInt(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long getLong(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float getFloat(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double getDouble(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public StringData getString(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public DecimalData getDecimal(int index, int precision, int scale) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public TimestampData getTimestamp(int index, int precision) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RawValueData getRawValue(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] getBinary(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public ArrayData getArray(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public MapData getMap(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RowData getRow(int index, int numFields) { - return new ColumnarGroupRowData((HeapRowColumnVector) vector, rowId, index); - } - - @Override - public boolean[] toBooleanArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] toByteArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short[] toShortArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int[] toIntArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long[] toLongArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float[] toFloatArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double[] toDoubleArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java deleted file mode 100644 index 69cb6feca13e4..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.MapData; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupMapData implements MapData { - - WritableColumnVector keyVector; - WritableColumnVector valueVector; - int rowId; - - public ColumnarGroupMapData(WritableColumnVector keyVector, WritableColumnVector valueVector, int rowId) { - this.keyVector = keyVector; - this.valueVector = valueVector; - this.rowId = rowId; - } - - @Override - public int size() { - if (keyVector == null) { - return 0; - } - - if (keyVector instanceof HeapArrayVector) { - return ((HeapArrayVector) keyVector).getArray(rowId).size(); - } - throw new UnsupportedOperationException(keyVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector"); - } - - @Override - public ArrayData keyArray() { - return ((HeapArrayVector) keyVector).getArray(rowId); - } - - @Override - public ArrayData valueArray() { - if (valueVector instanceof HeapArrayVector) { - return ((HeapArrayVector) valueVector).getArray(rowId); - } else if (valueVector instanceof HeapArrayGroupColumnVector) { - return ((HeapArrayGroupColumnVector) valueVector).getArray(rowId); - } - throw new UnsupportedOperationException(valueVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector, HeapArrayGroupColumnVector"); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java deleted file mode 100644 index 439c1880823f1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.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.hudi.table.format.cow.vector; - -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.RawValueData; -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.RowKind; - -public class ColumnarGroupRowData implements RowData { - - HeapRowColumnVector vector; - int rowId; - int index; - - public ColumnarGroupRowData(HeapRowColumnVector vector, int rowId, int index) { - this.vector = vector; - this.rowId = rowId; - this.index = index; - } - - @Override - public int getArity() { - return vector.vectors.length; - } - - @Override - public RowKind getRowKind() { - return RowKind.INSERT; - } - - @Override - public void setRowKind(RowKind rowKind) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public boolean isNullAt(int pos) { - return - vector.vectors[pos].isNullAt(rowId) - || ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).isNullAt(index); - } - - @Override - public boolean getBoolean(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBoolean(index); - } - - @Override - public byte getByte(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getByte(index); - } - - @Override - public short getShort(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getShort(index); - } - - @Override - public int getInt(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getInt(index); - } - - @Override - public long getLong(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getLong(index); - } - - @Override - public float getFloat(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getFloat(index); - } - - @Override - public double getDouble(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDouble(index); - } - - @Override - public StringData getString(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getString(index); - } - - @Override - public DecimalData getDecimal(int pos, int i1, int i2) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDecimal(index, i1, i2); - } - - @Override - public TimestampData getTimestamp(int pos, int i1) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getTimestamp(index, i1); - } - - @Override - public RawValueData getRawValue(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRawValue(index); - } - - @Override - public byte[] getBinary(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBinary(index); - } - - @Override - public ArrayData getArray(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getArray(index); - } - - @Override - public MapData getMap(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getMap(index); - } - - @Override - public RowData getRow(int pos, int numFields) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRow(index, numFields); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java deleted file mode 100644 index 3d7d8b1f0de0f..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java +++ /dev/null @@ -1,53 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.columnar.vector.ArrayColumnVector; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -/** - * This class represents a nullable heap row column vector. - */ -public class HeapArrayGroupColumnVector extends AbstractHeapVector - implements WritableColumnVector, ArrayColumnVector { - - public WritableColumnVector vector; - - public HeapArrayGroupColumnVector(int len) { - super(len); - } - - public HeapArrayGroupColumnVector(int len, WritableColumnVector vector) { - super(len); - this.vector = vector; - } - - @Override - public ArrayData getArray(int rowId) { - return new ColumnarGroupArrayData(vector, rowId); - } - - @Override - public void reset() { - super.reset(); - vector.reset(); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java index a0dced01e5e8d..2f21a323302f1 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java @@ -57,6 +57,37 @@ public int getLen() { return this.isNull.length; } + // --------------------------------------------------------------------------------------------- + // Flink 2.1-compatible accessors. Backed by the existing public {@code offsets}, {@code lengths} + // and {@code child} fields so legacy callers continue to work; the new {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + // future Flink-2.1-style caller use these accessors. + // --------------------------------------------------------------------------------------------- + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public ColumnVector getChild() { + return child; + } + + public void setChild(ColumnVector child) { + this.child = child; + } + @Override public ArrayData getArray(int i) { long offset = offsets[i]; diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java index 0f088df55c1ac..14aad22039e0a 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java @@ -20,30 +20,97 @@ import lombok.Getter; import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.columnar.ColumnarMapData; +import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.MapColumnVector; import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; /** * This class represents a nullable heap map column vector. + * + *

    Mirrors {@code org.apache.flink.table.data.columnar.vector.heap.HeapMapVector} from + * Flink 2.1 (FLINK-35702). One deliberate divergence from upstream is preserved for backward + * compatibility: the {@code keys} / {@code values} fields are typed + * {@link WritableColumnVector} rather than upstream's {@link ColumnVector}, so the existing + * Lombok-generated {@code getKeys()} / {@code getValues()} accessors keep their original + * signature. Callers wanting the Flink-2.1 contract (a {@code ColumnVector}) use + * {@link #getKeyColumnVector()} / {@link #getValueColumnVector()}. */ public class HeapMapColumnVector extends AbstractHeapVector implements WritableColumnVector, MapColumnVector { @Getter - private final WritableColumnVector keys; + private WritableColumnVector keys; @Getter - private final WritableColumnVector values; + private WritableColumnVector values; + + // --------------------------------------------------------------------------------------------- + // Flink 2.1 Dremel-style state. Populated by {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and + // consumed by {@link #getMap(int)}. + // --------------------------------------------------------------------------------------------- + private long[] offsets; + private long[] lengths; + private int size; public HeapMapColumnVector(int len, WritableColumnVector keys, WritableColumnVector values) { super(len); + this.offsets = new long[len]; + this.lengths = new long[len]; this.keys = keys; this.values = values; } + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public void setKeys(WritableColumnVector keys) { + this.keys = keys; + } + + public void setValues(WritableColumnVector values) { + this.values = values; + } + + /** + * Returns the keys child vector typed as {@link ColumnVector}, matching the Flink 2.1 contract + * consumed by {@code NestedColumnReader}. Functionally equivalent to {@link #getKeys()}. + */ + public ColumnVector getKeyColumnVector() { + return keys; + } + + /** Counterpart of {@link #getKeyColumnVector()} for the values child vector. */ + public ColumnVector getValueColumnVector() { + return values; + } + @Override public MapData getMap(int rowId) { - return new ColumnarGroupMapData(keys, values, rowId); + long offset = offsets[rowId]; + long length = lengths[rowId]; + return new ColumnarMapData(keys, values, (int) offset, (int) length); } } - diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java index ae194e4e6ab05..0c640ce92ee40 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java @@ -37,6 +37,21 @@ public HeapRowColumnVector(int len, WritableColumnVector... vectors) { this.vectors = vectors; } + /** + * Flink 2.1-compatible accessor for the children vectors. Backed by the existing public {@code + * vectors} field so legacy callers continue to work; the new {@link + * org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + * future Flink-2.1-style caller use this accessor. + */ + public WritableColumnVector[] getFields() { + return vectors; + } + + /** Counterpart of {@link #getFields()}. */ + public void setFields(WritableColumnVector[] fields) { + this.vectors = fields; + } + @Override public ColumnarRowData getRow(int i) { ColumnarRowData columnarRowData = new ColumnarRowData(new VectorizedColumnBatch(vectors)); diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java index 98b5e61050898..a37b88352cf52 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java @@ -18,21 +18,29 @@ package org.apache.hudi.table.format.cow.vector; +import org.apache.flink.formats.parquet.utils.ParquetSchemaConverter; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.columnar.vector.BytesColumnVector; import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.DecimalColumnVector; +import org.apache.flink.table.data.columnar.vector.Dictionary; +import org.apache.flink.table.data.columnar.vector.IntColumnVector; +import org.apache.flink.table.data.columnar.vector.LongColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableBytesVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableIntVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableLongVector; + +import static org.apache.flink.util.Preconditions.checkArgument; /** - * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to - * provide {@link DecimalColumnVector} interface. - * - *

    Reference Flink release 1.11.2 {@link org.apache.flink.formats.parquet.vector.ParquetDecimalVector} - * because it is not public. + * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to provide + * {@link DecimalColumnVector} interface. */ -public class ParquetDecimalVector implements DecimalColumnVector { +public class ParquetDecimalVector + implements DecimalColumnVector, WritableLongVector, WritableIntVector, WritableBytesVector { - public final ColumnVector vector; + private final ColumnVector vector; public ParquetDecimalVector(ColumnVector vector) { this.vector = vector; @@ -40,15 +48,180 @@ public ParquetDecimalVector(ColumnVector vector) { @Override public DecimalData getDecimal(int i, int precision, int scale) { - return DecimalData.fromUnscaledBytes( - ((BytesColumnVector) vector).getBytes(i).getBytes(), - precision, - scale); + if (ParquetSchemaConverter.is32BitDecimal(precision) && vector instanceof IntColumnVector) { + return DecimalData.fromUnscaledLong(((IntColumnVector) vector).getInt(i), precision, scale); + } else if (ParquetSchemaConverter.is64BitDecimal(precision) + && vector instanceof LongColumnVector) { + return DecimalData.fromUnscaledLong(((LongColumnVector) vector).getLong(i), precision, scale); + } else { + checkArgument( + vector instanceof BytesColumnVector, + "Reading decimal type occur unsupported vector type: %s", + vector.getClass()); + return DecimalData.fromUnscaledBytes( + ((BytesColumnVector) vector).getBytes(i).getBytes(), precision, scale); + } + } + + public ColumnVector getVector() { + return vector; } @Override public boolean isNullAt(int i) { return vector.isNullAt(i); } -} + @Override + public void reset() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).reset(); + } + } + + @Override + public void setNullAt(int rowId) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNullAt(rowId); + } + } + + @Override + public void setNulls(int rowId, int count) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNulls(rowId, count); + } + } + + @Override + public void fillWithNulls() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).fillWithNulls(); + } + } + + @Override + public void setDictionary(Dictionary dictionary) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setDictionary(dictionary); + } + } + + @Override + public boolean hasDictionary() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).hasDictionary(); + } + return false; + } + + @Override + public WritableIntVector reserveDictionaryIds(int capacity) { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).reserveDictionaryIds(capacity); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public WritableIntVector getDictionaryIds() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).getDictionaryIds(); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public Bytes getBytes(int i) { + if (vector instanceof WritableBytesVector) { + return ((WritableBytesVector) vector).getBytes(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void appendBytes(int rowId, byte[] value, int offset, int length) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).appendBytes(rowId, value, offset, length); + } + } + + @Override + public void fill(byte[] value) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).fill(value); + } + } + + @Override + public int getInt(int i) { + if (vector instanceof WritableIntVector) { + return ((WritableIntVector) vector).getInt(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setInt(int rowId, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInt(rowId, value); + } + } + + @Override + public void setIntsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setIntsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void setInts(int rowId, int count, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, value); + } + } + + @Override + public void setInts(int rowId, int count, int[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).fill(value); + } + } + + @Override + public long getLong(int i) { + if (vector instanceof WritableLongVector) { + return ((WritableLongVector) vector).getLong(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setLong(int rowId, long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLong(rowId, value); + } + } + + @Override + public void setLongsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLongsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).fill(value); + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java new file mode 100644 index 0000000000000..fcdedfbc9d71d --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent collection's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.CollectionPosition}). + */ +public class CollectionPosition { + @Nullable private final boolean[] isNull; + private final long[] offsets; + private final long[] length; + private final int valueCount; + + public CollectionPosition(boolean[] isNull, long[] offsets, long[] length, int valueCount) { + this.isNull = isNull; + this.offsets = offsets; + this.length = length; + this.valueCount = valueCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public long[] getOffsets() { + return offsets; + } + + public long[] getLength() { + return length; + } + + public int getValueCount() { + return valueCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java new file mode 100644 index 0000000000000..fe95419ac3218 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.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.hudi.table.format.cow.vector.position; + +/** + * To delegate repetition level and definition level. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.LevelDelegation}). + */ +public class LevelDelegation { + private final int[] repetitionLevel; + private final int[] definitionLevel; + + public LevelDelegation(int[] repetitionLevel, int[] definitionLevel) { + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + } + + public int[] getRepetitionLevel() { + return repetitionLevel; + } + + public int[] getDefinitionLevel() { + return definitionLevel; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java new file mode 100644 index 0000000000000..5438b67973238 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent struct's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.RowPosition}). + */ +public class RowPosition { + @Nullable private final boolean[] isNull; + private final int positionsCount; + + public RowPosition(boolean[] isNull, int positionsCount) { + this.isNull = isNull; + this.positionsCount = positionsCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public int getPositionsCount() { + return positionsCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java deleted file mode 100644 index 6a8a01b74946a..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java +++ /dev/null @@ -1,473 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayVector; -import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.TimestampData; -import org.apache.flink.table.data.columnar.vector.VectorizedColumnBatch; -import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.table.types.logical.ArrayType; -import org.apache.flink.table.types.logical.LogicalType; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Array {@link ColumnReader}. - */ -public class ArrayColumnReader extends BaseVectorizedColumnReader { - - // The value read in last time - private Object lastValue; - - // flag to indicate if there is no data in parquet data page - private boolean eof = false; - - // flag to indicate if it's the first time to read parquet data page with this instance - boolean isFirstRow = true; - - public ArrayColumnReader( - ColumnDescriptor descriptor, - PageReader pageReader, - boolean isUtcTimestamp, - Type type, - LogicalType logicalType) - throws IOException { - super(descriptor, pageReader, isUtcTimestamp, type, logicalType); - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayVector lcv = (HeapArrayVector) vector; - // before readBatch, initial the size of offsets & lengths as the default value, - // the actual size will be assigned in setChildrenInfo() after reading complete. - lcv.offsets = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - lcv.lengths = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - // Because the length of ListColumnVector.child can't be known now, - // the valueList will save all data for ListColumnVector temporary. - List valueList = new ArrayList<>(); - - LogicalType category = ((ArrayType) logicalType).getElementType(); - - // read the first row in parquet data page, this will be only happened once for this - // instance - if (isFirstRow) { - if (!fetchNextValue(category)) { - return; - } - isFirstRow = false; - } - - int index = collectDataFromParquetPage(readNumber, lcv, valueList, category); - - // Convert valueList to array for the ListColumnVector.child - fillColumnVector(category, lcv, valueList, index); - } - - /** - * Reads a single value from parquet page, puts it into lastValue. Returns a boolean indicating - * if there is more values to read (true). - * - * @param category - * @return boolean - * @throws IOException - */ - private boolean fetchNextValue(LogicalType category) throws IOException { - int left = readPageIfNeed(); - if (left > 0) { - // get the values of repetition and definitionLevel - readRepetitionAndDefinitionLevels(); - // read the data if it isn't null - if (definitionLevel == maxDefLevel) { - if (isCurrentPageDictionaryEncoded) { - lastValue = dataColumn.readValueDictionaryId(); - } else { - lastValue = readPrimitiveTypedRow(category); - } - } else { - lastValue = null; - } - return true; - } else { - eof = true; - return false; - } - } - - private int readPageIfNeed() throws IOException { - // Compute the number of values we want to read in this page. - int leftInPage = (int) (endOfPageValueCount - valuesRead); - if (leftInPage == 0) { - // no data left in current page, load data from new page - readPage(); - leftInPage = (int) (endOfPageValueCount - valuesRead); - } - return leftInPage; - } - - // Need to be in consistent with that VectorizedPrimitiveColumnReader#readBatchHelper - // TODO Reduce the duplicated code - private Object readPrimitiveTypedRow(LogicalType category) { - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dataColumn.readString(); - case BOOLEAN: - return dataColumn.readBoolean(); - case TIME_WITHOUT_TIME_ZONE: - case DATE: - case INTEGER: - return dataColumn.readInteger(); - case TINYINT: - return dataColumn.readTinyInt(); - case SMALLINT: - return dataColumn.readSmallInt(); - case BIGINT: - return dataColumn.readLong(); - case FLOAT: - return dataColumn.readFloat(); - case DOUBLE: - return dataColumn.readDouble(); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dataColumn.readInteger(); - case INT64: - return dataColumn.readLong(); - case BINARY: - case FIXED_LEN_BYTE_ARRAY: - return dataColumn.readString(); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dataColumn.readTimestamp(); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { - if (dictionaryValue == null) { - return null; - } - - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dictionary.readString(dictionaryValue); - case DATE: - case TIME_WITHOUT_TIME_ZONE: - case INTEGER: - return dictionary.readInteger(dictionaryValue); - case BOOLEAN: - return dictionary.readBoolean(dictionaryValue) ? 1 : 0; - case DOUBLE: - return dictionary.readDouble(dictionaryValue); - case FLOAT: - return dictionary.readFloat(dictionaryValue); - case TINYINT: - return dictionary.readTinyInt(dictionaryValue); - case SMALLINT: - return dictionary.readSmallInt(dictionaryValue); - case BIGINT: - return dictionary.readLong(dictionaryValue); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dictionary.readInteger(dictionaryValue); - case INT64: - return dictionary.readLong(dictionaryValue); - case FIXED_LEN_BYTE_ARRAY: - case BINARY: - return dictionary.readString(dictionaryValue); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dictionary.readTimestamp(dictionaryValue); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - /** - * Collects data from a parquet page and returns the final row index where it stopped. The - * returned index can be equal to or less than total. - * - * @param total maximum number of rows to collect - * @param lcv column vector to do initial setup in data collection time - * @param valueList collection of values that will be fed into the vector later - * @param category - * @return int - * @throws IOException - */ - private int collectDataFromParquetPage( - int total, HeapArrayVector lcv, List valueList, LogicalType category) - throws IOException { - int index = 0; - /* - * Here is a nested loop for collecting all values from a parquet page. - * A column of array type can be considered as a list of lists, so the two loops are as below: - * 1. The outer loop iterates on rows (index is a row index, so points to a row in the batch), e.g.: - * [0, 2, 3] <- index: 0 - * [NULL, 3, 4] <- index: 1 - * - * 2. The inner loop iterates on values within a row (sets all data from parquet data page - * for an element in ListColumnVector), so fetchNextValue returns values one-by-one: - * 0, 2, 3, NULL, 3, 4 - * - * As described below, the repetition level (repetitionLevel != 0) - * can be used to decide when we'll start to read values for the next list. - */ - while (!eof && index < total) { - // add element to ListColumnVector one by one - lcv.offsets[index] = valueList.size(); - /* - * Let's collect all values for a single list. - * Repetition level = 0 means that a new list started there in the parquet page, - * in that case, let's exit from the loop, and start to collect value for a new list. - */ - do { - /* - * Definition level = 0 when a NULL value was returned instead of a list - * (this is not the same as a NULL value in of a list). - */ - if (definitionLevel == 0) { - lcv.setNullAt(index); - } - valueList.add( - isCurrentPageDictionaryEncoded - ? dictionaryDecodeValue(category, (Integer) lastValue) - : lastValue); - } while (fetchNextValue(category) && (repetitionLevel != 0)); - - lcv.lengths[index] = valueList.size() - lcv.offsets[index]; - index++; - } - return index; - } - - /** - * The lengths & offsets will be initialized as default size (1024), it should be set to the - * actual size according to the element number. - */ - private void setChildrenInfo(HeapArrayVector lcv, int itemNum, int elementNum) { - lcv.setSize(itemNum); - long[] lcvLength = new long[elementNum]; - long[] lcvOffset = new long[elementNum]; - System.arraycopy(lcv.lengths, 0, lcvLength, 0, elementNum); - System.arraycopy(lcv.offsets, 0, lcvOffset, 0, elementNum); - lcv.lengths = lcvLength; - lcv.offsets = lcvOffset; - } - - private void fillColumnVector( - LogicalType category, HeapArrayVector lcv, List valueList, int elementNum) { - int total = valueList.size(); - setChildrenInfo(lcv, total, elementNum); - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - lcv.child = new HeapBytesVector(total); - ((HeapBytesVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (src == null) { - ((HeapBytesVector) lcv.child).setNullAt(i); - } else { - ((HeapBytesVector) lcv.child).appendBytes(i, src, 0, src.length); - } - } - break; - case BOOLEAN: - lcv.child = new HeapBooleanVector(total); - ((HeapBooleanVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapBooleanVector) lcv.child).setNullAt(i); - } else { - ((HeapBooleanVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TINYINT: - lcv.child = new HeapByteVector(total); - ((HeapByteVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapByteVector) lcv.child).setNullAt(i); - } else { - ((HeapByteVector) lcv.child).vector[i] = - (byte) ((List) valueList).get(i).intValue(); - } - } - break; - case SMALLINT: - lcv.child = new HeapShortVector(total); - ((HeapShortVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapShortVector) lcv.child).setNullAt(i); - } else { - ((HeapShortVector) lcv.child).vector[i] = - (short) ((List) valueList).get(i).intValue(); - } - } - break; - case INTEGER: - case DATE: - case TIME_WITHOUT_TIME_ZONE: - lcv.child = new HeapIntVector(total); - ((HeapIntVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) lcv.child).setNullAt(i); - } else { - ((HeapIntVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case FLOAT: - lcv.child = new HeapFloatVector(total); - ((HeapFloatVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapFloatVector) lcv.child).setNullAt(i); - } else { - ((HeapFloatVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case BIGINT: - lcv.child = new HeapLongVector(total); - ((HeapLongVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) lcv.child).setNullAt(i); - } else { - ((HeapLongVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case DOUBLE: - lcv.child = new HeapDoubleVector(total); - ((HeapDoubleVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapDoubleVector) lcv.child).setNullAt(i); - } else { - ((HeapDoubleVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - lcv.child = new HeapTimestampVector(total); - ((HeapTimestampVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapTimestampVector) lcv.child).setNullAt(i); - } else { - ((HeapTimestampVector) lcv.child) - .setTimestamp(i, ((List) valueList).get(i)); - } - } - break; - case DECIMAL: - PrimitiveType.PrimitiveTypeName primitiveTypeName = - descriptor.getPrimitiveType().getPrimitiveTypeName(); - switch (primitiveTypeName) { - case INT32: - lcv.child = new ParquetDecimalVector(new HeapIntVector(total)); - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - case INT64: - lcv.child = new ParquetDecimalVector(new HeapLongVector(total)); - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - default: - lcv.child = new ParquetDecimalVector(new HeapBytesVector(total)); - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (valueList.get(i) == null) { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .appendBytes(i, src, 0, src.length); - } - } - break; - } - break; - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java deleted file mode 100644 index df7c5d85bc4ab..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java +++ /dev/null @@ -1,45 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Array of a Group type (Array, Map, Row, etc.) {@link ColumnReader}. - */ -public class ArrayGroupReader implements ColumnReader { - - private final ColumnReader fieldReader; - - public ArrayGroupReader(ColumnReader fieldReader) { - this.fieldReader = fieldReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayGroupColumnVector rowColumnVector = (HeapArrayGroupColumnVector) vector; - - fieldReader.readToVector(readNumber, rowColumnVector.vector); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java index 7c9fd994a0c25..700d7505fbc73 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java @@ -226,12 +226,7 @@ private void readPageV2(DataPageV2 page) { this.definitionLevelColumn = newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); try { - log.debug( - "page data size " - + page.getData().size() - + " bytes and " - + pageValueCount - + " records"); + log.debug("page data size {} bytes and {} records", page.getData().size(), pageValueCount); initDataReader( page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); } catch (IOException e) { diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java deleted file mode 100644 index ee65dd22c4369..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java +++ /dev/null @@ -1,56 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Map {@link ColumnReader}. - */ -public class MapColumnReader implements ColumnReader { - - private final ArrayColumnReader keyReader; - private final ColumnReader valueReader; - - public MapColumnReader( - ArrayColumnReader keyReader, ColumnReader valueReader) { - this.keyReader = keyReader; - this.valueReader = valueReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapMapColumnVector mapColumnVector = (HeapMapColumnVector) vector; - AbstractHeapVector keyArrayColumnVector = (AbstractHeapVector) (mapColumnVector.getKeys()); - keyReader.readToVector(readNumber, mapColumnVector.getKeys()); - valueReader.readToVector(readNumber, mapColumnVector.getValues()); - for (int i = 0; i < keyArrayColumnVector.getLen(); i++) { - if (keyArrayColumnVector.isNullAt(i)) { - mapColumnVector.setNullAt(i); - } - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java new file mode 100644 index 0000000000000..ac94292c3315f --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.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.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.NestedPositionUtil; +import org.apache.hudi.table.format.cow.vector.HeapArrayVector; +import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; +import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; + +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.ArrayType; +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.util.FlinkRuntimeException; +import org.apache.flink.util.Preconditions; + +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.page.PageReadStore; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ColumnReader used to read a {@code Group} type in Parquet ({@code Map}, {@code Array}, {@code + * Row}). Resolves nested structures using Dremel striping/assembly; see the + * striping and assembly algorithms from the Dremel paper. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedColumnReader}). Differences vs. upstream: + * + *

      + *
    • Uses Hudi-local {@code HeapRowColumnVector}/{@code HeapMapColumnVector}/{@code + * HeapArrayVector} instead of the Flink-private {@code HeapRowVector}/{@code + * HeapMapVector}/{@code HeapArrayVector}. + *
    • Supports Hudi's schema-evolution contract: a {@code ParquetGroupField} representing a + * {@link RowType} may contain {@code null} children — meaning the corresponding logical + * field is absent from the Parquet file. Those slots are passed through unchanged and do + * not contribute to the row's repetition/definition-level stream. + *
    + */ +public class NestedColumnReader implements ColumnReader { + + private final Map columnReaders; + private final boolean isUtcTimestamp; + + private final PageReadStore pages; + + private final ParquetField field; + + public NestedColumnReader(boolean isUtcTimestamp, PageReadStore pages, ParquetField field) { + this.isUtcTimestamp = isUtcTimestamp; + this.pages = pages; + this.field = field; + this.columnReaders = new HashMap<>(); + } + + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + readData(field, readNumber, vector, false); + } + + private Tuple2 readData( + ParquetField field, int readNumber, ColumnVector vector, boolean inside) throws IOException { + if (field.getType() instanceof RowType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof MapType || field.getType() instanceof MultisetType) { + return readMap((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof ArrayType) { + return readArray((ParquetGroupField) field, readNumber, vector, inside); + } else { + return readPrimitive((ParquetPrimitiveField) field, readNumber, vector); + } + } + + private Tuple2 readRow( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapRowColumnVector heapRowVector = (HeapRowColumnVector) vector; + LevelDelegation levelDelegation = null; + List children = field.getChildren(); + WritableColumnVector[] childrenVectors = heapRowVector.getFields(); + WritableColumnVector[] finalChildrenVectors = new WritableColumnVector[childrenVectors.length]; + for (int i = 0; i < children.size(); i++) { + ParquetField child = children.get(i); + if (child == null) { + // Schema-evolution: the logical field is not present in the Parquet file. The slot + // vector was pre-populated with nulls by ParquetSplitReaderUtil#createWritableColumnVector + // (ROW branch), but HeapRowColumnVector#reset() (invoked once per batch by + // ParquetColumnarRowSplitReader#nextBatch) cascades to the children and clears those null + // flags. Since an absent field is never re-read, re-apply the nulls here so the column stays + // NULL instead of reverting to the type's zero value. Skip contributing to the level stream. + childrenVectors[i].fillWithNulls(); + finalChildrenVectors[i] = childrenVectors[i]; + continue; + } + Tuple2 tuple = + readData(child, readNumber, childrenVectors[i], true); + levelDelegation = tuple.f0; + finalChildrenVectors[i] = tuple.f1; + } + if (levelDelegation == null) { + throw new FlinkRuntimeException( + String.format("Row field does not have any non-null children: %s.", field)); + } + + RowPosition rowPosition = + NestedPositionUtil.calculateRowOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If row was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + heapRowVector = new HeapRowColumnVector(rowPosition.getPositionsCount(), finalChildrenVectors); + } else { + heapRowVector.setFields(finalChildrenVectors); + } + + if (rowPosition.getIsNull() != null) { + setFieldNullFlag(rowPosition.getIsNull(), heapRowVector); + } + + // Hudi-specific: collapse a present row whose every child is null into a null row, so that a + // SQL value like `row(null, null)` round-trips to NULL on read. This was the behaviour of the + // legacy RowColumnReader (deleted alongside the Dremel rewire) and existing Hudi tables rely + // on it. Diverges from Flink 2.1, which would surface it as Row(null, null). Pinned by the + // integration test ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes. + // positionsCount comes from the Dremel definition/repetition level stream + // (NestedPositionUtil#calculateRowOffsets). On a full, non-final batch that stream carries a + // one-record lookahead (NestedPrimitiveColumnReader#readAndNewVector reads one value past the + // batch in its do/while, and #getLevelDelegation keeps that trailing level for the next batch), + // so positionsCount can be one larger than the materialized vector lengths. When inside==true + // the row vector is renewed to positionsCount but its children are sized to their value count; + // when inside==false the row vector keeps its batch capacity. Either way, iterating all the way + // to positionsCount can read one element past a shorter vector and throw + // ArrayIndexOutOfBoundsException. Clamp to the shortest vector this loop indexes -- the phantom + // trailing position is never surfaced downstream (ParquetColumnarRowSplitReader caps the batch + // at num). + int rowCount = Math.min(rowPosition.getPositionsCount(), heapRowVector.getLen()); + for (WritableColumnVector child : finalChildrenVectors) { + rowCount = Math.min(rowCount, vectorLength(child)); + } + for (int j = 0; j < rowCount; j++) { + if (heapRowVector.isNullAt(j)) { + continue; + } + boolean allChildrenNull = true; + for (WritableColumnVector child : finalChildrenVectors) { + if (!child.isNullAt(j)) { + allChildrenNull = false; + break; + } + } + if (allChildrenNull) { + heapRowVector.setNullAt(j); + } + } + return Tuple2.of(levelDelegation, heapRowVector); + } + + private Tuple2 readMap( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapMapColumnVector mapVector = (HeapMapColumnVector) vector; + mapVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 2, + "Maps must have two type parameters, found %s", + children.size()); + Tuple2 keyTuple = + readData(children.get(0), readNumber, mapVector.getKeyColumnVector(), true); + Tuple2 valueTuple = + readData(children.get(1), readNumber, mapVector.getValueColumnVector(), true); + + LevelDelegation levelDelegation = keyTuple.f0; + + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If map was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + mapVector = new HeapMapColumnVector(collectionPosition.getValueCount(), keyTuple.f1, valueTuple.f1); + } else { + mapVector.setKeys(keyTuple.f1); + mapVector.setValues(valueTuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), mapVector); + } + + mapVector.setLengths(collectionPosition.getLength()); + mapVector.setOffsets(collectionPosition.getOffsets()); + + return Tuple2.of(levelDelegation, mapVector); + } + + private Tuple2 readArray( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapArrayVector arrayVector = (HeapArrayVector) vector; + arrayVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 1, + "Arrays must have a single type parameter, found %s", + children.size()); + Tuple2 tuple = + readData(children.get(0), readNumber, arrayVector.getChild(), true); + + LevelDelegation levelDelegation = tuple.f0; + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If array was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + arrayVector = new HeapArrayVector(collectionPosition.getValueCount(), tuple.f1); + } else { + arrayVector.setChild(tuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), arrayVector); + } + arrayVector.setLengths(collectionPosition.getLength()); + arrayVector.setOffsets(collectionPosition.getOffsets()); + return Tuple2.of(levelDelegation, arrayVector); + } + + private Tuple2 readPrimitive( + ParquetPrimitiveField field, int readNumber, ColumnVector vector) throws IOException { + ColumnDescriptor descriptor = field.getDescriptor(); + NestedPrimitiveColumnReader reader = columnReaders.get(descriptor); + if (reader == null) { + reader = + new NestedPrimitiveColumnReader( + descriptor, + pages.getPageReader(descriptor), + isUtcTimestamp, + descriptor.getPrimitiveType(), + field.getType()); + columnReaders.put(descriptor, reader); + } + WritableColumnVector writableColumnVector = + reader.readAndNewVector(readNumber, (WritableColumnVector) vector); + return Tuple2.of(reader.getLevelDelegation(), writableColumnVector); + } + + /** + * The length of the {@code isNull}-backed storage that {@code vector} (a row child) is indexed + * against by the null-collapse loop in {@link #readRow}. Every row child is an {@link + * AbstractHeapVector} (nested rows/arrays/maps and all non-decimal primitives) or a {@link + * ParquetDecimalVector} wrapping one (DECIMAL leaves; see {@code + * NestedPrimitiveColumnReader#fillColumnVector}); unwrapping the latter yields an {@code + * AbstractHeapVector} in all cases. + */ + private static int vectorLength(ColumnVector vector) { + ColumnVector storage = + vector instanceof ParquetDecimalVector + ? ((ParquetDecimalVector) vector).getVector() + : vector; + return ((AbstractHeapVector) storage).getLen(); + } + + private static void setFieldNullFlag(boolean[] nullFlags, AbstractHeapVector vector) { + for (int index = 0; index < vector.getLen() && index < nullFlags.length; index++) { + if (nullFlags[index]) { + vector.setNullAt(index); + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java new file mode 100644 index 0000000000000..72809db1b2ceb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java @@ -0,0 +1,639 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.IntArrayList; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; + +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.LogicalType; + +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +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.DictionaryPage; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.parquet.column.ValuesType.DEFINITION_LEVEL; +import static org.apache.parquet.column.ValuesType.REPETITION_LEVEL; +import static org.apache.parquet.column.ValuesType.VALUES; + +/** + * Reader to read a single primitive leaf column that participates in a nested (Dremel) structure. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedPrimitiveColumnReader}). Only the package + * and the Hudi-local {@link ParquetDecimalVector} / {@link LevelDelegation} / {@link IntArrayList} + * imports are changed; the algorithm is untouched. The companion Hudi-specific {@code + * Int64TimestampColumnReader} / {@code FixedLenBytesColumnReader} behaviours stay at the leaf- + * reader creation boundary in {@code ParquetSplitReaderUtil}, not inside this class — keeping it + * a faithful copy of upstream. + */ +public class NestedPrimitiveColumnReader implements ColumnReader { + private static final Logger LOG = LoggerFactory.getLogger(NestedPrimitiveColumnReader.class); + + private final IntArrayList repetitionLevelList = new IntArrayList(0); + private final IntArrayList definitionLevelList = new IntArrayList(0); + + private final PageReader pageReader; + private final ColumnDescriptor descriptor; + private final Type type; + private final LogicalType logicalType; + + /** The dictionary, if this column has dictionary encoding. */ + private final ParquetDataColumnReader dictionary; + + /** Maximum definition level for this column. */ + private final int maxDefLevel; + + private boolean isUtcTimestamp; + + /** Total number of values read. */ + private long valuesRead; + + /** + * value that indicates the end of the current page. That is, if valuesRead == + * endOfPageValueCount, we are at the end of the page. + */ + private long endOfPageValueCount; + + /** If true, the current page is dictionary encoded. */ + private boolean isCurrentPageDictionaryEncoded; + + private int definitionLevel; + private int repetitionLevel; + + /** Repetition/Definition/Value readers. */ + private IntIterator repetitionLevelColumn; + + private IntIterator definitionLevelColumn; + private ParquetDataColumnReader dataColumn; + + /** Total values in the current page. */ + private int pageValueCount; + + // flag to indicate if there is no data in parquet data page + private boolean eof = false; + + private boolean isFirstRow = true; + + private Object lastValue; + + public NestedPrimitiveColumnReader( + ColumnDescriptor descriptor, + PageReader pageReader, + boolean isUtcTimestamp, + Type parquetType, + LogicalType logicalType) + throws IOException { + this.descriptor = descriptor; + this.type = parquetType; + this.pageReader = pageReader; + this.maxDefLevel = descriptor.getMaxDefinitionLevel(); + this.isUtcTimestamp = isUtcTimestamp; + this.logicalType = logicalType; + + DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); + if (dictionaryPage != null) { + try { + this.dictionary = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + parquetType.asPrimitiveType(), + dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } catch (IOException e) { + throw new IOException( + String.format("Could not decode the dictionary for %s", descriptor), e); + } + } else { + this.dictionary = null; + this.isCurrentPageDictionaryEncoded = false; + } + } + + // Not invoked directly; callers use readAndNewVector instead. + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + throw new UnsupportedOperationException("This function should not be called."); + } + + public WritableColumnVector readAndNewVector(int readNumber, WritableColumnVector vector) + throws IOException { + if (isFirstRow) { + if (!readValue()) { + return vector; + } + isFirstRow = false; + } + + // index to set value. + int index = 0; + int valueIndex = 0; + List valueList = new ArrayList<>(); + + // repeated type need two loops to read data. + while (!eof && index < readNumber) { + do { + valueList.add(lastValue); + valueIndex++; + } while (readValue() && (repetitionLevel != 0)); + index++; + } + + return fillColumnVector(valueIndex, valueList); + } + + public LevelDelegation getLevelDelegation() { + int[] repetition = repetitionLevelList.toArray(); + int[] definition = definitionLevelList.toArray(); + repetitionLevelList.clear(); + definitionLevelList.clear(); + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + return new LevelDelegation(repetition, definition); + } + + private boolean readValue() throws IOException { + int left = readPageIfNeed(); + if (left > 0) { + // get the values of repetition and definitionLevel + readAndSaveRepetitionAndDefinitionLevels(); + // read the data if it isn't null + if (definitionLevel == maxDefLevel) { + if (isCurrentPageDictionaryEncoded) { + int dictionaryId = dataColumn.readValueDictionaryId(); + lastValue = dictionaryDecodeValue(logicalType, dictionaryId); + } else { + lastValue = readPrimitiveTypedRow(logicalType); + } + } else { + lastValue = null; + } + return true; + } else { + eof = true; + return false; + } + } + + private void readAndSaveRepetitionAndDefinitionLevels() { + // get the values of repetition and definitionLevel + repetitionLevel = repetitionLevelColumn.nextInt(); + definitionLevel = definitionLevelColumn.nextInt(); + valuesRead++; + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + } + + private int readPageIfNeed() throws IOException { + // Compute the number of values we want to read in this page. + int leftInPage = (int) (endOfPageValueCount - valuesRead); + if (leftInPage == 0) { + // no data left in current page, load data from new page + readPage(); + leftInPage = (int) (endOfPageValueCount - valuesRead); + } + return leftInPage; + } + + private Object readPrimitiveTypedRow(LogicalType category) { + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dataColumn.readBytes(); + case BOOLEAN: + return dataColumn.readBoolean(); + case TIME_WITHOUT_TIME_ZONE: + case DATE: + case INTEGER: + return dataColumn.readInteger(); + case TINYINT: + return dataColumn.readTinyInt(); + case SMALLINT: + return dataColumn.readSmallInt(); + case BIGINT: + return dataColumn.readLong(); + case FLOAT: + return dataColumn.readFloat(); + case DOUBLE: + return dataColumn.readDouble(); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dataColumn.readInteger(); + case INT64: + return dataColumn.readLong(); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return dataColumn.readBytes(); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dataColumn.readTimestamp(); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { + if (dictionaryValue == null) { + return null; + } + + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dictionary.readBytes(dictionaryValue); + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTEGER: + return dictionary.readInteger(dictionaryValue); + case BOOLEAN: + return dictionary.readBoolean(dictionaryValue) ? 1 : 0; + case DOUBLE: + return dictionary.readDouble(dictionaryValue); + case FLOAT: + return dictionary.readFloat(dictionaryValue); + case TINYINT: + return dictionary.readTinyInt(dictionaryValue); + case SMALLINT: + return dictionary.readSmallInt(dictionaryValue); + case BIGINT: + return dictionary.readLong(dictionaryValue); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dictionary.readInteger(dictionaryValue); + case INT64: + return dictionary.readLong(dictionaryValue); + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return dictionary.readBytes(dictionaryValue); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dictionary.readTimestamp(dictionaryValue); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WritableColumnVector fillColumnVector(int total, List valueList) { + switch (logicalType.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + HeapBytesVector heapBytesVector = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (src == null) { + heapBytesVector.setNullAt(i); + } else { + heapBytesVector.appendBytes(i, src, 0, src.length); + } + } + return heapBytesVector; + case BOOLEAN: + HeapBooleanVector heapBooleanVector = new HeapBooleanVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapBooleanVector.setNullAt(i); + } else { + heapBooleanVector.vector[i] = ((List) valueList).get(i); + } + } + return heapBooleanVector; + case TINYINT: + HeapByteVector heapByteVector = new HeapByteVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapByteVector.setNullAt(i); + } else { + heapByteVector.vector[i] = (byte) ((List) valueList).get(i).intValue(); + } + } + return heapByteVector; + case SMALLINT: + HeapShortVector heapShortVector = new HeapShortVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapShortVector.setNullAt(i); + } else { + heapShortVector.vector[i] = (short) ((List) valueList).get(i).intValue(); + } + } + return heapShortVector; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + HeapIntVector heapIntVector = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapIntVector.setNullAt(i); + } else { + heapIntVector.vector[i] = ((List) valueList).get(i); + } + } + return heapIntVector; + case FLOAT: + HeapFloatVector heapFloatVector = new HeapFloatVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapFloatVector.setNullAt(i); + } else { + heapFloatVector.vector[i] = ((List) valueList).get(i); + } + } + return heapFloatVector; + case BIGINT: + HeapLongVector heapLongVector = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapLongVector.setNullAt(i); + } else { + heapLongVector.vector[i] = ((List) valueList).get(i); + } + } + return heapLongVector; + case DOUBLE: + HeapDoubleVector heapDoubleVector = new HeapDoubleVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapDoubleVector.setNullAt(i); + } else { + heapDoubleVector.vector[i] = ((List) valueList).get(i); + } + } + return heapDoubleVector; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + HeapTimestampVector heapTimestampVector = new HeapTimestampVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapTimestampVector.setNullAt(i); + } else { + heapTimestampVector.setTimestamp(i, ((List) valueList).get(i)); + } + } + return heapTimestampVector; + case DECIMAL: + PrimitiveType.PrimitiveTypeName primitiveTypeName = + descriptor.getPrimitiveType().getPrimitiveTypeName(); + switch (primitiveTypeName) { + case INT32: + HeapIntVector phiv = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phiv.setNullAt(i); + } else { + phiv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phiv); + case INT64: + HeapLongVector phlv = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phlv.setNullAt(i); + } else { + phlv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phlv); + default: + HeapBytesVector phbv = getHeapBytesVector(total, valueList); + return new ParquetDecimalVector(phbv); + } + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HeapBytesVector getHeapBytesVector(int total, List valueList) { + HeapBytesVector phbv = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (valueList.get(i) == null) { + phbv.setNullAt(i); + } else { + phbv.appendBytes(i, src, 0, src.length); + } + } + return phbv; + } + + protected void readPage() { + DataPage page = pageReader.readPage(); + + if (page == null) { + return; + } + + page.accept( + new DataPage.Visitor() { + @Override + public Void visit(DataPageV1 dataPageV1) { + readPageV1(dataPageV1); + return null; + } + + @Override + public Void visit(DataPageV2 dataPageV2) { + readPageV2(dataPageV2); + return null; + } + }); + } + + private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int valueCount) + throws IOException { + this.pageValueCount = valueCount; + this.endOfPageValueCount = valuesRead + pageValueCount; + if (dataEncoding.usesDictionary()) { + this.dataColumn = null; + if (dictionary == null) { + throw new IOException( + String.format( + "Could not read page in col %s because the dictionary was missing for encoding %s.", + descriptor, dataEncoding)); + } + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getDictionaryBasedValuesReader( + descriptor, VALUES, dictionary.getDictionary()), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } else { + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getValuesReader(descriptor, VALUES), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = false; + } + + try { + dataColumn.initFromPage(pageValueCount, in); + } catch (IOException e) { + throw new IOException(String.format("Could not read page in col %s.", descriptor), e); + } + } + + private void readPageV1(DataPageV1 page) { + ValuesReader rlReader = page.getRlEncoding().getValuesReader(descriptor, REPETITION_LEVEL); + ValuesReader dlReader = page.getDlEncoding().getValuesReader(descriptor, DEFINITION_LEVEL); + this.repetitionLevelColumn = new ValuesReaderIntIterator(rlReader); + this.definitionLevelColumn = new ValuesReaderIntIterator(dlReader); + try { + BytesInput bytes = page.getBytes(); + LOG.debug("Page size {} bytes and {} records.", bytes.size(), pageValueCount); + ByteBufferInputStream in = bytes.toInputStream(); + LOG.debug("Reading repetition levels at {}.", in.position()); + rlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading definition levels at {}.", in.position()); + dlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading data at {}.", in.position()); + initDataReader(page.getValueEncoding(), in, page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private void readPageV2(DataPageV2 page) { + this.pageValueCount = page.getValueCount(); + this.repetitionLevelColumn = + newRLEIterator(descriptor.getMaxRepetitionLevel(), page.getRepetitionLevels()); + this.definitionLevelColumn = + newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); + try { + LOG.debug( + "Page data size {} bytes and {} records.", page.getData().size(), pageValueCount); + initDataReader( + page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private IntIterator newRLEIterator(int maxLevel, BytesInput bytes) { + try { + if (maxLevel == 0) { + return new NullIntIterator(); + } + return new RLEIntIterator( + new RunLengthBitPackingHybridDecoder( + BytesUtils.getWidthFromMaxInt(maxLevel), + new ByteArrayInputStream(bytes.toByteArray()))); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read levels in page for col %s.", descriptor), e); + } + } + + /** Utility interface to abstract over different way to read ints with different encodings. */ + interface IntIterator { + int nextInt(); + } + + /** Reading int from {@link ValuesReader}. */ + protected static final class ValuesReaderIntIterator implements IntIterator { + ValuesReader delegate; + + public ValuesReaderIntIterator(ValuesReader delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + return delegate.readInteger(); + } + } + + /** Reading int from {@link RunLengthBitPackingHybridDecoder}. */ + protected static final class RLEIntIterator implements IntIterator { + RunLengthBitPackingHybridDecoder delegate; + + public RLEIntIterator(RunLengthBitPackingHybridDecoder delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + try { + return delegate.readInt(); + } catch (IOException e) { + throw new ParquetDecodingException(e); + } + } + } + + /** Reading zero always. */ + protected static final class NullIntIterator implements IntIterator { + @Override + public int nextInt() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java index 3572b117a6313..1826419db5d44 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java @@ -18,7 +18,9 @@ package org.apache.hudi.table.format.cow.vector.reader; +import org.apache.hudi.table.format.cow.ParquetSplitReaderUtil; import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; import org.apache.flink.formats.parquet.vector.reader.ColumnReader; import org.apache.flink.table.data.RowData; @@ -28,6 +30,7 @@ import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.util.FlinkRuntimeException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -39,6 +42,8 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.Type; @@ -46,6 +51,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,7 +59,6 @@ import java.util.Map; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createColumnReader; import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createWritableColumnVector; import static org.apache.parquet.filter2.compat.FilterCompat.get; import static org.apache.parquet.filter2.compat.RowGroupFilter.filterRowGroups; @@ -77,6 +82,14 @@ public class ParquetColumnarRowSplitReader implements Closeable { private final MessageType requestedSchema; + /** + * {@link ParquetField} tree per top-level requested column, used by + * {@link ParquetSplitReaderUtil#createColumnReader(boolean, LogicalType, Type, List, + * PageReadStore, ParquetField)} to drive the Dremel-style {@link NestedColumnReader} for + * nested types. Entries are {@code null} for primitive top-level fields. Built once per split. + */ + private final List requestedFields; + /** * The total number of rows this RecordReader will eventually read. The sum of the rows of all * the row groups. @@ -158,6 +171,20 @@ public ParquetColumnarRowSplitReader( checkSchema(); + // Build the ParquetField tree once per split (the Dremel-style nested reader reuses it across + // row groups). Only columns with nested logical type get a non-null entry — primitive columns + // still use Hudi's specialized ColumnReaders. + MessageColumnIO messageColumnIO = new ColumnIOFactory().getColumnIO(requestedSchema); + List requestedRowFields = new ArrayList<>(requestedTypes.length); + List requestedFieldNames = new ArrayList<>(requestedTypes.length); + for (int i = 0; i < requestedTypes.length; i++) { + String name = requestedSchema.getFieldName(i); + requestedRowFields.add(new RowType.RowField(name, requestedTypes[i])); + requestedFieldNames.add(name); + } + this.requestedFields = ParquetSplitReaderUtil.buildFieldsList( + requestedRowFields, requestedFieldNames, messageColumnIO); + this.writableVectors = createWritableVectors(); ColumnVector[] columnVectors = patchedVector(selectedFieldNames.length, createReadableVectors(), requestedIndices); this.columnarBatch = generator.generate(columnVectors); @@ -340,12 +367,13 @@ private void readNextRowGroup() throws IOException { List columns = requestedSchema.getColumns(); columnReaders = new ColumnReader[types.size()]; for (int i = 0; i < types.size(); ++i) { - columnReaders[i] = createColumnReader( + columnReaders[i] = ParquetSplitReaderUtil.createColumnReader( utcTimestamp, requestedTypes[i], types.get(i), columns, - pages); + pages, + requestedFields.get(i)); } totalCountLoadedSoFar += pages.getRowCount(); } diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java index fdfe5d6fa3a33..1abc6ed56c0db 100644 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java @@ -26,12 +26,16 @@ import org.apache.parquet.column.Dictionary; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; @@ -252,21 +256,115 @@ public TimestampData readTimestamp() { } } + /** + * Reader for Parquet INT64 timestamp values (MILLIS / MICROS / NANOS), i.e. the standard + * timestamp encoding defined by Parquet's + * {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and the legacy + * {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} annotations. + * (The older INT96 encoding is marked deprecated by the Parquet format spec — see + * + * LogicalTypes.md — but is still supported here via {@link TypesFromInt96PageReader} for + * backwards compatibility with files written by older Hive / Spark / Impala versions.) + * + *

    Used by {@link NestedPrimitiveColumnReader} when a TIMESTAMP column sits inside a + * {@code Row}, {@code Array} or {@code Map}; the top-level path continues to use + * {@link Int64TimestampColumnReader} for batched-vector efficiency. + */ + public static class TypesFromInt64PageReader extends DefaultParquetDataColumnReader { + private final boolean isUtcTimestamp; + private final ChronoUnit chronoUnit; + + public TypesFromInt64PageReader( + ValuesReader realReader, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(realReader); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + public TypesFromInt64PageReader( + Dictionary dict, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(dict); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + @Override + public TimestampData readTimestamp() { + return int64ToTimestamp(isUtcTimestamp, valuesReader.readLong(), chronoUnit); + } + + @Override + public TimestampData readTimestamp(int id) { + return int64ToTimestamp(isUtcTimestamp, dict.decodeToLong(id), chronoUnit); + } + } + private static ParquetDataColumnReader getDataColumnReaderByTypeHelper( boolean isDictionary, PrimitiveType parquetType, Dictionary dictionary, ValuesReader valuesReader, boolean isUtcTimestamp) { - if (parquetType.getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.INT96) { + PrimitiveType.PrimitiveTypeName typeName = parquetType.getPrimitiveTypeName(); + if (typeName == PrimitiveType.PrimitiveTypeName.INT96) { return isDictionary ? new TypesFromInt96PageReader(dictionary, isUtcTimestamp) : new TypesFromInt96PageReader(valuesReader, isUtcTimestamp); - } else { - return isDictionary - ? new DefaultParquetDataColumnReader(dictionary) - : new DefaultParquetDataColumnReader(valuesReader); } + if (typeName == PrimitiveType.PrimitiveTypeName.INT64) { + ChronoUnit unit = resolveInt64TimestampUnit(parquetType); + if (unit != null) { + return isDictionary + ? new TypesFromInt64PageReader(dictionary, isUtcTimestamp, unit) + : new TypesFromInt64PageReader(valuesReader, isUtcTimestamp, unit); + } + } + return isDictionary + ? new DefaultParquetDataColumnReader(dictionary) + : new DefaultParquetDataColumnReader(valuesReader); + } + + /** + * Returns the {@link ChronoUnit} for a Parquet INT64 TIMESTAMP column, or {@code null} if the + * column is a plain INT64 (not a timestamp). + * + *

    Supports both the modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and + * the legacy {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} + * encodings. + */ + private static ChronoUnit resolveInt64TimestampUnit(PrimitiveType parquetType) { + LogicalTypeAnnotation annotation = parquetType.getLogicalTypeAnnotation(); + if (annotation instanceof LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) { + LogicalTypeAnnotation.TimeUnit unit = + ((LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation).getUnit(); + switch (unit) { + case MILLIS: + return ChronoUnit.MILLIS; + case MICROS: + return ChronoUnit.MICROS; + case NANOS: + return ChronoUnit.NANOS; + default: + return null; + } + } + OriginalType originalType = parquetType.getOriginalType(); + if (originalType == OriginalType.TIMESTAMP_MILLIS) { + return ChronoUnit.MILLIS; + } + if (originalType == OriginalType.TIMESTAMP_MICROS) { + return ChronoUnit.MICROS; + } + return null; + } + + private static TimestampData int64ToTimestamp( + boolean isUtcTimestamp, long value, ChronoUnit unit) { + Instant instant = Instant.EPOCH.plus(value, unit); + if (isUtcTimestamp) { + return TimestampData.fromInstant(instant); + } + return TimestampData.fromTimestamp(Timestamp.from(instant)); } public static ParquetDataColumnReader getDataColumnReaderByTypeOnDictionary( @@ -281,10 +379,10 @@ public static ParquetDataColumnReader getDataColumnReaderByType( } private static TimestampData int96ToTimestamp( - boolean utcTimestamp, long nanosOfDay, int julianDay) { + boolean isUtcTimestamp, long nanosOfDay, int julianDay) { long millisecond = julianDayToMillis(julianDay) + (nanosOfDay / NANOS_PER_MILLISECOND); - if (utcTimestamp) { + if (isUtcTimestamp) { int nanoOfMillisecond = (int) (nanosOfDay % NANOS_PER_MILLISECOND); return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); } else { diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java deleted file mode 100644 index 79b50487f13c1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; -import java.util.List; - -/** - * Row {@link ColumnReader}. - */ -public class RowColumnReader implements ColumnReader { - - private final List fieldReaders; - - public RowColumnReader(List fieldReaders) { - this.fieldReaders = fieldReaders; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapRowColumnVector rowColumnVector = (HeapRowColumnVector) vector; - WritableColumnVector[] vectors = rowColumnVector.vectors; - // row vector null array - boolean[] isNulls = new boolean[readNumber]; - for (int i = 0; i < vectors.length; i++) { - fieldReaders.get(i).readToVector(readNumber, vectors[i]); - - for (int j = 0; j < readNumber; j++) { - if (i == 0) { - isNulls[j] = vectors[i].isNullAt(j); - } else { - isNulls[j] = isNulls[j] && vectors[i].isNullAt(j); - } - if (i == vectors.length - 1 && isNulls[j]) { - // rowColumnVector[j] is null only when all fields[j] of rowColumnVector[j] is - // null - rowColumnVector.setNullAt(j); - } - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java new file mode 100644 index 0000000000000..0f5e00779a2f5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +/** + * Field that represent parquet's field type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetField}). + */ +public abstract class ParquetField { + private final LogicalType type; + private final int repetitionLevel; + private final int definitionLevel; + private final boolean required; + + public ParquetField( + LogicalType type, int repetitionLevel, int definitionLevel, boolean required) { + this.type = type; + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + this.required = required; + } + + public LogicalType getType() { + return type; + } + + public int getRepetitionLevel() { + return repetitionLevel; + } + + public int getDefinitionLevel() { + return definitionLevel; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "Field{" + + "type=" + + type + + ", repetitionLevel=" + + repetitionLevel + + ", definitionLevel=" + + definitionLevel + + ", required=" + + required + + '}'; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java new file mode 100644 index 0000000000000..f91dcca965d64 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's Group Field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetGroupField}) with a Hudi-specific extension: + * entries in the {@code children} list may be {@code null} to denote a Row child that is absent + * from the parquet file but present in the requested logical schema (schema evolution). This + * replaces Hudi's previous {@code EmptyColumnReader} branch for Row subtrees. + */ +public class ParquetGroupField extends ParquetField { + + private final List children; + + public ParquetGroupField( + LogicalType type, + int repetitionLevel, + int definitionLevel, + boolean required, + List children) { + super(type, repetitionLevel, definitionLevel, required); + // Use a plain unmodifiable list (not ImmutableList) so that null entries are allowed for + // schema-evolution missing children in ROW types. + this.children = + Collections.unmodifiableList(new ArrayList<>(requireNonNull(children, "children is null"))); + } + + /** Children of this group. Entries may be {@code null} for absent-in-file Row fields. */ + public List getChildren() { + return children; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java new file mode 100644 index 0000000000000..f6af6f9ff479e --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.column.ColumnDescriptor; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's primitive field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetPrimitiveField}). + */ +public class ParquetPrimitiveField extends ParquetField { + + private final ColumnDescriptor descriptor; + private final int id; + + public ParquetPrimitiveField( + LogicalType type, boolean required, ColumnDescriptor descriptor, int id) { + super( + type, + descriptor.getMaxRepetitionLevel(), + descriptor.getMaxDefinitionLevel(), + required); + this.descriptor = requireNonNull(descriptor, "descriptor is required"); + this.id = id; + } + + public ColumnDescriptor getDescriptor() { + return descriptor; + } + + public int getId() { + return id; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..ae2e4107d6ea7 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + throw new UnsupportedOperationException("Variant is not supported yet."); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java new file mode 100644 index 0000000000000..7cb62824e8543 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.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.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests for the Flink 2.1-compatible accessors added on {@link HeapArrayVector}, + * {@link HeapMapColumnVector} and {@link HeapRowColumnVector} when vendoring Flink 2.1's + * nested-Parquet reader (FLINK-35702). + * + *

    The accessors are wrappers over the existing public fields so legacy callers continue to + * work. These tests exist solely to pin down that wrapper contract — runtime correctness of the + * Dremel-style read path is exercised end-to-end by integration tests in + * {@code ITTestHoodieDataSource} (testParquetComplexTypes / testParquetComplexNestedRowTypes / + * testParquetArrayMapOfRowTypes / testParquetNullChildColumnsRowTypes). + */ +class TestHeapColumnVectorAccessors { + + // ----------------------------------------------------------------------------------------------- + // HeapArrayVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapArrayVectorAccessorsReflectPublicFields() { + HeapIntVector child = new HeapIntVector(4); + HeapArrayVector vector = new HeapArrayVector(2, child); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector replacementChild = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setChild(replacementChild); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(replacementChild, vector.getChild()); + assertEquals(2, vector.getSize()); + + // Backing public fields are kept in sync — preserves backward compatibility. + assertSame(offsets, vector.offsets); + assertSame(lengths, vector.lengths); + assertSame(replacementChild, vector.child); + } + + // ----------------------------------------------------------------------------------------------- + // HeapMapColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapMapColumnVectorConstructorInitializesOffsetsAndLengths() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + + HeapMapColumnVector vector = new HeapMapColumnVector(3, keys, values); + + assertEquals(3, vector.getOffsets().length); + assertEquals(3, vector.getLengths().length); + } + + @Test + void heapMapColumnVectorAccessorsReflectInternalState() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + HeapMapColumnVector vector = new HeapMapColumnVector(2, keys, values); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector newKeys = new HeapLongVector(4); + HeapLongVector newValues = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setKeys(newKeys); + vector.setValues(newValues); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(newKeys, vector.getKeys()); + assertSame(newValues, vector.getValues()); + // The Flink-2.1-style ColumnVector accessors return the same underlying child. + assertSame(newKeys, vector.getKeyColumnVector()); + assertSame(newValues, vector.getValueColumnVector()); + assertEquals(2, vector.getSize()); + } + + // ----------------------------------------------------------------------------------------------- + // HeapRowColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapRowColumnVectorFieldsAccessorsReflectPublicVectors() { + HeapIntVector intField = new HeapIntVector(2); + HeapLongVector longField = new HeapLongVector(2); + HeapRowColumnVector vector = new HeapRowColumnVector(2, intField, longField); + + WritableColumnVector[] originalFields = vector.getFields(); + assertEquals(2, originalFields.length); + assertSame(intField, originalFields[0]); + assertSame(longField, originalFields[1]); + // Backing public field is kept in sync — preserves backward compatibility. + assertSame(originalFields, vector.vectors); + + HeapIntVector replacement = new HeapIntVector(2); + WritableColumnVector[] replacementFields = {replacement, longField}; + vector.setFields(replacementFields); + + assertSame(replacementFields, vector.getFields()); + assertSame(replacementFields, vector.vectors); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java new file mode 100644 index 0000000000000..02fe1e61ccb05 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.columnar.vector.BytesColumnVector; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ParquetDecimalVector}. + */ +public class TestParquetDecimalVector { + + @Test + void testGetDecimalFromInt32Vector() { + // precision <= 9 => ParquetSchemaConverter.is32BitDecimal(precision) == true + HeapIntVector intVector = new HeapIntVector(1); + intVector.vector[0] = 12345; + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(new BigDecimal("123.45"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromInt64Vector() { + // 9 < precision <= 18 => ParquetSchemaConverter.is64BitDecimal(precision) == true + HeapLongVector longVector = new HeapLongVector(1); + longVector.vector[0] = 1234567890123456L; + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + DecimalData decoded = wrapped.getDecimal(0, 18, 4); + + assertEquals(new BigDecimal("123456789012.3456"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtLargePrecision() { + // precision > 18 => BINARY / FIXED_LEN_BYTE_ARRAY path + BigDecimal original = new BigDecimal("12345678901234567890.1234567890"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 30, 10); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtSmallPrecision() { + // A Parquet file can legally encode a small-precision decimal as BINARY. In that case the + // dispatch must fall through to the bytes branch rather than require an IntColumnVector. + BigDecimal original = new BigDecimal("123.45"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalThrowsOnUnsupportedVectorType() { + // A large-precision request must have a bytes-backed child; any other writable child is an + // illegal combination and must be surfaced via Preconditions.checkArgument. + ColumnVector unsupported = new HeapShortVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(unsupported); + + assertThrows(IllegalArgumentException.class, () -> wrapped.getDecimal(0, 30, 10)); + } + + @Test + void testIsNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + intVector.vector[0] = 1; + intVector.setNullAt(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + assertFalse(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testWritableIntRoundTrip() { + HeapIntVector intVector = new HeapIntVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setInt(0, 42); + + assertEquals(42, wrapped.getInt(0)); + assertEquals(42, intVector.vector[0]); + } + + @Test + void testWritableLongRoundTrip() { + HeapLongVector longVector = new HeapLongVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + wrapped.setLong(0, 9876543210L); + + assertEquals(9876543210L, wrapped.getLong(0)); + assertEquals(9876543210L, longVector.vector[0]); + } + + @Test + void testWritableBytesRoundTrip() { + HeapBytesVector bytesVector = new HeapBytesVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + byte[] payload = new byte[] {0x01, 0x02, 0x03}; + + wrapped.appendBytes(0, payload, 0, payload.length); + + BytesColumnVector.Bytes out = wrapped.getBytes(0); + assertEquals(payload.length, out.len); + assertEquals(0x01, out.data[out.offset]); + assertEquals(0x02, out.data[out.offset + 1]); + assertEquals(0x03, out.data[out.offset + 2]); + } + + @Test + void testResetDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(1); + intVector.setNullAt(0); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + assertTrue(wrapped.isNullAt(0)); + + wrapped.reset(); + + assertFalse(wrapped.isNullAt(0)); + } + + @Test + void testFillWithNullsDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.fillWithNulls(); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testSetNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setNullAt(0); + wrapped.setNulls(1, 1); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java new file mode 100644 index 0000000000000..9d6607d03febb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.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.hudi.table.format.cow.vector.reader; + +import org.apache.flink.table.data.TimestampData; + +import org.apache.parquet.column.Dictionary; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for the {@link ParquetDataColumnReaderFactory} INT64 timestamp dispatch added when + * vendoring Flink 2.1's nested-Parquet reader (FLINK-35702). + * + *

    The factory is exercised end-to-end by integration tests through + * {@link NestedPrimitiveColumnReader}; this unit test focuses on the small, deterministic piece + * that was added by this PR — selecting the right {@code ParquetDataColumnReader} for each + * supported INT64 TIMESTAMP encoding (modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} + * MILLIS / MICROS / NANOS plus the legacy {@link OriginalType} encodings) and decoding values + * using both the values-reader and dictionary code paths. + */ +class TestParquetDataColumnReaderFactory { + + // ----------------------------------------------------------------------------------------------- + // Type dispatch + // ----------------------------------------------------------------------------------------------- + + @Test + void valuesReaderDispatchInt96TimestampUsesInt96Reader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT96).named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt96PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64WithoutAnnotationUsesDefaultReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT64).named("plainLong"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMicrosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampNanosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMillisOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MILLIS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMicrosOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MICROS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt32DoesNotUseTimestampReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT32).named("i"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void dictionaryReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + // ----------------------------------------------------------------------------------------------- + // INT64 → TimestampData decoding (per ChronoUnit, both UTC and local-time-zone branches) + // ----------------------------------------------------------------------------------------------- + + @Test + void int64ReaderReadsTimestampMillisFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_123L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMillis), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + assertEquals(0, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMicrosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + long epochMicros = 1_700_000_000_123_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMicros), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMicros / 1_000L, ts.getMillisecond()); + // 456 microseconds remain → 456_000 nanoseconds within the millisecond + assertEquals(456_000, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampNanosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + long epochNanos = 1_700_000_000_123_456_789L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochNanos), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochNanos / 1_000_000L, ts.getMillisecond()); + assertEquals(456_789, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMillisFromDictionaryInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(epochMillis), true); + + TimestampData ts = reader.readTimestamp(0); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + } + + // ----------------------------------------------------------------------------------------------- + // Stubs (only the methods exercised by the dispatch + decoding tests above) + // ----------------------------------------------------------------------------------------------- + + /** Minimal {@link ValuesReader} returning a fixed long; other methods throw. */ + private static final class StubValuesReader extends ValuesReader { + private final long fixedLong; + + StubValuesReader() { + this(0L); + } + + StubValuesReader(long fixedLong) { + this.fixedLong = fixedLong; + } + + @Override + public long readLong() { + return fixedLong; + } + + @Override + public void skip() { + // unused + } + } + + /** Minimal {@link Dictionary} returning a fixed long for any id; other methods throw. */ + private static final class StubDictionary extends Dictionary { + private final long fixedLong; + + StubDictionary() { + this(0L); + } + + StubDictionary(long fixedLong) { + super(null); + this.fixedLong = fixedLong; + } + + @Override + public Binary decodeToBinary(int id) { + throw new UnsupportedOperationException(); + } + + @Override + public long decodeToLong(int id) { + return fixedLong; + } + + @Override + public int getMaxId() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java new file mode 100644 index 0000000000000..a071ef937611c --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.19.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.VarCharType; + +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link ParquetGroupField}. + */ +public class TestParquetGroupField { + + @Test + void testChildrenWithAllNonNullEntriesAreRetained() { + ParquetField c0 = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetField c1 = new ParquetPrimitiveField(new VarCharType(), true, descriptor(), 1); + List children = Arrays.asList(c0, c1); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertSame(c0, group.getChildren().get(0)); + assertSame(c1, group.getChildren().get(1)); + } + + @Test + void testChildrenMayContainNullForSchemaEvolution() { + // A ROW field present in the requested Flink schema but absent from the Parquet file is + // represented by a null slot in `children`. The group must allow this (Hudi-specific + // extension over Flink's ImmutableList-backed equivalent). + ParquetField present = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List children = Arrays.asList(present, null); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertNotNull(group.getChildren().get(0)); + assertNull(group.getChildren().get(1)); + } + + @Test + void testChildrenListIsUnmodifiable() { + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.singletonList(child)); + + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().add(null)); + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().remove(0)); + } + + @Test + void testChildrenListIsDefensivelyCopied() { + // Mutations to the caller-supplied list must not be visible through the group. + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List mutable = new ArrayList<>(); + mutable.add(child); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, mutable); + mutable.add(null); + + assertEquals(1, group.getChildren().size()); + } + + @Test + void testNullChildrenListThrows() { + assertThrows( + NullPointerException.class, + () -> new ParquetGroupField(rowType(), 0, 1, true, null)); + } + + @Test + void testEmptyChildrenListIsAllowed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.emptyList()); + + assertEquals(0, group.getChildren().size()); + } + + @Test + void testFieldMetadataIsExposed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 2, 5, false, Collections.emptyList()); + + assertEquals(2, group.getRepetitionLevel()); + assertEquals(5, group.getDefinitionLevel()); + assertFalse(group.isRequired()); + } + + private static LogicalType rowType() { + return RowType.of(new IntType()); + } + + private static ColumnDescriptor descriptor() { + PrimitiveType primitive = Types.required(PrimitiveTypeName.INT32).named("f"); + return new ColumnDescriptor(new String[] {"f"}, primitive, 0, 0); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/pom.xml b/hudi-flink-datasource/hudi-flink1.20.x/pom.xml index fee65624c6857..06738ec1b2f7e 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink1.20.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -127,12 +127,6 @@ ${flink1.20.version} provided - - org.apache.flink - flink-table-planner_2.12 - ${flink1.20.version} - provided - diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..e8e31b341a180 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +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.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + private static final String VARIANT_UNSUPPORTED_MSG = + "VARIANT type is only supported in Flink 2.1+. " + + "Please upgrade your Flink version to use Variant columns."; + + public static Option variantParquetAnnotation() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Variant getVariant(RowData rowData, int pos) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static boolean isVariantType(LogicalType logicalType) { + return false; + } + + public static DataType createVariantType() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantMetadata(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantValue(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java index 2bb5be1d9614e..7b2bb0fa55f8e 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java @@ -7,7 +7,7 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,19 +19,18 @@ package org.apache.hudi.table.format.cow; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; import org.apache.hudi.table.format.cow.vector.HeapArrayVector; import org.apache.hudi.table.format.cow.vector.HeapDecimalVector; import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; -import org.apache.hudi.table.format.cow.vector.reader.ArrayColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.ArrayGroupReader; import org.apache.hudi.table.format.cow.vector.reader.EmptyColumnReader; import org.apache.hudi.table.format.cow.vector.reader.FixedLenBytesColumnReader; import org.apache.hudi.table.format.cow.vector.reader.Int64TimestampColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.MapColumnReader; +import org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader; import org.apache.hudi.table.format.cow.vector.reader.ParquetColumnarRowSplitReader; -import org.apache.hudi.table.format.cow.vector.reader.RowColumnReader; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.vector.reader.BooleanColumnReader; @@ -64,12 +63,13 @@ 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.LogicalTypeRoot; 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.TimestampType; +import org.apache.flink.util.FlinkRuntimeException; import org.apache.flink.util.Preconditions; +import org.apache.flink.util.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.parquet.ParquetRuntimeException; import org.apache.parquet.column.ColumnDescriptor; @@ -77,12 +77,18 @@ import org.apache.parquet.column.page.PageReader; import org.apache.parquet.filter.UnboundRecordFilter; import org.apache.parquet.filter2.predicate.FilterPredicate; +import org.apache.parquet.io.ColumnIO; +import org.apache.parquet.io.GroupColumnIO; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.PrimitiveColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.InvalidSchemaException; import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; +import javax.annotation.Nullable; + import java.io.IOException; import java.math.BigDecimal; import java.sql.Date; @@ -90,25 +96,38 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.apache.flink.table.utils.DateTimeUtils.toInternal; import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; import static org.apache.parquet.Preconditions.checkArgument; +import static org.apache.parquet.schema.Type.Repetition.REPEATED; +import static org.apache.parquet.schema.Type.Repetition.REQUIRED; /** * Util for generating {@link ParquetColumnarRowSplitReader}. * - *

    NOTE: reference from Flink release 1.11.2 {@code ParquetSplitReaderUtil}, modify to support INT64 - * based TIMESTAMP_MILLIS as ConvertedType, should remove when Flink supports that. + *

    Uses the Dremel-style nested reader ported from Apache Flink 2.1 (FLINK-35702). For primitive + * top-level columns we keep Hudi's specialized readers — {@link Int64TimestampColumnReader}, + * {@link FixedLenBytesColumnReader}, and the Hudi {@link HeapDecimalVector} — unchanged. For + * nested types (ARRAY / MAP / MULTISET / ROW) we build a {@link ParquetField} tree once per + * split via {@link #buildFieldsList(List, List, MessageColumnIO)} and delegate reading to + * {@link NestedColumnReader}. + * + *

    Schema evolution: missing top-level fields are still handled by the caller + * ({@link ParquetColumnarRowSplitReader} patches them with null vectors). Missing fields + * inside a Row are handled here — {@link #constructField} returns {@code null} for a + * child that isn't physically present, and the corresponding child in the pre-allocated vector + * is filled with nulls via {@link #createVectorFromConstant} so the Dremel assembler can + * passthrough the slot (see {@link NestedColumnReader#readToVector}). */ public class ParquetSplitReaderUtil { - /** - * Util for generating partitioned {@link ParquetColumnarRowSplitReader}. - */ + /** Util for generating partitioned {@link ParquetColumnarRowSplitReader}. */ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( boolean utcTimestamp, boolean caseSensitive, @@ -182,10 +201,13 @@ private static ColumnVector createVector( return readVector; } - private static ColumnVector createVectorFromConstant( - LogicalType type, - Object value, - int batchSize) { + /** + * Builds a constant-filled column vector for either a partition column (non-null value) or a + * missing-column slot (null value). Used both at the batch-generator level for partition + * injection and at the row-reader level for fields absent from the Parquet file. + */ + public static ColumnVector createVectorFromConstant( + LogicalType type, Object value, int batchSize) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -278,6 +300,7 @@ private static ColumnVector createVectorFromConstant( value == null ? null : toInternal((Date) value), batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: HeapTimestampVector tv = new HeapTimestampVector(batchSize); if (value == null) { tv.fillWithNulls(); @@ -286,46 +309,41 @@ private static ColumnVector createVectorFromConstant( } return tv; case ARRAY: - ArrayType arrayType = (ArrayType) type; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - HeapArrayGroupColumnVector arrayGroup = new HeapArrayGroupColumnVector(batchSize); - if (value == null) { - arrayGroup.fillWithNulls(); - return arrayGroup; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } - } else { - HeapArrayVector arrayVector = new HeapArrayVector(batchSize); - if (value == null) { - arrayVector.fillWithNulls(); - return arrayVector; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } + if (value != null) { + throw new UnsupportedOperationException("Unsupported create array with default value."); } + HeapArrayVector arrayVector = new HeapArrayVector(batchSize); + arrayVector.fillWithNulls(); + return arrayVector; case MAP: - HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); - if (value == null) { - mapVector.fillWithNulls(); - return mapVector; - } else { - throw new UnsupportedOperationException("Unsupported create map with default value."); + case MULTISET: + if (value != null) { + throw new UnsupportedOperationException( + "Unsupported create " + type.getTypeRoot() + " with default value."); } + HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); + mapVector.fillWithNulls(); + return mapVector; case ROW: - HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize); - if (value == null) { - rowVector.fillWithNulls(); - return rowVector; - } else { + if (value != null) { throw new UnsupportedOperationException("Unsupported create row with default value."); } + RowType rowType = (RowType) type; + WritableColumnVector[] childVectors = new WritableColumnVector[rowType.getFieldCount()]; + for (int i = 0; i < childVectors.length; i++) { + childVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); + } + HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize, childVectors); + rowVector.fillWithNulls(); + return rowVector; default: throw new UnsupportedOperationException("Unsupported type: " + type); } } - private static List filterDescriptors(int depth, Type type, List columns) throws ParquetRuntimeException { + private static List filterDescriptors( + int depth, Type type, List columns) throws ParquetRuntimeException { List filtered = new ArrayList<>(); for (ColumnDescriptor descriptor : columns) { if (depth >= descriptor.getPath().length) { @@ -339,24 +357,61 @@ private static List filterDescriptors(int depth, Type type, Li return filtered; } + /** + * Creates a {@link ColumnReader} for one top-level requested field. For primitive types the + * Hudi-specialized reader path is used. For nested types ({@code ARRAY}, {@code MAP}, + * {@code MULTISET}, {@code ROW}) the Dremel-style {@link NestedColumnReader} is used, driven by + * the supplied pre-built {@link ParquetField} tree. + * + * @param field the {@link ParquetField} tree for this column, built by + * {@link #buildFieldsList(List, List, MessageColumnIO)}. Required (non-null) for nested + * types; ignored for primitives. + */ + public static ColumnReader createColumnReader( + boolean utcTimestamp, + LogicalType fieldType, + Type physicalType, + List descriptors, + PageReadStore pages, + @Nullable ParquetField field) throws IOException { + switch (fieldType.getTypeRoot()) { + case ARRAY: + case MAP: + case MULTISET: + case ROW: + Preconditions.checkNotNull( + field, "ParquetField must be provided for nested type: %s", fieldType); + return new NestedColumnReader(utcTimestamp, pages, field); + default: + return createPrimitiveColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages); + } + } + + /** + * Backward-compat entry point kept for callers that don't project nested types and therefore + * never need a {@link ParquetField} tree. Forwards to the {@link ParquetField}-aware overload + * with a null field; nested types now go through that overload directly. + * + * @deprecated use {@link #createColumnReader(boolean, LogicalType, Type, List, PageReadStore, + * ParquetField)} so nested types take the Dremel path. + */ + @Deprecated public static ColumnReader createColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List descriptors, PageReadStore pages) throws IOException { - return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, - pages, 0); + return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages, null); } - private static ColumnReader createColumnReader( + private static ColumnReader createPrimitiveColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List columns, - PageReadStore pages, - int depth) throws IOException { - List descriptors = filterDescriptors(depth, physicalType, columns); + PageReadStore pages) throws IOException { + List descriptors = filterDescriptors(0, physicalType, columns); ColumnDescriptor descriptor = descriptors.get(0); PageReader pageReader = pages.getPageReader(descriptor); switch (fieldType.getTypeRoot()) { @@ -392,7 +447,9 @@ private static ColumnReader createColumnReader( case INT96: return new TimestampColumnReader(utcTimestamp, descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for TIMESTAMP: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } case DECIMAL: switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { @@ -403,106 +460,23 @@ private static ColumnReader createColumnReader( case BINARY: return new BytesColumnReader(descriptor, pageReader); case FIXED_LEN_BYTE_ARRAY: - return new FixedLenBytesColumnReader( - descriptor, pageReader); + return new FixedLenBytesColumnReader(descriptor, pageReader); default: - throw new AssertionError(); - } - case ARRAY: - ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new ArrayGroupReader(createColumnReader( - utcTimestamp, - arrayType.getElementType(), - elementType, - descriptors, - pages, - elementDepth)); - } else { - return new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - fieldType); + throw new AssertionError( + "Unexpected physical type for DECIMAL: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } - case MAP: - MapType mapType = (MapType) fieldType; - ArrayColumnReader keyReader = - new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - new ArrayType(mapType.getKeyType())); - ColumnReader valueReader; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueReader = new ArrayGroupReader(createColumnReader( - utcTimestamp, - mapType.getValueType(), - physicalType.asGroupType().getType(0).asGroupType().getType(1), // Get the value physical type - descriptors.subList(1, descriptors.size()), // remove the key descriptor - pages, - depth + 2)); // increase the depth by 2, because there's a key_value entry in the path - } else { - valueReader = new ArrayColumnReader( - descriptors.get(1), - pages.getPageReader(descriptors.get(1)), - utcTimestamp, - descriptors.get(1).getPrimitiveType(), - new ArrayType(mapType.getValueType())); - } - return new MapColumnReader(keyReader, valueReader); - case ROW: - RowType rowType = (RowType) fieldType; - GroupType groupType = physicalType.asGroupType(); - List fieldReaders = new ArrayList<>(); - for (int i = 0; i < rowType.getFieldCount(); i++) { - // schema evolution: read the parquet file with a new extended field name. - int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); - if (fieldIndex < 0) { - fieldReaders.add(new EmptyColumnReader()); - } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - fieldReaders.add( - createColumnReader( - utcTimestamp, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } else { - fieldReaders.add( - createColumnReader( - utcTimestamp, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } - } - } - return new RowColumnReader(fieldReaders); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + /** + * Creates the writable column vector that the reader will write into. The returned vector shape + * matches {@code fieldType}; for ROW types missing physical fields are slotted with null-filled + * vectors (sourced from {@link #createVectorFromConstant}) so that the Dremel assembler in + * {@link NestedColumnReader} can pass them through unchanged. + */ public static WritableColumnVector createWritableColumnVector( int batchSize, LogicalType fieldType, @@ -523,33 +497,40 @@ private static WritableColumnVector createWritableColumnVector( switch (fieldType.getTypeRoot()) { case BOOLEAN: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBooleanVector(batchSize); case TINYINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapByteVector(batchSize); case DOUBLE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDoubleVector(batchSize); case FLOAT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.FLOAT, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.FLOAT, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapFloatVector(batchSize); case INTEGER: case DATE: case TIME_WITHOUT_TIME_ZONE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapIntVector(batchSize); case BIGINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT64, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT64, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapLongVector(batchSize); case SMALLINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapShortVector(batchSize); case CHAR: case VARCHAR: @@ -566,112 +547,64 @@ private static WritableColumnVector createWritableColumnVector( case DECIMAL: checkArgument( (typeName == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY - || typeName == PrimitiveType.PrimitiveTypeName.BINARY) + || typeName == PrimitiveType.PrimitiveTypeName.BINARY) && primitiveType.getOriginalType() == OriginalType.DECIMAL, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDecimalVector(batchSize); case ARRAY: ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - elementType, - descriptors, - elementDepth)); - } else { - return new HeapArrayVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - physicalType, - descriptors, - depth)); - } - case MAP: + return new HeapArrayVector( + batchSize, + createWritableColumnVector( + batchSize, arrayType.getElementType(), physicalType, descriptors, depth)); + case MAP: { MapType mapType = (MapType) fieldType; - GroupType repeatedType = physicalType.asGroupType().getType(0).asGroupType(); - // the map column has three level paths. - WritableColumnVector keyColumnVector = createWritableColumnVector( + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( batchSize, - new ArrayType(mapType.getKeyType().isNullable(), mapType.getKeyType()), - repeatedType.getType(0), - descriptors, - depth + 2); - WritableColumnVector valueColumnVector; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueColumnVector = new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - mapType.getValueType(), - repeatedType.getType(1).asGroupType(), - descriptors, - depth + 2)); - } else { - valueColumnVector = createWritableColumnVector( - batchSize, - new ArrayType(mapType.getValueType().isNullable(), mapType.getValueType()), - repeatedType.getType(1), - descriptors, - depth + 2); - } - return new HeapMapColumnVector(batchSize, keyColumnVector, valueColumnVector); + createWritableColumnVector( + batchSize, mapType.getKeyType(), repeatedType.getType(0), descriptors, depth + 2), + createWritableColumnVector( + batchSize, mapType.getValueType(), repeatedType.getType(1), descriptors, depth + 2)); + } + case MULTISET: { + MultisetType multisetType = (MultisetType) fieldType; + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( + batchSize, + createWritableColumnVector( + batchSize, + multisetType.getElementType(), + repeatedType.getType(0), + descriptors, + depth + 2), + createWritableColumnVector( + batchSize, + new IntType(false), + repeatedType.getType(1), + descriptors, + depth + 2)); + } case ROW: RowType rowType = (RowType) fieldType; GroupType groupType = physicalType.asGroupType(); WritableColumnVector[] columnVectors = new WritableColumnVector[rowType.getFieldCount()]; for (int i = 0; i < columnVectors.length; i++) { - // schema evolution: read the file with a new extended field name. int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); if (fieldIndex < 0) { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (groupType.getRepetition().equals(Type.Repetition.REPEATED) && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant( - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), null, batchSize); - } else { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); - } + // Schema evolution: logical field is absent from the Parquet file. Slot a null-filled + // vector of the correct shape; NestedColumnReader.readRow will pass it through when the + // matching ParquetField child is null. + columnVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = - createWritableColumnVector( - batchSize, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } else { - columnVectors[i] = - createWritableColumnVector( - batchSize, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } + columnVectors[i] = + createWritableColumnVector( + batchSize, + rowType.getTypeAt(i), + groupType.getType(fieldIndex), + descriptors, + depth + 1); } } return new HeapRowColumnVector(batchSize, columnVectors); @@ -681,56 +614,245 @@ private static WritableColumnVector createWritableColumnVector( } /** - * Returns the field index with given physical row type {@code groupType} and field name {@code fieldName}. - * - * @return The physical field index or -1 if the field does not exist + * Peels one {@code repeated group key_value} wrapper off a MAP / MULTISET physical type, matching + * Parquet's canonical 3-level map encoding. */ - private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { - // get index from fileSchema type, else, return -1 - return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + private static GroupType unwrapMapRepeatedType(Type physicalType) { + return physicalType.asGroupType().getType(0).asGroupType(); } + // ------------------------------------------------------------------------------------------ + // ParquetField tree construction (vendored from Apache Flink 2.1 ParquetSplitReaderUtil) + // + // The only Hudi-specific divergence is in `constructField`: the ROW branch tolerates children + // missing from the Parquet file by emitting a null ParquetField child (upstream throws). This + // matches the Hudi schema-evolution contract and is the companion to the null-child branch in + // `NestedColumnReader#readRow` and the null-vector slot in `createWritableColumnVector#ROW`. + // ------------------------------------------------------------------------------------------ + /** - * Check whether the given list type is a three-level list type. - *

    - * group (LIST) { - * repeated group list { - * element; - * } - * } - * - * @param type list type - * @return true if the list type is a three-level list type + * Builds {@link ParquetField} trees — one per top-level projected logical column — that feed + * {@link NestedColumnReader}. The returned list mirrors the input {@code children} positionally; + * primitive top-level fields produce {@code null} entries (callers don't need a tree for those). */ - private static boolean isThreeLevelList(Type type) { - if (type.isPrimitive()) { - return false; + public static List buildFieldsList( + List children, List fieldNames, MessageColumnIO columnIO) { + List list = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + RowType.RowField child = children.get(i); + if (isNestedType(child.getType())) { + list.add(constructField(child, lookupColumnByName(columnIO, fieldNames.get(i)))); + } else { + list.add(null); + } } - GroupType groupType = type.asGroupType(); - OriginalType originalType = groupType.getOriginalType(); - return originalType == OriginalType.LIST - && groupType.getType(0).getName().equals("list"); + return list; + } + + private static boolean isNestedType(LogicalType type) { + return type instanceof RowType + || type instanceof ArrayType + || type instanceof MapType + || type instanceof MultisetType; + } + + @Nullable + private static ParquetField constructField(RowType.RowField rowField, ColumnIO columnIO) { + boolean required = columnIO.getType().getRepetition() == REQUIRED; + int repetitionLevel = columnIO.getRepetitionLevel(); + int definitionLevel = columnIO.getDefinitionLevel(); + LogicalType type = rowField.getType(); + String fieldName = rowField.getName(); + if (type instanceof RowType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + RowType rowType = (RowType) type; + List childFields = rowType.getFields(); + List fieldsList = new ArrayList<>(childFields.size()); + for (RowType.RowField childField : childFields) { + // Hudi schema evolution: a logical child may be absent from the Parquet file. In that + // case we emit a null ParquetField so that NestedColumnReader.readRow passes through the + // pre-filled null vector instead of recursing. + ColumnIO childIo = lookupColumnByNameOrNull(groupColumnIO, childField.getName()); + if (childIo == null) { + fieldsList.add(null); + } else { + fieldsList.add(constructField(childField, childIo)); + } + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(fieldsList)); + } + + if (type instanceof MapType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MapType mapType = (MapType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", mapType.getKeyType()), keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", mapType.getValueType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof MultisetType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MultisetType multisetType = (MultisetType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", multisetType.getElementType()), + keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", new IntType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof ArrayType) { + ArrayType arrayType = (ArrayType) type; + ColumnIO elementTypeColumnIO; + if (columnIO instanceof GroupColumnIO) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) { + while (!Objects.equals(groupColumnIO.getName(), fieldName)) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + elementTypeColumnIO = groupColumnIO; + } else { + if (arrayType.getElementType() instanceof RowType) { + elementTypeColumnIO = groupColumnIO; + } else { + elementTypeColumnIO = groupColumnIO.getChild(0); + } + } + } else if (columnIO instanceof PrimitiveColumnIO) { + elementTypeColumnIO = columnIO; + } else { + throw new FlinkRuntimeException(String.format("Unknown ColumnIO, %s", columnIO)); + } + + ParquetField elementField = + constructField( + new RowType.RowField("", arrayType.getElementType()), + getArrayElementColumn(elementTypeColumnIO)); + if (repetitionLevel == elementField.getRepetitionLevel()) { + repetitionLevel = columnIO.getParent().getRepetitionLevel(); + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.singletonList(elementField)); + } + + PrimitiveColumnIO primitiveColumnIO = (PrimitiveColumnIO) columnIO; + return new ParquetPrimitiveField( + type, required, primitiveColumnIO.getColumnDescriptor(), primitiveColumnIO.getId()); } /** - * Construct the error message when primitive type mismatches. - * - * @param primitiveType Primitive type - * @param fieldType Logical field type - * @return The error message + * Parquet column names are case-insensitive in Flink's lookup. Matches upstream + * {@code ParquetSplitReaderUtil.lookupColumnByName}; throws when absent. */ - private static String getPrimitiveTypeCheckFailureMessage(PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { - return String.format("Unexpected type exception. Primitive type: %s. Field type: %s.", primitiveType, fieldType.getTypeRoot().name()); + public static ColumnIO lookupColumnByName(GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = lookupColumnByNameOrNull(groupColumnIO, columnName); + if (columnIO != null) { + return columnIO; + } + throw new FlinkRuntimeException( + "Can not find column io for parquet reader. Column name: " + columnName); } /** - * Construct the error message when original type mismatches. + * Case-insensitive column lookup that returns {@code null} when no match is found — the + * Hudi-specific companion to {@link #lookupColumnByName}, used by {@link #constructField} to + * emit null {@link ParquetField} children for fields absent from the Parquet file. + */ + @Nullable + private static ColumnIO lookupColumnByNameOrNull( + GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = groupColumnIO.getChild(columnName); + if (columnIO != null) { + return columnIO; + } + for (int i = 0; i < groupColumnIO.getChildrenCount(); i++) { + if (groupColumnIO.getChild(i).getName().equalsIgnoreCase(columnName)) { + return groupColumnIO.getChild(i); + } + } + return null; + } + + public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO groupColumnIO) { + while (groupColumnIO.getChildrenCount() == 1) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + return groupColumnIO; + } + + public static ColumnIO getArrayElementColumn(ColumnIO columnIO) { + while (columnIO instanceof GroupColumnIO && !columnIO.getType().isRepetition(REPEATED)) { + columnIO = ((GroupColumnIO) columnIO).getChild(0); + } + + // Three-level list: skip the synthetic `element` / `list` wrapper when present. + if (columnIO instanceof GroupColumnIO + && columnIO.getType().getLogicalTypeAnnotation() == null + && ((GroupColumnIO) columnIO).getChildrenCount() == 1 + && !columnIO.getName().equals("array") + && !columnIO.getName().equals(columnIO.getParent().getName() + "_tuple")) { + return ((GroupColumnIO) columnIO).getChild(0); + } + return columnIO; + } + + /** + * Returns the field index with given physical row type {@code groupType} and field name + * {@code fieldName}. * - * @param originalType Original type - * @param fieldType Logical field type - * @return The error message + * @return the physical field index or -1 if the field does not exist + */ + private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { + return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + } + + private static String getPrimitiveTypeCheckFailureMessage( + PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Primitive type: %s. Field type: %s.", + primitiveType, fieldType.getTypeRoot().name()); + } + + private static String getOriginalTypeCheckFailureMessage( + OriginalType originalType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Original type: %s. Field type: %s.", + originalType, fieldType.getTypeRoot().name()); + } + + /** + * Returns a synthetic null-column reader to fill missing top-level fields. Kept as a convenience + * for callers that need to mirror Hudi's original behaviour where a missing column produces an + * explicit null-valued reader rather than being omitted from the batch. */ - private static String getOriginalTypeCheckFailureMessage(OriginalType originalType, LogicalType fieldType) { - return String.format("Unexpected type exception. Original type: %s. Field type: %s.", originalType, fieldType.getTypeRoot().name()); + public static ColumnReader emptyColumnReader() { + return new EmptyColumnReader(); } } diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java new file mode 100644 index 0000000000000..d51d7ee754b8a --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of booleans. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.runtime.util.BooleanArrayList}) because Flink 1.18 does not ship this helper. + */ +public class BooleanArrayList { + private int size; + private boolean[] array; + + public BooleanArrayList(int capacity) { + this.size = 0; + this.array = new boolean[capacity]; + } + + public int size() { + return size; + } + + public boolean add(boolean element) { + grow(size + 1); + array[size++] = element; + return true; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public boolean[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final boolean[] t = new boolean[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java new file mode 100644 index 0000000000000..4787dbb5b9ddb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; +import java.util.NoSuchElementException; + +/** + * Minimal implementation of an array-backed list of ints. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.IntArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class IntArrayList { + + private int size; + private int[] array; + + public IntArrayList(final int capacity) { + this.size = 0; + this.array = new int[capacity]; + } + + public int size() { + return size; + } + + public boolean add(final int number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public int removeLast() { + if (size == 0) { + throw new NoSuchElementException(); + } + --size; + return array[size]; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return size == 0; + } + + private void grow(final int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final int[] t = new int[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } + + public int[] toArray() { + return Arrays.copyOf(array, size); + } + + public static final IntArrayList EMPTY = + new IntArrayList(0) { + + @Override + public boolean add(int number) { + throw new UnsupportedOperationException(); + } + + @Override + public int removeLast() { + throw new UnsupportedOperationException(); + } + }; +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java new file mode 100644 index 0000000000000..a51291f9d8441 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of longs. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.LongArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class LongArrayList { + + private int size; + private long[] array; + + public LongArrayList(int capacity) { + this.size = 0; + this.array = new long[capacity]; + } + + public int size() { + return size; + } + + public boolean add(long number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public long removeLong(int index) { + if (index >= size) { + throw new IndexOutOfBoundsException( + "Index (" + index + ") is greater than or equal to list size (" + size + ")"); + } + final long old = array[index]; + size--; + if (index != size) { + System.arraycopy(array, index + 1, array, index, size - index); + } + return old; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public long[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final long[] t = new long[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java new file mode 100644 index 0000000000000..3f2f8976b69bf --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.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.hudi.table.format.cow.utils; + +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; + +import static java.lang.String.format; + +/** + * Utils to calculate nested type position. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.utils.NestedPositionUtil}). + */ +public class NestedPositionUtil { + + /** + * Calculate row offsets according to column's max repetition level, definition level, value's + * repetition level and definition level. Each row has three situation: + *

  • Row is not defined,because it's optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Row is null + *
  • Row is defined and not empty. + * + * @param field field that contains the row column message include max repetition level and + * definition level. + * @param fieldRepetitionLevels int array with each value's repetition level. + * @param fieldDefinitionLevels int array with each value's definition level. + * @return {@link RowPosition} contains collections row count and isNull array. + */ + public static RowPosition calculateRowOffsets( + ParquetField field, int[] fieldDefinitionLevels, int[] fieldRepetitionLevels) { + int rowDefinitionLevel = field.getDefinitionLevel(); + int rowRepetitionLevel = field.getRepetitionLevel(); + int nullValuesCount = 0; + BooleanArrayList nullRowFlags = new BooleanArrayList(0); + for (int i = 0; i < fieldDefinitionLevels.length; i++) { + // If a row's last field is an array, the repetition levels for the array's items will + // be larger than the parent row's repetition level, so we need to skip those values. + if (fieldRepetitionLevels[i] > rowRepetitionLevel) { + continue; + } + + if (fieldDefinitionLevels[i] >= rowDefinitionLevel) { + // current row is defined and not empty + nullRowFlags.add(false); + } else { + // current row is null + nullRowFlags.add(true); + nullValuesCount++; + } + } + if (nullValuesCount == 0) { + return new RowPosition(null, fieldDefinitionLevels.length); + } + return new RowPosition(nullRowFlags.toArray(), nullRowFlags.size()); + } + + /** + * Calculate the collection's offsets according to column's max repetition level, definition + * level, value's repetition level and definition level. Each collection (Array or Map) has four + * situation: + *
  • Collection is not defined, because optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Collection is null + *
  • Collection is defined but empty + *
  • Collection is defined and not empty. In this case offset value is increased by the number + * of elements in that collection + * + * @param field field that contains array/map column message include max repetition level and + * definition level. + * @param definitionLevels int array with each value's definition level. + * @param repetitionLevels int array with each value's repetition level. + * @return {@link CollectionPosition} contains collections offset array, length array and isNull + * array. + */ + public static CollectionPosition calculateCollectionOffsets( + ParquetField field, int[] definitionLevels, int[] repetitionLevels) { + int collectionDefinitionLevel = field.getDefinitionLevel(); + int collectionRepetitionLevel = field.getRepetitionLevel() + 1; + int offset = 0; + int valueCount = 0; + LongArrayList offsets = new LongArrayList(0); + offsets.add(offset); + BooleanArrayList emptyCollectionFlags = new BooleanArrayList(0); + BooleanArrayList nullCollectionFlags = new BooleanArrayList(0); + int nullValuesCount = 0; + for (int i = 0; + i < definitionLevels.length; + i = getNextCollectionStartIndex(repetitionLevels, collectionRepetitionLevel, i)) { + valueCount++; + if (definitionLevels[i] >= collectionDefinitionLevel - 1) { + boolean isNull = + isOptionalFieldValueNull(definitionLevels[i], collectionDefinitionLevel); + nullCollectionFlags.add(isNull); + nullValuesCount += isNull ? 1 : 0; + // definitionLevels[i] > collectionDefinitionLevel => Collection is defined and not + // empty + // definitionLevels[i] == collectionDefinitionLevel => Collection is defined but + // empty + if (definitionLevels[i] > collectionDefinitionLevel) { + emptyCollectionFlags.add(false); + offset += getCollectionSize(repetitionLevels, collectionRepetitionLevel, i + 1); + } else if (definitionLevels[i] == collectionDefinitionLevel) { + offset++; + emptyCollectionFlags.add(true); + } else { + offset++; + emptyCollectionFlags.add(false); + } + offsets.add(offset); + } else { + // when definitionLevels[i] < collectionDefinitionLevel - 1, it means the collection + // is + // not defined, but we need to regard it as null to avoid getting value wrong. + nullCollectionFlags.add(true); + nullValuesCount++; + offsets.add(++offset); + emptyCollectionFlags.add(false); + } + } + long[] offsetsArray = offsets.toArray(); + long[] length = calculateLengthByOffsets(emptyCollectionFlags.toArray(), offsetsArray); + if (nullValuesCount == 0) { + return new CollectionPosition(null, offsetsArray, length, valueCount); + } + return new CollectionPosition( + nullCollectionFlags.toArray(), offsetsArray, length, valueCount); + } + + public static boolean isOptionalFieldValueNull(int definitionLevel, int maxDefinitionLevel) { + return definitionLevel == maxDefinitionLevel - 1; + } + + public static long[] calculateLengthByOffsets( + boolean[] collectionIsEmpty, long[] arrayOffsets) { + LongArrayList lengthList = new LongArrayList(arrayOffsets.length); + for (int i = 0; i < arrayOffsets.length - 1; i++) { + long offset = arrayOffsets[i]; + long length = arrayOffsets[i + 1] - offset; + if (length < 0) { + throw new IllegalArgumentException( + format( + "Offset is not monotonically ascending. offsets[%s]=%s, offsets[%s]=%s", + i, arrayOffsets[i], i + 1, arrayOffsets[i + 1])); + } + if (collectionIsEmpty[i]) { + length = 0; + } + lengthList.add(length); + } + return lengthList.toArray(); + } + + private static int getNextCollectionStartIndex( + int[] repetitionLevels, int maxRepetitionLevel, int elementIndex) { + do { + elementIndex++; + } while (hasMoreElements(repetitionLevels, elementIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, elementIndex)); + return elementIndex; + } + + /** This method is only called for non-empty collections. */ + private static int getCollectionSize( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + int size = 1; + while (hasMoreElements(repetitionLevels, nextIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, nextIndex)) { + // Collection elements cannot only be primitive, but also can have nested structure + // Counting only elements which belong to current collection, skipping inner elements of + // nested collections/structs + if (repetitionLevels[nextIndex] <= maxRepetitionLevel) { + size++; + } + nextIndex++; + } + return size; + } + + private static boolean isNotCollectionBeginningMarker( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + return repetitionLevels[nextIndex] >= maxRepetitionLevel; + } + + private static boolean hasMoreElements(int[] repetitionLevels, int nextIndex) { + return nextIndex < repetitionLevels.length; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java deleted file mode 100644 index 4c9275f3b0932..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java +++ /dev/null @@ -1,179 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupArrayData implements ArrayData { - - WritableColumnVector vector; - int rowId; - - public ColumnarGroupArrayData(WritableColumnVector vector, int rowId) { - this.vector = vector; - this.rowId = rowId; - } - - @Override - public int size() { - if (vector == null) { - return 0; - } - - if (vector instanceof HeapRowColumnVector) { - // assume all fields have the same size - if (((HeapRowColumnVector) vector).vectors == null || ((HeapRowColumnVector) vector).vectors.length == 0) { - return 0; - } - return ((HeapArrayVector) ((HeapRowColumnVector) vector).vectors[0]).getArray(rowId).size(); - } - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean isNullAt(int index) { - if (vector == null) { - return true; - } - - if (vector instanceof HeapRowColumnVector) { - return ((HeapRowColumnVector) vector).vectors == null; - } - - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean getBoolean(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte getByte(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short getShort(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int getInt(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long getLong(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float getFloat(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double getDouble(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public StringData getString(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public DecimalData getDecimal(int index, int precision, int scale) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public TimestampData getTimestamp(int index, int precision) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RawValueData getRawValue(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] getBinary(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public ArrayData getArray(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public MapData getMap(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RowData getRow(int index, int numFields) { - return new ColumnarGroupRowData((HeapRowColumnVector) vector, rowId, index); - } - - @Override - public boolean[] toBooleanArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] toByteArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short[] toShortArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int[] toIntArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long[] toLongArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float[] toFloatArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double[] toDoubleArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java deleted file mode 100644 index 69cb6feca13e4..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.MapData; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupMapData implements MapData { - - WritableColumnVector keyVector; - WritableColumnVector valueVector; - int rowId; - - public ColumnarGroupMapData(WritableColumnVector keyVector, WritableColumnVector valueVector, int rowId) { - this.keyVector = keyVector; - this.valueVector = valueVector; - this.rowId = rowId; - } - - @Override - public int size() { - if (keyVector == null) { - return 0; - } - - if (keyVector instanceof HeapArrayVector) { - return ((HeapArrayVector) keyVector).getArray(rowId).size(); - } - throw new UnsupportedOperationException(keyVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector"); - } - - @Override - public ArrayData keyArray() { - return ((HeapArrayVector) keyVector).getArray(rowId); - } - - @Override - public ArrayData valueArray() { - if (valueVector instanceof HeapArrayVector) { - return ((HeapArrayVector) valueVector).getArray(rowId); - } else if (valueVector instanceof HeapArrayGroupColumnVector) { - return ((HeapArrayGroupColumnVector) valueVector).getArray(rowId); - } - throw new UnsupportedOperationException(valueVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector, HeapArrayGroupColumnVector"); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java deleted file mode 100644 index 439c1880823f1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.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.hudi.table.format.cow.vector; - -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.RawValueData; -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.RowKind; - -public class ColumnarGroupRowData implements RowData { - - HeapRowColumnVector vector; - int rowId; - int index; - - public ColumnarGroupRowData(HeapRowColumnVector vector, int rowId, int index) { - this.vector = vector; - this.rowId = rowId; - this.index = index; - } - - @Override - public int getArity() { - return vector.vectors.length; - } - - @Override - public RowKind getRowKind() { - return RowKind.INSERT; - } - - @Override - public void setRowKind(RowKind rowKind) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public boolean isNullAt(int pos) { - return - vector.vectors[pos].isNullAt(rowId) - || ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).isNullAt(index); - } - - @Override - public boolean getBoolean(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBoolean(index); - } - - @Override - public byte getByte(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getByte(index); - } - - @Override - public short getShort(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getShort(index); - } - - @Override - public int getInt(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getInt(index); - } - - @Override - public long getLong(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getLong(index); - } - - @Override - public float getFloat(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getFloat(index); - } - - @Override - public double getDouble(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDouble(index); - } - - @Override - public StringData getString(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getString(index); - } - - @Override - public DecimalData getDecimal(int pos, int i1, int i2) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDecimal(index, i1, i2); - } - - @Override - public TimestampData getTimestamp(int pos, int i1) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getTimestamp(index, i1); - } - - @Override - public RawValueData getRawValue(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRawValue(index); - } - - @Override - public byte[] getBinary(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBinary(index); - } - - @Override - public ArrayData getArray(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getArray(index); - } - - @Override - public MapData getMap(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getMap(index); - } - - @Override - public RowData getRow(int pos, int numFields) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRow(index, numFields); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java deleted file mode 100644 index 3d7d8b1f0de0f..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java +++ /dev/null @@ -1,53 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.columnar.vector.ArrayColumnVector; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -/** - * This class represents a nullable heap row column vector. - */ -public class HeapArrayGroupColumnVector extends AbstractHeapVector - implements WritableColumnVector, ArrayColumnVector { - - public WritableColumnVector vector; - - public HeapArrayGroupColumnVector(int len) { - super(len); - } - - public HeapArrayGroupColumnVector(int len, WritableColumnVector vector) { - super(len); - this.vector = vector; - } - - @Override - public ArrayData getArray(int rowId) { - return new ColumnarGroupArrayData(vector, rowId); - } - - @Override - public void reset() { - super.reset(); - vector.reset(); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java index a0dced01e5e8d..2f21a323302f1 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java @@ -57,6 +57,37 @@ public int getLen() { return this.isNull.length; } + // --------------------------------------------------------------------------------------------- + // Flink 2.1-compatible accessors. Backed by the existing public {@code offsets}, {@code lengths} + // and {@code child} fields so legacy callers continue to work; the new {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + // future Flink-2.1-style caller use these accessors. + // --------------------------------------------------------------------------------------------- + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public ColumnVector getChild() { + return child; + } + + public void setChild(ColumnVector child) { + this.child = child; + } + @Override public ArrayData getArray(int i) { long offset = offsets[i]; diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java index 0d83f82baedf3..14aad22039e0a 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java @@ -20,29 +20,97 @@ import lombok.Getter; import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.columnar.ColumnarMapData; +import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.MapColumnVector; import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; /** * This class represents a nullable heap map column vector. + * + *

    Mirrors {@code org.apache.flink.table.data.columnar.vector.heap.HeapMapVector} from + * Flink 2.1 (FLINK-35702). One deliberate divergence from upstream is preserved for backward + * compatibility: the {@code keys} / {@code values} fields are typed + * {@link WritableColumnVector} rather than upstream's {@link ColumnVector}, so the existing + * Lombok-generated {@code getKeys()} / {@code getValues()} accessors keep their original + * signature. Callers wanting the Flink-2.1 contract (a {@code ColumnVector}) use + * {@link #getKeyColumnVector()} / {@link #getValueColumnVector()}. */ public class HeapMapColumnVector extends AbstractHeapVector implements WritableColumnVector, MapColumnVector { @Getter - private final WritableColumnVector keys; + private WritableColumnVector keys; @Getter - private final WritableColumnVector values; + private WritableColumnVector values; + + // --------------------------------------------------------------------------------------------- + // Flink 2.1 Dremel-style state. Populated by {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and + // consumed by {@link #getMap(int)}. + // --------------------------------------------------------------------------------------------- + private long[] offsets; + private long[] lengths; + private int size; public HeapMapColumnVector(int len, WritableColumnVector keys, WritableColumnVector values) { super(len); + this.offsets = new long[len]; + this.lengths = new long[len]; + this.keys = keys; + this.values = values; + } + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public void setKeys(WritableColumnVector keys) { this.keys = keys; + } + + public void setValues(WritableColumnVector values) { this.values = values; } + /** + * Returns the keys child vector typed as {@link ColumnVector}, matching the Flink 2.1 contract + * consumed by {@code NestedColumnReader}. Functionally equivalent to {@link #getKeys()}. + */ + public ColumnVector getKeyColumnVector() { + return keys; + } + + /** Counterpart of {@link #getKeyColumnVector()} for the values child vector. */ + public ColumnVector getValueColumnVector() { + return values; + } + @Override public MapData getMap(int rowId) { - return new ColumnarGroupMapData(keys, values, rowId); + long offset = offsets[rowId]; + long length = lengths[rowId]; + return new ColumnarMapData(keys, values, (int) offset, (int) length); } } diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java index ae194e4e6ab05..0c640ce92ee40 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java @@ -37,6 +37,21 @@ public HeapRowColumnVector(int len, WritableColumnVector... vectors) { this.vectors = vectors; } + /** + * Flink 2.1-compatible accessor for the children vectors. Backed by the existing public {@code + * vectors} field so legacy callers continue to work; the new {@link + * org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + * future Flink-2.1-style caller use this accessor. + */ + public WritableColumnVector[] getFields() { + return vectors; + } + + /** Counterpart of {@link #getFields()}. */ + public void setFields(WritableColumnVector[] fields) { + this.vectors = fields; + } + @Override public ColumnarRowData getRow(int i) { ColumnarRowData columnarRowData = new ColumnarRowData(new VectorizedColumnBatch(vectors)); diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java index 98b5e61050898..a37b88352cf52 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java @@ -18,21 +18,29 @@ package org.apache.hudi.table.format.cow.vector; +import org.apache.flink.formats.parquet.utils.ParquetSchemaConverter; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.columnar.vector.BytesColumnVector; import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.DecimalColumnVector; +import org.apache.flink.table.data.columnar.vector.Dictionary; +import org.apache.flink.table.data.columnar.vector.IntColumnVector; +import org.apache.flink.table.data.columnar.vector.LongColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableBytesVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableIntVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableLongVector; + +import static org.apache.flink.util.Preconditions.checkArgument; /** - * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to - * provide {@link DecimalColumnVector} interface. - * - *

    Reference Flink release 1.11.2 {@link org.apache.flink.formats.parquet.vector.ParquetDecimalVector} - * because it is not public. + * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to provide + * {@link DecimalColumnVector} interface. */ -public class ParquetDecimalVector implements DecimalColumnVector { +public class ParquetDecimalVector + implements DecimalColumnVector, WritableLongVector, WritableIntVector, WritableBytesVector { - public final ColumnVector vector; + private final ColumnVector vector; public ParquetDecimalVector(ColumnVector vector) { this.vector = vector; @@ -40,15 +48,180 @@ public ParquetDecimalVector(ColumnVector vector) { @Override public DecimalData getDecimal(int i, int precision, int scale) { - return DecimalData.fromUnscaledBytes( - ((BytesColumnVector) vector).getBytes(i).getBytes(), - precision, - scale); + if (ParquetSchemaConverter.is32BitDecimal(precision) && vector instanceof IntColumnVector) { + return DecimalData.fromUnscaledLong(((IntColumnVector) vector).getInt(i), precision, scale); + } else if (ParquetSchemaConverter.is64BitDecimal(precision) + && vector instanceof LongColumnVector) { + return DecimalData.fromUnscaledLong(((LongColumnVector) vector).getLong(i), precision, scale); + } else { + checkArgument( + vector instanceof BytesColumnVector, + "Reading decimal type occur unsupported vector type: %s", + vector.getClass()); + return DecimalData.fromUnscaledBytes( + ((BytesColumnVector) vector).getBytes(i).getBytes(), precision, scale); + } + } + + public ColumnVector getVector() { + return vector; } @Override public boolean isNullAt(int i) { return vector.isNullAt(i); } -} + @Override + public void reset() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).reset(); + } + } + + @Override + public void setNullAt(int rowId) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNullAt(rowId); + } + } + + @Override + public void setNulls(int rowId, int count) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNulls(rowId, count); + } + } + + @Override + public void fillWithNulls() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).fillWithNulls(); + } + } + + @Override + public void setDictionary(Dictionary dictionary) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setDictionary(dictionary); + } + } + + @Override + public boolean hasDictionary() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).hasDictionary(); + } + return false; + } + + @Override + public WritableIntVector reserveDictionaryIds(int capacity) { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).reserveDictionaryIds(capacity); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public WritableIntVector getDictionaryIds() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).getDictionaryIds(); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public Bytes getBytes(int i) { + if (vector instanceof WritableBytesVector) { + return ((WritableBytesVector) vector).getBytes(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void appendBytes(int rowId, byte[] value, int offset, int length) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).appendBytes(rowId, value, offset, length); + } + } + + @Override + public void fill(byte[] value) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).fill(value); + } + } + + @Override + public int getInt(int i) { + if (vector instanceof WritableIntVector) { + return ((WritableIntVector) vector).getInt(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setInt(int rowId, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInt(rowId, value); + } + } + + @Override + public void setIntsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setIntsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void setInts(int rowId, int count, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, value); + } + } + + @Override + public void setInts(int rowId, int count, int[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).fill(value); + } + } + + @Override + public long getLong(int i) { + if (vector instanceof WritableLongVector) { + return ((WritableLongVector) vector).getLong(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setLong(int rowId, long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLong(rowId, value); + } + } + + @Override + public void setLongsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLongsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).fill(value); + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java new file mode 100644 index 0000000000000..fcdedfbc9d71d --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent collection's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.CollectionPosition}). + */ +public class CollectionPosition { + @Nullable private final boolean[] isNull; + private final long[] offsets; + private final long[] length; + private final int valueCount; + + public CollectionPosition(boolean[] isNull, long[] offsets, long[] length, int valueCount) { + this.isNull = isNull; + this.offsets = offsets; + this.length = length; + this.valueCount = valueCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public long[] getOffsets() { + return offsets; + } + + public long[] getLength() { + return length; + } + + public int getValueCount() { + return valueCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java new file mode 100644 index 0000000000000..fe95419ac3218 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.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.hudi.table.format.cow.vector.position; + +/** + * To delegate repetition level and definition level. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.LevelDelegation}). + */ +public class LevelDelegation { + private final int[] repetitionLevel; + private final int[] definitionLevel; + + public LevelDelegation(int[] repetitionLevel, int[] definitionLevel) { + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + } + + public int[] getRepetitionLevel() { + return repetitionLevel; + } + + public int[] getDefinitionLevel() { + return definitionLevel; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java new file mode 100644 index 0000000000000..5438b67973238 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent struct's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.RowPosition}). + */ +public class RowPosition { + @Nullable private final boolean[] isNull; + private final int positionsCount; + + public RowPosition(boolean[] isNull, int positionsCount) { + this.isNull = isNull; + this.positionsCount = positionsCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public int getPositionsCount() { + return positionsCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java deleted file mode 100644 index 6a8a01b74946a..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java +++ /dev/null @@ -1,473 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayVector; -import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.TimestampData; -import org.apache.flink.table.data.columnar.vector.VectorizedColumnBatch; -import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.table.types.logical.ArrayType; -import org.apache.flink.table.types.logical.LogicalType; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Array {@link ColumnReader}. - */ -public class ArrayColumnReader extends BaseVectorizedColumnReader { - - // The value read in last time - private Object lastValue; - - // flag to indicate if there is no data in parquet data page - private boolean eof = false; - - // flag to indicate if it's the first time to read parquet data page with this instance - boolean isFirstRow = true; - - public ArrayColumnReader( - ColumnDescriptor descriptor, - PageReader pageReader, - boolean isUtcTimestamp, - Type type, - LogicalType logicalType) - throws IOException { - super(descriptor, pageReader, isUtcTimestamp, type, logicalType); - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayVector lcv = (HeapArrayVector) vector; - // before readBatch, initial the size of offsets & lengths as the default value, - // the actual size will be assigned in setChildrenInfo() after reading complete. - lcv.offsets = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - lcv.lengths = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - // Because the length of ListColumnVector.child can't be known now, - // the valueList will save all data for ListColumnVector temporary. - List valueList = new ArrayList<>(); - - LogicalType category = ((ArrayType) logicalType).getElementType(); - - // read the first row in parquet data page, this will be only happened once for this - // instance - if (isFirstRow) { - if (!fetchNextValue(category)) { - return; - } - isFirstRow = false; - } - - int index = collectDataFromParquetPage(readNumber, lcv, valueList, category); - - // Convert valueList to array for the ListColumnVector.child - fillColumnVector(category, lcv, valueList, index); - } - - /** - * Reads a single value from parquet page, puts it into lastValue. Returns a boolean indicating - * if there is more values to read (true). - * - * @param category - * @return boolean - * @throws IOException - */ - private boolean fetchNextValue(LogicalType category) throws IOException { - int left = readPageIfNeed(); - if (left > 0) { - // get the values of repetition and definitionLevel - readRepetitionAndDefinitionLevels(); - // read the data if it isn't null - if (definitionLevel == maxDefLevel) { - if (isCurrentPageDictionaryEncoded) { - lastValue = dataColumn.readValueDictionaryId(); - } else { - lastValue = readPrimitiveTypedRow(category); - } - } else { - lastValue = null; - } - return true; - } else { - eof = true; - return false; - } - } - - private int readPageIfNeed() throws IOException { - // Compute the number of values we want to read in this page. - int leftInPage = (int) (endOfPageValueCount - valuesRead); - if (leftInPage == 0) { - // no data left in current page, load data from new page - readPage(); - leftInPage = (int) (endOfPageValueCount - valuesRead); - } - return leftInPage; - } - - // Need to be in consistent with that VectorizedPrimitiveColumnReader#readBatchHelper - // TODO Reduce the duplicated code - private Object readPrimitiveTypedRow(LogicalType category) { - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dataColumn.readString(); - case BOOLEAN: - return dataColumn.readBoolean(); - case TIME_WITHOUT_TIME_ZONE: - case DATE: - case INTEGER: - return dataColumn.readInteger(); - case TINYINT: - return dataColumn.readTinyInt(); - case SMALLINT: - return dataColumn.readSmallInt(); - case BIGINT: - return dataColumn.readLong(); - case FLOAT: - return dataColumn.readFloat(); - case DOUBLE: - return dataColumn.readDouble(); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dataColumn.readInteger(); - case INT64: - return dataColumn.readLong(); - case BINARY: - case FIXED_LEN_BYTE_ARRAY: - return dataColumn.readString(); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dataColumn.readTimestamp(); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { - if (dictionaryValue == null) { - return null; - } - - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dictionary.readString(dictionaryValue); - case DATE: - case TIME_WITHOUT_TIME_ZONE: - case INTEGER: - return dictionary.readInteger(dictionaryValue); - case BOOLEAN: - return dictionary.readBoolean(dictionaryValue) ? 1 : 0; - case DOUBLE: - return dictionary.readDouble(dictionaryValue); - case FLOAT: - return dictionary.readFloat(dictionaryValue); - case TINYINT: - return dictionary.readTinyInt(dictionaryValue); - case SMALLINT: - return dictionary.readSmallInt(dictionaryValue); - case BIGINT: - return dictionary.readLong(dictionaryValue); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dictionary.readInteger(dictionaryValue); - case INT64: - return dictionary.readLong(dictionaryValue); - case FIXED_LEN_BYTE_ARRAY: - case BINARY: - return dictionary.readString(dictionaryValue); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dictionary.readTimestamp(dictionaryValue); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - /** - * Collects data from a parquet page and returns the final row index where it stopped. The - * returned index can be equal to or less than total. - * - * @param total maximum number of rows to collect - * @param lcv column vector to do initial setup in data collection time - * @param valueList collection of values that will be fed into the vector later - * @param category - * @return int - * @throws IOException - */ - private int collectDataFromParquetPage( - int total, HeapArrayVector lcv, List valueList, LogicalType category) - throws IOException { - int index = 0; - /* - * Here is a nested loop for collecting all values from a parquet page. - * A column of array type can be considered as a list of lists, so the two loops are as below: - * 1. The outer loop iterates on rows (index is a row index, so points to a row in the batch), e.g.: - * [0, 2, 3] <- index: 0 - * [NULL, 3, 4] <- index: 1 - * - * 2. The inner loop iterates on values within a row (sets all data from parquet data page - * for an element in ListColumnVector), so fetchNextValue returns values one-by-one: - * 0, 2, 3, NULL, 3, 4 - * - * As described below, the repetition level (repetitionLevel != 0) - * can be used to decide when we'll start to read values for the next list. - */ - while (!eof && index < total) { - // add element to ListColumnVector one by one - lcv.offsets[index] = valueList.size(); - /* - * Let's collect all values for a single list. - * Repetition level = 0 means that a new list started there in the parquet page, - * in that case, let's exit from the loop, and start to collect value for a new list. - */ - do { - /* - * Definition level = 0 when a NULL value was returned instead of a list - * (this is not the same as a NULL value in of a list). - */ - if (definitionLevel == 0) { - lcv.setNullAt(index); - } - valueList.add( - isCurrentPageDictionaryEncoded - ? dictionaryDecodeValue(category, (Integer) lastValue) - : lastValue); - } while (fetchNextValue(category) && (repetitionLevel != 0)); - - lcv.lengths[index] = valueList.size() - lcv.offsets[index]; - index++; - } - return index; - } - - /** - * The lengths & offsets will be initialized as default size (1024), it should be set to the - * actual size according to the element number. - */ - private void setChildrenInfo(HeapArrayVector lcv, int itemNum, int elementNum) { - lcv.setSize(itemNum); - long[] lcvLength = new long[elementNum]; - long[] lcvOffset = new long[elementNum]; - System.arraycopy(lcv.lengths, 0, lcvLength, 0, elementNum); - System.arraycopy(lcv.offsets, 0, lcvOffset, 0, elementNum); - lcv.lengths = lcvLength; - lcv.offsets = lcvOffset; - } - - private void fillColumnVector( - LogicalType category, HeapArrayVector lcv, List valueList, int elementNum) { - int total = valueList.size(); - setChildrenInfo(lcv, total, elementNum); - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - lcv.child = new HeapBytesVector(total); - ((HeapBytesVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (src == null) { - ((HeapBytesVector) lcv.child).setNullAt(i); - } else { - ((HeapBytesVector) lcv.child).appendBytes(i, src, 0, src.length); - } - } - break; - case BOOLEAN: - lcv.child = new HeapBooleanVector(total); - ((HeapBooleanVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapBooleanVector) lcv.child).setNullAt(i); - } else { - ((HeapBooleanVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TINYINT: - lcv.child = new HeapByteVector(total); - ((HeapByteVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapByteVector) lcv.child).setNullAt(i); - } else { - ((HeapByteVector) lcv.child).vector[i] = - (byte) ((List) valueList).get(i).intValue(); - } - } - break; - case SMALLINT: - lcv.child = new HeapShortVector(total); - ((HeapShortVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapShortVector) lcv.child).setNullAt(i); - } else { - ((HeapShortVector) lcv.child).vector[i] = - (short) ((List) valueList).get(i).intValue(); - } - } - break; - case INTEGER: - case DATE: - case TIME_WITHOUT_TIME_ZONE: - lcv.child = new HeapIntVector(total); - ((HeapIntVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) lcv.child).setNullAt(i); - } else { - ((HeapIntVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case FLOAT: - lcv.child = new HeapFloatVector(total); - ((HeapFloatVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapFloatVector) lcv.child).setNullAt(i); - } else { - ((HeapFloatVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case BIGINT: - lcv.child = new HeapLongVector(total); - ((HeapLongVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) lcv.child).setNullAt(i); - } else { - ((HeapLongVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case DOUBLE: - lcv.child = new HeapDoubleVector(total); - ((HeapDoubleVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapDoubleVector) lcv.child).setNullAt(i); - } else { - ((HeapDoubleVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - lcv.child = new HeapTimestampVector(total); - ((HeapTimestampVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapTimestampVector) lcv.child).setNullAt(i); - } else { - ((HeapTimestampVector) lcv.child) - .setTimestamp(i, ((List) valueList).get(i)); - } - } - break; - case DECIMAL: - PrimitiveType.PrimitiveTypeName primitiveTypeName = - descriptor.getPrimitiveType().getPrimitiveTypeName(); - switch (primitiveTypeName) { - case INT32: - lcv.child = new ParquetDecimalVector(new HeapIntVector(total)); - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - case INT64: - lcv.child = new ParquetDecimalVector(new HeapLongVector(total)); - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - default: - lcv.child = new ParquetDecimalVector(new HeapBytesVector(total)); - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (valueList.get(i) == null) { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .appendBytes(i, src, 0, src.length); - } - } - break; - } - break; - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java deleted file mode 100644 index df7c5d85bc4ab..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java +++ /dev/null @@ -1,45 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Array of a Group type (Array, Map, Row, etc.) {@link ColumnReader}. - */ -public class ArrayGroupReader implements ColumnReader { - - private final ColumnReader fieldReader; - - public ArrayGroupReader(ColumnReader fieldReader) { - this.fieldReader = fieldReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayGroupColumnVector rowColumnVector = (HeapArrayGroupColumnVector) vector; - - fieldReader.readToVector(readNumber, rowColumnVector.vector); - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java index 7c9fd994a0c25..700d7505fbc73 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java @@ -226,12 +226,7 @@ private void readPageV2(DataPageV2 page) { this.definitionLevelColumn = newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); try { - log.debug( - "page data size " - + page.getData().size() - + " bytes and " - + pageValueCount - + " records"); + log.debug("page data size {} bytes and {} records", page.getData().size(), pageValueCount); initDataReader( page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); } catch (IOException e) { diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java deleted file mode 100644 index 6d743530fccc7..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java +++ /dev/null @@ -1,55 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Map {@link ColumnReader}. - */ -public class MapColumnReader implements ColumnReader { - - private final ArrayColumnReader keyReader; - private final ColumnReader valueReader; - - public MapColumnReader( - ArrayColumnReader keyReader, ColumnReader valueReader) { - this.keyReader = keyReader; - this.valueReader = valueReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapMapColumnVector mapColumnVector = (HeapMapColumnVector) vector; - AbstractHeapVector keyArrayColumnVector = (AbstractHeapVector) (mapColumnVector.getKeys()); - keyReader.readToVector(readNumber, mapColumnVector.getKeys()); - valueReader.readToVector(readNumber, mapColumnVector.getValues()); - for (int i = 0; i < keyArrayColumnVector.getLen(); i++) { - if (keyArrayColumnVector.isNullAt(i)) { - mapColumnVector.setNullAt(i); - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java new file mode 100644 index 0000000000000..60575f148cc45 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.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.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.NestedPositionUtil; +import org.apache.hudi.table.format.cow.vector.HeapArrayVector; +import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; +import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; + +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.ArrayType; +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.util.FlinkRuntimeException; +import org.apache.flink.util.Preconditions; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.page.PageReadStore; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ColumnReader used to read a {@code Group} type in Parquet ({@code Map}, {@code Array}, {@code + * Row}). Resolves nested structures using Dremel striping/assembly; see the + * striping and assembly algorithms from the Dremel paper. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedColumnReader}). Differences vs. upstream: + * + *

      + *
    • Uses Hudi-local {@code HeapRowColumnVector}/{@code HeapMapColumnVector}/{@code + * HeapArrayVector} instead of the Flink-private {@code HeapRowVector}/{@code + * HeapMapVector}/{@code HeapArrayVector}. + *
    • Supports Hudi's schema-evolution contract: a {@code ParquetGroupField} representing a + * {@link RowType} may contain {@code null} children — meaning the corresponding logical + * field is absent from the Parquet file. Those slots are passed through unchanged and do + * not contribute to the row's repetition/definition-level stream. + *
    + */ +public class NestedColumnReader implements ColumnReader { + + private final Map columnReaders; + private final boolean isUtcTimestamp; + + private final PageReadStore pages; + + private final ParquetField field; + + public NestedColumnReader(boolean isUtcTimestamp, PageReadStore pages, ParquetField field) { + this.isUtcTimestamp = isUtcTimestamp; + this.pages = pages; + this.field = field; + this.columnReaders = new HashMap<>(); + } + + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + readData(field, readNumber, vector, false); + } + + private Tuple2 readData( + ParquetField field, int readNumber, ColumnVector vector, boolean inside) throws IOException { + if (field.getType() instanceof RowType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof MapType || field.getType() instanceof MultisetType) { + return readMap((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof ArrayType) { + return readArray((ParquetGroupField) field, readNumber, vector, inside); + } else { + return readPrimitive((ParquetPrimitiveField) field, readNumber, vector); + } + } + + private Tuple2 readRow( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapRowColumnVector heapRowVector = (HeapRowColumnVector) vector; + LevelDelegation levelDelegation = null; + List children = field.getChildren(); + WritableColumnVector[] childrenVectors = heapRowVector.getFields(); + WritableColumnVector[] finalChildrenVectors = new WritableColumnVector[childrenVectors.length]; + for (int i = 0; i < children.size(); i++) { + ParquetField child = children.get(i); + if (child == null) { + // Schema-evolution: the logical field is not present in the Parquet file. The slot + // vector was pre-populated with nulls by ParquetSplitReaderUtil#createWritableColumnVector + // (ROW branch), but HeapRowColumnVector#reset() (invoked once per batch by + // ParquetColumnarRowSplitReader#nextBatch) cascades to the children and clears those null + // flags. Since an absent field is never re-read, re-apply the nulls here so the column stays + // NULL instead of reverting to the type's zero value. Skip contributing to the level stream. + childrenVectors[i].fillWithNulls(); + finalChildrenVectors[i] = childrenVectors[i]; + continue; + } + Tuple2 tuple = + readData(child, readNumber, childrenVectors[i], true); + levelDelegation = tuple.f0; + finalChildrenVectors[i] = tuple.f1; + } + if (levelDelegation == null) { + throw new FlinkRuntimeException( + String.format("Row field does not have any non-null children: %s.", field)); + } + + RowPosition rowPosition = + NestedPositionUtil.calculateRowOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If row was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + heapRowVector = new HeapRowColumnVector(rowPosition.getPositionsCount(), finalChildrenVectors); + } else { + heapRowVector.setFields(finalChildrenVectors); + } + + if (rowPosition.getIsNull() != null) { + setFieldNullFlag(rowPosition.getIsNull(), heapRowVector); + } + + // Hudi-specific: collapse a present row whose every child is null into a null row, so that a + // SQL value like `row(null, null)` round-trips to NULL on read. This was the behaviour of the + // legacy RowColumnReader (deleted alongside the Dremel rewire) and existing Hudi tables rely + // on it. Diverges from Flink 2.1, which would surface it as Row(null, null). Pinned by the + // integration test ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes. + // positionsCount comes from the Dremel definition/repetition level stream + // (NestedPositionUtil#calculateRowOffsets). On a full, non-final batch that stream carries a + // one-record lookahead (NestedPrimitiveColumnReader#readAndNewVector reads one value past the + // batch in its do/while, and #getLevelDelegation keeps that trailing level for the next batch), + // so positionsCount can be one larger than the materialized vector lengths. When inside==true + // the row vector is renewed to positionsCount but its children are sized to their value count; + // when inside==false the row vector keeps its batch capacity. Either way, iterating all the way + // to positionsCount can read one element past a shorter vector and throw + // ArrayIndexOutOfBoundsException. Clamp to the shortest vector this loop indexes -- the phantom + // trailing position is never surfaced downstream (ParquetColumnarRowSplitReader caps the batch + // at num). + int rowCount = Math.min(rowPosition.getPositionsCount(), heapRowVector.getLen()); + for (WritableColumnVector child : finalChildrenVectors) { + rowCount = Math.min(rowCount, vectorLength(child)); + } + for (int j = 0; j < rowCount; j++) { + if (heapRowVector.isNullAt(j)) { + continue; + } + boolean allChildrenNull = true; + for (WritableColumnVector child : finalChildrenVectors) { + if (!child.isNullAt(j)) { + allChildrenNull = false; + break; + } + } + if (allChildrenNull) { + heapRowVector.setNullAt(j); + } + } + return Tuple2.of(levelDelegation, heapRowVector); + } + + private Tuple2 readMap( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapMapColumnVector mapVector = (HeapMapColumnVector) vector; + mapVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 2, + "Maps must have two type parameters, found %s", + children.size()); + Tuple2 keyTuple = + readData(children.get(0), readNumber, mapVector.getKeyColumnVector(), true); + Tuple2 valueTuple = + readData(children.get(1), readNumber, mapVector.getValueColumnVector(), true); + + LevelDelegation levelDelegation = keyTuple.f0; + + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If map was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + mapVector = new HeapMapColumnVector(collectionPosition.getValueCount(), keyTuple.f1, valueTuple.f1); + } else { + mapVector.setKeys(keyTuple.f1); + mapVector.setValues(valueTuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), mapVector); + } + + mapVector.setLengths(collectionPosition.getLength()); + mapVector.setOffsets(collectionPosition.getOffsets()); + + return Tuple2.of(levelDelegation, mapVector); + } + + private Tuple2 readArray( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapArrayVector arrayVector = (HeapArrayVector) vector; + arrayVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 1, + "Arrays must have a single type parameter, found %s", + children.size()); + Tuple2 tuple = + readData(children.get(0), readNumber, arrayVector.getChild(), true); + + LevelDelegation levelDelegation = tuple.f0; + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If array was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + arrayVector = new HeapArrayVector(collectionPosition.getValueCount(), tuple.f1); + } else { + arrayVector.setChild(tuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), arrayVector); + } + arrayVector.setLengths(collectionPosition.getLength()); + arrayVector.setOffsets(collectionPosition.getOffsets()); + return Tuple2.of(levelDelegation, arrayVector); + } + + private Tuple2 readPrimitive( + ParquetPrimitiveField field, int readNumber, ColumnVector vector) throws IOException { + ColumnDescriptor descriptor = field.getDescriptor(); + NestedPrimitiveColumnReader reader = columnReaders.get(descriptor); + if (reader == null) { + reader = + new NestedPrimitiveColumnReader( + descriptor, + pages.getPageReader(descriptor), + isUtcTimestamp, + descriptor.getPrimitiveType(), + field.getType()); + columnReaders.put(descriptor, reader); + } + WritableColumnVector writableColumnVector = + reader.readAndNewVector(readNumber, (WritableColumnVector) vector); + return Tuple2.of(reader.getLevelDelegation(), writableColumnVector); + } + + /** + * The length of the {@code isNull}-backed storage that {@code vector} (a row child) is indexed + * against by the null-collapse loop in {@link #readRow}. Every row child is an {@link + * AbstractHeapVector} (nested rows/arrays/maps and all non-decimal primitives) or a {@link + * ParquetDecimalVector} wrapping one (DECIMAL leaves; see {@code + * NestedPrimitiveColumnReader#fillColumnVector}); unwrapping the latter yields an {@code + * AbstractHeapVector} in all cases. + */ + private static int vectorLength(ColumnVector vector) { + ColumnVector storage = + vector instanceof ParquetDecimalVector + ? ((ParquetDecimalVector) vector).getVector() + : vector; + return ((AbstractHeapVector) storage).getLen(); + } + + private static void setFieldNullFlag(boolean[] nullFlags, AbstractHeapVector vector) { + for (int index = 0; index < vector.getLen() && index < nullFlags.length; index++) { + if (nullFlags[index]) { + vector.setNullAt(index); + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java new file mode 100644 index 0000000000000..a18520c3b5cd5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java @@ -0,0 +1,638 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.IntArrayList; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; + +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +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.DictionaryPage; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.parquet.column.ValuesType.DEFINITION_LEVEL; +import static org.apache.parquet.column.ValuesType.REPETITION_LEVEL; +import static org.apache.parquet.column.ValuesType.VALUES; + +/** + * Reader to read a single primitive leaf column that participates in a nested (Dremel) structure. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedPrimitiveColumnReader}). Only the package + * and the Hudi-local {@link ParquetDecimalVector} / {@link LevelDelegation} / {@link IntArrayList} + * imports are changed; the algorithm is untouched. The companion Hudi-specific {@code + * Int64TimestampColumnReader} / {@code FixedLenBytesColumnReader} behaviours stay at the leaf- + * reader creation boundary in {@code ParquetSplitReaderUtil}, not inside this class — keeping it + * a faithful copy of upstream. + */ +public class NestedPrimitiveColumnReader implements ColumnReader { + private static final Logger LOG = LoggerFactory.getLogger(NestedPrimitiveColumnReader.class); + + private final IntArrayList repetitionLevelList = new IntArrayList(0); + private final IntArrayList definitionLevelList = new IntArrayList(0); + + private final PageReader pageReader; + private final ColumnDescriptor descriptor; + private final Type type; + private final LogicalType logicalType; + + /** The dictionary, if this column has dictionary encoding. */ + private final ParquetDataColumnReader dictionary; + + /** Maximum definition level for this column. */ + private final int maxDefLevel; + + private boolean isUtcTimestamp; + + /** Total number of values read. */ + private long valuesRead; + + /** + * value that indicates the end of the current page. That is, if valuesRead == + * endOfPageValueCount, we are at the end of the page. + */ + private long endOfPageValueCount; + + /** If true, the current page is dictionary encoded. */ + private boolean isCurrentPageDictionaryEncoded; + + private int definitionLevel; + private int repetitionLevel; + + /** Repetition/Definition/Value readers. */ + private IntIterator repetitionLevelColumn; + + private IntIterator definitionLevelColumn; + private ParquetDataColumnReader dataColumn; + + /** Total values in the current page. */ + private int pageValueCount; + + // flag to indicate if there is no data in parquet data page + private boolean eof = false; + + private boolean isFirstRow = true; + + private Object lastValue; + + public NestedPrimitiveColumnReader( + ColumnDescriptor descriptor, + PageReader pageReader, + boolean isUtcTimestamp, + Type parquetType, + LogicalType logicalType) + throws IOException { + this.descriptor = descriptor; + this.type = parquetType; + this.pageReader = pageReader; + this.maxDefLevel = descriptor.getMaxDefinitionLevel(); + this.isUtcTimestamp = isUtcTimestamp; + this.logicalType = logicalType; + + DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); + if (dictionaryPage != null) { + try { + this.dictionary = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + parquetType.asPrimitiveType(), + dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } catch (IOException e) { + throw new IOException( + String.format("Could not decode the dictionary for %s", descriptor), e); + } + } else { + this.dictionary = null; + this.isCurrentPageDictionaryEncoded = false; + } + } + + // Not invoked directly; callers use readAndNewVector instead. + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + throw new UnsupportedOperationException("This function should not be called."); + } + + public WritableColumnVector readAndNewVector(int readNumber, WritableColumnVector vector) + throws IOException { + if (isFirstRow) { + if (!readValue()) { + return vector; + } + isFirstRow = false; + } + + // index to set value. + int index = 0; + int valueIndex = 0; + List valueList = new ArrayList<>(); + + // repeated type need two loops to read data. + while (!eof && index < readNumber) { + do { + valueList.add(lastValue); + valueIndex++; + } while (readValue() && (repetitionLevel != 0)); + index++; + } + + return fillColumnVector(valueIndex, valueList); + } + + public LevelDelegation getLevelDelegation() { + int[] repetition = repetitionLevelList.toArray(); + int[] definition = definitionLevelList.toArray(); + repetitionLevelList.clear(); + definitionLevelList.clear(); + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + return new LevelDelegation(repetition, definition); + } + + private boolean readValue() throws IOException { + int left = readPageIfNeed(); + if (left > 0) { + // get the values of repetition and definitionLevel + readAndSaveRepetitionAndDefinitionLevels(); + // read the data if it isn't null + if (definitionLevel == maxDefLevel) { + if (isCurrentPageDictionaryEncoded) { + int dictionaryId = dataColumn.readValueDictionaryId(); + lastValue = dictionaryDecodeValue(logicalType, dictionaryId); + } else { + lastValue = readPrimitiveTypedRow(logicalType); + } + } else { + lastValue = null; + } + return true; + } else { + eof = true; + return false; + } + } + + private void readAndSaveRepetitionAndDefinitionLevels() { + // get the values of repetition and definitionLevel + repetitionLevel = repetitionLevelColumn.nextInt(); + definitionLevel = definitionLevelColumn.nextInt(); + valuesRead++; + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + } + + private int readPageIfNeed() throws IOException { + // Compute the number of values we want to read in this page. + int leftInPage = (int) (endOfPageValueCount - valuesRead); + if (leftInPage == 0) { + // no data left in current page, load data from new page + readPage(); + leftInPage = (int) (endOfPageValueCount - valuesRead); + } + return leftInPage; + } + + private Object readPrimitiveTypedRow(LogicalType category) { + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dataColumn.readBytes(); + case BOOLEAN: + return dataColumn.readBoolean(); + case TIME_WITHOUT_TIME_ZONE: + case DATE: + case INTEGER: + return dataColumn.readInteger(); + case TINYINT: + return dataColumn.readTinyInt(); + case SMALLINT: + return dataColumn.readSmallInt(); + case BIGINT: + return dataColumn.readLong(); + case FLOAT: + return dataColumn.readFloat(); + case DOUBLE: + return dataColumn.readDouble(); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dataColumn.readInteger(); + case INT64: + return dataColumn.readLong(); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return dataColumn.readBytes(); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dataColumn.readTimestamp(); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { + if (dictionaryValue == null) { + return null; + } + + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dictionary.readBytes(dictionaryValue); + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTEGER: + return dictionary.readInteger(dictionaryValue); + case BOOLEAN: + return dictionary.readBoolean(dictionaryValue) ? 1 : 0; + case DOUBLE: + return dictionary.readDouble(dictionaryValue); + case FLOAT: + return dictionary.readFloat(dictionaryValue); + case TINYINT: + return dictionary.readTinyInt(dictionaryValue); + case SMALLINT: + return dictionary.readSmallInt(dictionaryValue); + case BIGINT: + return dictionary.readLong(dictionaryValue); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dictionary.readInteger(dictionaryValue); + case INT64: + return dictionary.readLong(dictionaryValue); + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return dictionary.readBytes(dictionaryValue); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dictionary.readTimestamp(dictionaryValue); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WritableColumnVector fillColumnVector(int total, List valueList) { + switch (logicalType.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + HeapBytesVector heapBytesVector = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (src == null) { + heapBytesVector.setNullAt(i); + } else { + heapBytesVector.appendBytes(i, src, 0, src.length); + } + } + return heapBytesVector; + case BOOLEAN: + HeapBooleanVector heapBooleanVector = new HeapBooleanVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapBooleanVector.setNullAt(i); + } else { + heapBooleanVector.vector[i] = ((List) valueList).get(i); + } + } + return heapBooleanVector; + case TINYINT: + HeapByteVector heapByteVector = new HeapByteVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapByteVector.setNullAt(i); + } else { + heapByteVector.vector[i] = (byte) ((List) valueList).get(i).intValue(); + } + } + return heapByteVector; + case SMALLINT: + HeapShortVector heapShortVector = new HeapShortVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapShortVector.setNullAt(i); + } else { + heapShortVector.vector[i] = (short) ((List) valueList).get(i).intValue(); + } + } + return heapShortVector; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + HeapIntVector heapIntVector = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapIntVector.setNullAt(i); + } else { + heapIntVector.vector[i] = ((List) valueList).get(i); + } + } + return heapIntVector; + case FLOAT: + HeapFloatVector heapFloatVector = new HeapFloatVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapFloatVector.setNullAt(i); + } else { + heapFloatVector.vector[i] = ((List) valueList).get(i); + } + } + return heapFloatVector; + case BIGINT: + HeapLongVector heapLongVector = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapLongVector.setNullAt(i); + } else { + heapLongVector.vector[i] = ((List) valueList).get(i); + } + } + return heapLongVector; + case DOUBLE: + HeapDoubleVector heapDoubleVector = new HeapDoubleVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapDoubleVector.setNullAt(i); + } else { + heapDoubleVector.vector[i] = ((List) valueList).get(i); + } + } + return heapDoubleVector; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + HeapTimestampVector heapTimestampVector = new HeapTimestampVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapTimestampVector.setNullAt(i); + } else { + heapTimestampVector.setTimestamp(i, ((List) valueList).get(i)); + } + } + return heapTimestampVector; + case DECIMAL: + PrimitiveType.PrimitiveTypeName primitiveTypeName = + descriptor.getPrimitiveType().getPrimitiveTypeName(); + switch (primitiveTypeName) { + case INT32: + HeapIntVector phiv = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phiv.setNullAt(i); + } else { + phiv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phiv); + case INT64: + HeapLongVector phlv = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phlv.setNullAt(i); + } else { + phlv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phlv); + default: + HeapBytesVector phbv = getHeapBytesVector(total, valueList); + return new ParquetDecimalVector(phbv); + } + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HeapBytesVector getHeapBytesVector(int total, List valueList) { + HeapBytesVector phbv = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (valueList.get(i) == null) { + phbv.setNullAt(i); + } else { + phbv.appendBytes(i, src, 0, src.length); + } + } + return phbv; + } + + protected void readPage() { + DataPage page = pageReader.readPage(); + + if (page == null) { + return; + } + + page.accept( + new DataPage.Visitor() { + @Override + public Void visit(DataPageV1 dataPageV1) { + readPageV1(dataPageV1); + return null; + } + + @Override + public Void visit(DataPageV2 dataPageV2) { + readPageV2(dataPageV2); + return null; + } + }); + } + + private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int valueCount) + throws IOException { + this.pageValueCount = valueCount; + this.endOfPageValueCount = valuesRead + pageValueCount; + if (dataEncoding.usesDictionary()) { + this.dataColumn = null; + if (dictionary == null) { + throw new IOException( + String.format( + "Could not read page in col %s because the dictionary was missing for encoding %s.", + descriptor, dataEncoding)); + } + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getDictionaryBasedValuesReader( + descriptor, VALUES, dictionary.getDictionary()), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } else { + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getValuesReader(descriptor, VALUES), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = false; + } + + try { + dataColumn.initFromPage(pageValueCount, in); + } catch (IOException e) { + throw new IOException(String.format("Could not read page in col %s.", descriptor), e); + } + } + + private void readPageV1(DataPageV1 page) { + ValuesReader rlReader = page.getRlEncoding().getValuesReader(descriptor, REPETITION_LEVEL); + ValuesReader dlReader = page.getDlEncoding().getValuesReader(descriptor, DEFINITION_LEVEL); + this.repetitionLevelColumn = new ValuesReaderIntIterator(rlReader); + this.definitionLevelColumn = new ValuesReaderIntIterator(dlReader); + try { + BytesInput bytes = page.getBytes(); + LOG.debug("Page size {} bytes and {} records.", bytes.size(), pageValueCount); + ByteBufferInputStream in = bytes.toInputStream(); + LOG.debug("Reading repetition levels at {}.", in.position()); + rlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading definition levels at {}.", in.position()); + dlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading data at {}.", in.position()); + initDataReader(page.getValueEncoding(), in, page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private void readPageV2(DataPageV2 page) { + this.pageValueCount = page.getValueCount(); + this.repetitionLevelColumn = + newRLEIterator(descriptor.getMaxRepetitionLevel(), page.getRepetitionLevels()); + this.definitionLevelColumn = + newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); + try { + LOG.debug( + "Page data size {} bytes and {} records.", page.getData().size(), pageValueCount); + initDataReader( + page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private IntIterator newRLEIterator(int maxLevel, BytesInput bytes) { + try { + if (maxLevel == 0) { + return new NullIntIterator(); + } + return new RLEIntIterator( + new RunLengthBitPackingHybridDecoder( + BytesUtils.getWidthFromMaxInt(maxLevel), + new ByteArrayInputStream(bytes.toByteArray()))); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read levels in page for col %s.", descriptor), e); + } + } + + /** Utility interface to abstract over different way to read ints with different encodings. */ + interface IntIterator { + int nextInt(); + } + + /** Reading int from {@link ValuesReader}. */ + protected static final class ValuesReaderIntIterator implements IntIterator { + ValuesReader delegate; + + public ValuesReaderIntIterator(ValuesReader delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + return delegate.readInteger(); + } + } + + /** Reading int from {@link RunLengthBitPackingHybridDecoder}. */ + protected static final class RLEIntIterator implements IntIterator { + RunLengthBitPackingHybridDecoder delegate; + + public RLEIntIterator(RunLengthBitPackingHybridDecoder delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + try { + return delegate.readInt(); + } catch (IOException e) { + throw new ParquetDecodingException(e); + } + } + } + + /** Reading zero always. */ + protected static final class NullIntIterator implements IntIterator { + @Override + public int nextInt() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java index 3572b117a6313..1826419db5d44 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java @@ -18,7 +18,9 @@ package org.apache.hudi.table.format.cow.vector.reader; +import org.apache.hudi.table.format.cow.ParquetSplitReaderUtil; import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; import org.apache.flink.formats.parquet.vector.reader.ColumnReader; import org.apache.flink.table.data.RowData; @@ -28,6 +30,7 @@ import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.util.FlinkRuntimeException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -39,6 +42,8 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.Type; @@ -46,6 +51,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,7 +59,6 @@ import java.util.Map; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createColumnReader; import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createWritableColumnVector; import static org.apache.parquet.filter2.compat.FilterCompat.get; import static org.apache.parquet.filter2.compat.RowGroupFilter.filterRowGroups; @@ -77,6 +82,14 @@ public class ParquetColumnarRowSplitReader implements Closeable { private final MessageType requestedSchema; + /** + * {@link ParquetField} tree per top-level requested column, used by + * {@link ParquetSplitReaderUtil#createColumnReader(boolean, LogicalType, Type, List, + * PageReadStore, ParquetField)} to drive the Dremel-style {@link NestedColumnReader} for + * nested types. Entries are {@code null} for primitive top-level fields. Built once per split. + */ + private final List requestedFields; + /** * The total number of rows this RecordReader will eventually read. The sum of the rows of all * the row groups. @@ -158,6 +171,20 @@ public ParquetColumnarRowSplitReader( checkSchema(); + // Build the ParquetField tree once per split (the Dremel-style nested reader reuses it across + // row groups). Only columns with nested logical type get a non-null entry — primitive columns + // still use Hudi's specialized ColumnReaders. + MessageColumnIO messageColumnIO = new ColumnIOFactory().getColumnIO(requestedSchema); + List requestedRowFields = new ArrayList<>(requestedTypes.length); + List requestedFieldNames = new ArrayList<>(requestedTypes.length); + for (int i = 0; i < requestedTypes.length; i++) { + String name = requestedSchema.getFieldName(i); + requestedRowFields.add(new RowType.RowField(name, requestedTypes[i])); + requestedFieldNames.add(name); + } + this.requestedFields = ParquetSplitReaderUtil.buildFieldsList( + requestedRowFields, requestedFieldNames, messageColumnIO); + this.writableVectors = createWritableVectors(); ColumnVector[] columnVectors = patchedVector(selectedFieldNames.length, createReadableVectors(), requestedIndices); this.columnarBatch = generator.generate(columnVectors); @@ -340,12 +367,13 @@ private void readNextRowGroup() throws IOException { List columns = requestedSchema.getColumns(); columnReaders = new ColumnReader[types.size()]; for (int i = 0; i < types.size(); ++i) { - columnReaders[i] = createColumnReader( + columnReaders[i] = ParquetSplitReaderUtil.createColumnReader( utcTimestamp, requestedTypes[i], types.get(i), columns, - pages); + pages, + requestedFields.get(i)); } totalCountLoadedSoFar += pages.getRowCount(); } diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java index fdfe5d6fa3a33..1abc6ed56c0db 100644 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java @@ -26,12 +26,16 @@ import org.apache.parquet.column.Dictionary; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; @@ -252,21 +256,115 @@ public TimestampData readTimestamp() { } } + /** + * Reader for Parquet INT64 timestamp values (MILLIS / MICROS / NANOS), i.e. the standard + * timestamp encoding defined by Parquet's + * {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and the legacy + * {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} annotations. + * (The older INT96 encoding is marked deprecated by the Parquet format spec — see + * + * LogicalTypes.md — but is still supported here via {@link TypesFromInt96PageReader} for + * backwards compatibility with files written by older Hive / Spark / Impala versions.) + * + *

    Used by {@link NestedPrimitiveColumnReader} when a TIMESTAMP column sits inside a + * {@code Row}, {@code Array} or {@code Map}; the top-level path continues to use + * {@link Int64TimestampColumnReader} for batched-vector efficiency. + */ + public static class TypesFromInt64PageReader extends DefaultParquetDataColumnReader { + private final boolean isUtcTimestamp; + private final ChronoUnit chronoUnit; + + public TypesFromInt64PageReader( + ValuesReader realReader, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(realReader); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + public TypesFromInt64PageReader( + Dictionary dict, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(dict); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + @Override + public TimestampData readTimestamp() { + return int64ToTimestamp(isUtcTimestamp, valuesReader.readLong(), chronoUnit); + } + + @Override + public TimestampData readTimestamp(int id) { + return int64ToTimestamp(isUtcTimestamp, dict.decodeToLong(id), chronoUnit); + } + } + private static ParquetDataColumnReader getDataColumnReaderByTypeHelper( boolean isDictionary, PrimitiveType parquetType, Dictionary dictionary, ValuesReader valuesReader, boolean isUtcTimestamp) { - if (parquetType.getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.INT96) { + PrimitiveType.PrimitiveTypeName typeName = parquetType.getPrimitiveTypeName(); + if (typeName == PrimitiveType.PrimitiveTypeName.INT96) { return isDictionary ? new TypesFromInt96PageReader(dictionary, isUtcTimestamp) : new TypesFromInt96PageReader(valuesReader, isUtcTimestamp); - } else { - return isDictionary - ? new DefaultParquetDataColumnReader(dictionary) - : new DefaultParquetDataColumnReader(valuesReader); } + if (typeName == PrimitiveType.PrimitiveTypeName.INT64) { + ChronoUnit unit = resolveInt64TimestampUnit(parquetType); + if (unit != null) { + return isDictionary + ? new TypesFromInt64PageReader(dictionary, isUtcTimestamp, unit) + : new TypesFromInt64PageReader(valuesReader, isUtcTimestamp, unit); + } + } + return isDictionary + ? new DefaultParquetDataColumnReader(dictionary) + : new DefaultParquetDataColumnReader(valuesReader); + } + + /** + * Returns the {@link ChronoUnit} for a Parquet INT64 TIMESTAMP column, or {@code null} if the + * column is a plain INT64 (not a timestamp). + * + *

    Supports both the modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and + * the legacy {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} + * encodings. + */ + private static ChronoUnit resolveInt64TimestampUnit(PrimitiveType parquetType) { + LogicalTypeAnnotation annotation = parquetType.getLogicalTypeAnnotation(); + if (annotation instanceof LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) { + LogicalTypeAnnotation.TimeUnit unit = + ((LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation).getUnit(); + switch (unit) { + case MILLIS: + return ChronoUnit.MILLIS; + case MICROS: + return ChronoUnit.MICROS; + case NANOS: + return ChronoUnit.NANOS; + default: + return null; + } + } + OriginalType originalType = parquetType.getOriginalType(); + if (originalType == OriginalType.TIMESTAMP_MILLIS) { + return ChronoUnit.MILLIS; + } + if (originalType == OriginalType.TIMESTAMP_MICROS) { + return ChronoUnit.MICROS; + } + return null; + } + + private static TimestampData int64ToTimestamp( + boolean isUtcTimestamp, long value, ChronoUnit unit) { + Instant instant = Instant.EPOCH.plus(value, unit); + if (isUtcTimestamp) { + return TimestampData.fromInstant(instant); + } + return TimestampData.fromTimestamp(Timestamp.from(instant)); } public static ParquetDataColumnReader getDataColumnReaderByTypeOnDictionary( @@ -281,10 +379,10 @@ public static ParquetDataColumnReader getDataColumnReaderByType( } private static TimestampData int96ToTimestamp( - boolean utcTimestamp, long nanosOfDay, int julianDay) { + boolean isUtcTimestamp, long nanosOfDay, int julianDay) { long millisecond = julianDayToMillis(julianDay) + (nanosOfDay / NANOS_PER_MILLISECOND); - if (utcTimestamp) { + if (isUtcTimestamp) { int nanoOfMillisecond = (int) (nanosOfDay % NANOS_PER_MILLISECOND); return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); } else { diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java deleted file mode 100644 index 79b50487f13c1..0000000000000 --- a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; -import java.util.List; - -/** - * Row {@link ColumnReader}. - */ -public class RowColumnReader implements ColumnReader { - - private final List fieldReaders; - - public RowColumnReader(List fieldReaders) { - this.fieldReaders = fieldReaders; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapRowColumnVector rowColumnVector = (HeapRowColumnVector) vector; - WritableColumnVector[] vectors = rowColumnVector.vectors; - // row vector null array - boolean[] isNulls = new boolean[readNumber]; - for (int i = 0; i < vectors.length; i++) { - fieldReaders.get(i).readToVector(readNumber, vectors[i]); - - for (int j = 0; j < readNumber; j++) { - if (i == 0) { - isNulls[j] = vectors[i].isNullAt(j); - } else { - isNulls[j] = isNulls[j] && vectors[i].isNullAt(j); - } - if (i == vectors.length - 1 && isNulls[j]) { - // rowColumnVector[j] is null only when all fields[j] of rowColumnVector[j] is - // null - rowColumnVector.setNullAt(j); - } - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java new file mode 100644 index 0000000000000..0f5e00779a2f5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +/** + * Field that represent parquet's field type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetField}). + */ +public abstract class ParquetField { + private final LogicalType type; + private final int repetitionLevel; + private final int definitionLevel; + private final boolean required; + + public ParquetField( + LogicalType type, int repetitionLevel, int definitionLevel, boolean required) { + this.type = type; + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + this.required = required; + } + + public LogicalType getType() { + return type; + } + + public int getRepetitionLevel() { + return repetitionLevel; + } + + public int getDefinitionLevel() { + return definitionLevel; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "Field{" + + "type=" + + type + + ", repetitionLevel=" + + repetitionLevel + + ", definitionLevel=" + + definitionLevel + + ", required=" + + required + + '}'; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java new file mode 100644 index 0000000000000..f91dcca965d64 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's Group Field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetGroupField}) with a Hudi-specific extension: + * entries in the {@code children} list may be {@code null} to denote a Row child that is absent + * from the parquet file but present in the requested logical schema (schema evolution). This + * replaces Hudi's previous {@code EmptyColumnReader} branch for Row subtrees. + */ +public class ParquetGroupField extends ParquetField { + + private final List children; + + public ParquetGroupField( + LogicalType type, + int repetitionLevel, + int definitionLevel, + boolean required, + List children) { + super(type, repetitionLevel, definitionLevel, required); + // Use a plain unmodifiable list (not ImmutableList) so that null entries are allowed for + // schema-evolution missing children in ROW types. + this.children = + Collections.unmodifiableList(new ArrayList<>(requireNonNull(children, "children is null"))); + } + + /** Children of this group. Entries may be {@code null} for absent-in-file Row fields. */ + public List getChildren() { + return children; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java new file mode 100644 index 0000000000000..f6af6f9ff479e --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.column.ColumnDescriptor; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's primitive field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetPrimitiveField}). + */ +public class ParquetPrimitiveField extends ParquetField { + + private final ColumnDescriptor descriptor; + private final int id; + + public ParquetPrimitiveField( + LogicalType type, boolean required, ColumnDescriptor descriptor, int id) { + super( + type, + descriptor.getMaxRepetitionLevel(), + descriptor.getMaxDefinitionLevel(), + required); + this.descriptor = requireNonNull(descriptor, "descriptor is required"); + this.id = id; + } + + public ColumnDescriptor getDescriptor() { + return descriptor; + } + + public int getId() { + return id; + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..ae2e4107d6ea7 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + throw new UnsupportedOperationException("Variant is not supported yet."); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java new file mode 100644 index 0000000000000..aff1c32917cf9 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests for the Flink 2.1-compatible accessors added on {@link HeapArrayVector}, + * {@link HeapMapColumnVector} and {@link HeapRowColumnVector} when vendoring Flink 2.1's + * nested-Parquet reader (FLINK-35702). + * + *

    The accessors are wrappers over the existing public fields so legacy callers continue to + * work. These tests exist solely to pin down that wrapper contract — runtime correctness of the + * Dremel-style read path is exercised end-to-end by integration tests in + * {@code ITTestHoodieDataSource} (testParquetComplexTypes / testParquetComplexNestedRowTypes / + * testParquetArrayMapOfRowTypes / testParquetNullChildColumnsRowTypes). + */ +class TestHeapColumnVectorAccessors { + + // ----------------------------------------------------------------------------------------------- + // HeapArrayVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapArrayVectorAccessorsReflectPublicFields() { + HeapIntVector child = new HeapIntVector(4); + HeapArrayVector vector = new HeapArrayVector(2, child); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector replacementChild = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setChild(replacementChild); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(replacementChild, vector.getChild()); + assertEquals(2, vector.getSize()); + + // Backing public fields are kept in sync — preserves backward compatibility. + assertSame(offsets, vector.offsets); + assertSame(lengths, vector.lengths); + assertSame(replacementChild, vector.child); + } + + // ----------------------------------------------------------------------------------------------- + // HeapMapColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapMapColumnVectorConstructorInitializesOffsetsAndLengths() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + + HeapMapColumnVector vector = new HeapMapColumnVector(3, keys, values); + + assertEquals(3, vector.getOffsets().length); + assertEquals(3, vector.getLengths().length); + } + + @Test + void heapMapColumnVectorAccessorsReflectInternalState() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + HeapMapColumnVector vector = new HeapMapColumnVector(2, keys, values); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector newKeys = new HeapLongVector(4); + HeapLongVector newValues = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setKeys(newKeys); + vector.setValues(newValues); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(newKeys, vector.getKeys()); + assertSame(newValues, vector.getValues()); + // The Flink-2.1-style ColumnVector accessors return the same underlying child. + assertSame(newKeys, vector.getKeyColumnVector()); + assertSame(newValues, vector.getValueColumnVector()); + assertEquals(2, vector.getSize()); + } + + // ----------------------------------------------------------------------------------------------- + // HeapRowColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapRowColumnVectorFieldsAccessorsReflectPublicVectors() { + HeapIntVector intField = new HeapIntVector(2); + HeapLongVector longField = new HeapLongVector(2); + HeapRowColumnVector vector = new HeapRowColumnVector(2, intField, longField); + + WritableColumnVector[] originalFields = vector.getFields(); + assertEquals(2, originalFields.length); + assertSame(intField, originalFields[0]); + assertSame(longField, originalFields[1]); + // Backing public field is kept in sync — preserves backward compatibility. + assertSame(originalFields, vector.vectors); + + HeapIntVector replacement = new HeapIntVector(2); + WritableColumnVector[] replacementFields = {replacement, longField}; + vector.setFields(replacementFields); + + assertSame(replacementFields, vector.getFields()); + assertSame(replacementFields, vector.vectors); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java new file mode 100644 index 0000000000000..04f5809b9dac4 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.columnar.vector.BytesColumnVector; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ParquetDecimalVector}. + */ +public class TestParquetDecimalVector { + + @Test + void testGetDecimalFromInt32Vector() { + // precision <= 9 => ParquetSchemaConverter.is32BitDecimal(precision) == true + HeapIntVector intVector = new HeapIntVector(1); + intVector.vector[0] = 12345; + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(new BigDecimal("123.45"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromInt64Vector() { + // 9 < precision <= 18 => ParquetSchemaConverter.is64BitDecimal(precision) == true + HeapLongVector longVector = new HeapLongVector(1); + longVector.vector[0] = 1234567890123456L; + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + DecimalData decoded = wrapped.getDecimal(0, 18, 4); + + assertEquals(new BigDecimal("123456789012.3456"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtLargePrecision() { + // precision > 18 => BINARY / FIXED_LEN_BYTE_ARRAY path + BigDecimal original = new BigDecimal("12345678901234567890.1234567890"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 30, 10); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtSmallPrecision() { + // A Parquet file can legally encode a small-precision decimal as BINARY. In that case the + // dispatch must fall through to the bytes branch rather than require an IntColumnVector. + BigDecimal original = new BigDecimal("123.45"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalThrowsOnUnsupportedVectorType() { + // A large-precision request must have a bytes-backed child; any other writable child is an + // illegal combination and must be surfaced via Preconditions.checkArgument. + ColumnVector unsupported = new HeapShortVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(unsupported); + + assertThrows(IllegalArgumentException.class, () -> wrapped.getDecimal(0, 30, 10)); + } + + @Test + void testIsNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + intVector.vector[0] = 1; + intVector.setNullAt(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + assertFalse(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testWritableIntRoundTrip() { + HeapIntVector intVector = new HeapIntVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setInt(0, 42); + + assertEquals(42, wrapped.getInt(0)); + assertEquals(42, intVector.vector[0]); + } + + @Test + void testWritableLongRoundTrip() { + HeapLongVector longVector = new HeapLongVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + wrapped.setLong(0, 9876543210L); + + assertEquals(9876543210L, wrapped.getLong(0)); + assertEquals(9876543210L, longVector.vector[0]); + } + + @Test + void testWritableBytesRoundTrip() { + HeapBytesVector bytesVector = new HeapBytesVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + byte[] payload = new byte[] {0x01, 0x02, 0x03}; + + wrapped.appendBytes(0, payload, 0, payload.length); + + BytesColumnVector.Bytes out = wrapped.getBytes(0); + assertEquals(payload.length, out.len); + assertEquals(0x01, out.data[out.offset]); + assertEquals(0x02, out.data[out.offset + 1]); + assertEquals(0x03, out.data[out.offset + 2]); + } + + @Test + void testResetDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(1); + intVector.setNullAt(0); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + assertTrue(wrapped.isNullAt(0)); + + wrapped.reset(); + + assertFalse(wrapped.isNullAt(0)); + } + + @Test + void testFillWithNullsDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.fillWithNulls(); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testSetNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setNullAt(0); + wrapped.setNulls(1, 1); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java new file mode 100644 index 0000000000000..ea222dad576a5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.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.hudi.table.format.cow.vector.reader; + +import org.apache.flink.table.data.TimestampData; +import org.apache.parquet.column.Dictionary; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for the {@link ParquetDataColumnReaderFactory} INT64 timestamp dispatch added when + * vendoring Flink 2.1's nested-Parquet reader (FLINK-35702). + * + *

    The factory is exercised end-to-end by integration tests through + * {@link NestedPrimitiveColumnReader}; this unit test focuses on the small, deterministic piece + * that was added by this PR — selecting the right {@code ParquetDataColumnReader} for each + * supported INT64 TIMESTAMP encoding (modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} + * MILLIS / MICROS / NANOS plus the legacy {@link OriginalType} encodings) and decoding values + * using both the values-reader and dictionary code paths. + */ +class TestParquetDataColumnReaderFactory { + + // ----------------------------------------------------------------------------------------------- + // Type dispatch + // ----------------------------------------------------------------------------------------------- + + @Test + void valuesReaderDispatchInt96TimestampUsesInt96Reader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT96).named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt96PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64WithoutAnnotationUsesDefaultReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT64).named("plainLong"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMicrosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampNanosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMillisOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MILLIS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMicrosOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MICROS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt32DoesNotUseTimestampReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT32).named("i"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void dictionaryReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + // ----------------------------------------------------------------------------------------------- + // INT64 → TimestampData decoding (per ChronoUnit, both UTC and local-time-zone branches) + // ----------------------------------------------------------------------------------------------- + + @Test + void int64ReaderReadsTimestampMillisFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_123L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMillis), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + assertEquals(0, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMicrosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + long epochMicros = 1_700_000_000_123_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMicros), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMicros / 1_000L, ts.getMillisecond()); + // 456 microseconds remain → 456_000 nanoseconds within the millisecond + assertEquals(456_000, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampNanosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + long epochNanos = 1_700_000_000_123_456_789L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochNanos), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochNanos / 1_000_000L, ts.getMillisecond()); + assertEquals(456_789, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMillisFromDictionaryInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(epochMillis), true); + + TimestampData ts = reader.readTimestamp(0); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + } + + // ----------------------------------------------------------------------------------------------- + // Stubs (only the methods exercised by the dispatch + decoding tests above) + // ----------------------------------------------------------------------------------------------- + + /** Minimal {@link ValuesReader} returning a fixed long; other methods throw. */ + private static final class StubValuesReader extends ValuesReader { + private final long fixedLong; + + StubValuesReader() { + this(0L); + } + + StubValuesReader(long fixedLong) { + this.fixedLong = fixedLong; + } + + @Override + public long readLong() { + return fixedLong; + } + + @Override + public void skip() { + // unused + } + } + + /** Minimal {@link Dictionary} returning a fixed long for any id; other methods throw. */ + private static final class StubDictionary extends Dictionary { + private final long fixedLong; + + StubDictionary() { + this(0L); + } + + StubDictionary(long fixedLong) { + super(null); + this.fixedLong = fixedLong; + } + + @Override + public Binary decodeToBinary(int id) { + throw new UnsupportedOperationException(); + } + + @Override + public long decodeToLong(int id) { + return fixedLong; + } + + @Override + public int getMaxId() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java new file mode 100644 index 0000000000000..2b71bae4dc152 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink1.20.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.VarCharType; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link ParquetGroupField}. + */ +public class TestParquetGroupField { + + @Test + void testChildrenWithAllNonNullEntriesAreRetained() { + ParquetField c0 = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetField c1 = new ParquetPrimitiveField(new VarCharType(), true, descriptor(), 1); + List children = Arrays.asList(c0, c1); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertSame(c0, group.getChildren().get(0)); + assertSame(c1, group.getChildren().get(1)); + } + + @Test + void testChildrenMayContainNullForSchemaEvolution() { + // A ROW field present in the requested Flink schema but absent from the Parquet file is + // represented by a null slot in `children`. The group must allow this (Hudi-specific + // extension over Flink's ImmutableList-backed equivalent). + ParquetField present = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List children = Arrays.asList(present, null); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertNotNull(group.getChildren().get(0)); + assertNull(group.getChildren().get(1)); + } + + @Test + void testChildrenListIsUnmodifiable() { + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.singletonList(child)); + + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().add(null)); + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().remove(0)); + } + + @Test + void testChildrenListIsDefensivelyCopied() { + // Mutations to the caller-supplied list must not be visible through the group. + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List mutable = new ArrayList<>(); + mutable.add(child); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, mutable); + mutable.add(null); + + assertEquals(1, group.getChildren().size()); + } + + @Test + void testNullChildrenListThrows() { + assertThrows( + NullPointerException.class, + () -> new ParquetGroupField(rowType(), 0, 1, true, null)); + } + + @Test + void testEmptyChildrenListIsAllowed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.emptyList()); + + assertEquals(0, group.getChildren().size()); + } + + @Test + void testFieldMetadataIsExposed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 2, 5, false, Collections.emptyList()); + + assertEquals(2, group.getRepetitionLevel()); + assertEquals(5, group.getDefinitionLevel()); + assertFalse(group.isRequired()); + } + + private static LogicalType rowType() { + return RowType.of(new IntType()); + } + + private static ColumnDescriptor descriptor() { + PrimitiveType primitive = Types.required(PrimitiveTypeName.INT32).named("f"); + return new ColumnDescriptor(new String[] {"f"}, primitive, 0, 0); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/pom.xml b/hudi-flink-datasource/hudi-flink2.0.x/pom.xml index 90c40c1f8959e..86647aa03d2d6 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink2.0.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -127,12 +127,6 @@ ${flink2.0.version} provided - - org.apache.flink - flink-table-planner_2.12 - ${flink2.0.version} - provided - diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..18db48f0fed1f --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +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.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + private static final String VARIANT_UNSUPPORTED_MSG = + "VARIANT type is only supported in Flink 2.1+. " + + "Please upgrade your Flink version to use Variant columns."; + + public static Option variantParquetAnnotation() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Variant getVariant(RowData rowData, int pos) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static boolean isVariantType(LogicalType logicalType) { + return false; + } + + public static DataType createVariantType() { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantMetadata(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } + + public static byte[] getVariantValue(Object obj) { + throw new UnsupportedOperationException(VARIANT_UNSUPPORTED_MSG); + } +} \ No newline at end of file diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java index bb5d0c55b81de..7b2bb0fa55f8e 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java @@ -19,19 +19,18 @@ package org.apache.hudi.table.format.cow; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; import org.apache.hudi.table.format.cow.vector.HeapArrayVector; import org.apache.hudi.table.format.cow.vector.HeapDecimalVector; import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; -import org.apache.hudi.table.format.cow.vector.reader.ArrayColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.ArrayGroupReader; import org.apache.hudi.table.format.cow.vector.reader.EmptyColumnReader; import org.apache.hudi.table.format.cow.vector.reader.FixedLenBytesColumnReader; import org.apache.hudi.table.format.cow.vector.reader.Int64TimestampColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.MapColumnReader; +import org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader; import org.apache.hudi.table.format.cow.vector.reader.ParquetColumnarRowSplitReader; -import org.apache.hudi.table.format.cow.vector.reader.RowColumnReader; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.vector.reader.BooleanColumnReader; @@ -64,12 +63,13 @@ 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.LogicalTypeRoot; 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.TimestampType; +import org.apache.flink.util.FlinkRuntimeException; import org.apache.flink.util.Preconditions; +import org.apache.flink.util.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.parquet.ParquetRuntimeException; import org.apache.parquet.column.ColumnDescriptor; @@ -77,12 +77,18 @@ import org.apache.parquet.column.page.PageReader; import org.apache.parquet.filter.UnboundRecordFilter; import org.apache.parquet.filter2.predicate.FilterPredicate; +import org.apache.parquet.io.ColumnIO; +import org.apache.parquet.io.GroupColumnIO; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.PrimitiveColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.InvalidSchemaException; import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; +import javax.annotation.Nullable; + import java.io.IOException; import java.math.BigDecimal; import java.sql.Date; @@ -90,25 +96,38 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.apache.flink.table.utils.DateTimeUtils.toInternal; import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; import static org.apache.parquet.Preconditions.checkArgument; +import static org.apache.parquet.schema.Type.Repetition.REPEATED; +import static org.apache.parquet.schema.Type.Repetition.REQUIRED; /** * Util for generating {@link ParquetColumnarRowSplitReader}. * - *

    NOTE: reference from Flink release 1.11.2 {@code ParquetSplitReaderUtil}, modify to support INT64 - * based TIMESTAMP_MILLIS as ConvertedType, should remove when Flink supports that. + *

    Uses the Dremel-style nested reader ported from Apache Flink 2.1 (FLINK-35702). For primitive + * top-level columns we keep Hudi's specialized readers — {@link Int64TimestampColumnReader}, + * {@link FixedLenBytesColumnReader}, and the Hudi {@link HeapDecimalVector} — unchanged. For + * nested types (ARRAY / MAP / MULTISET / ROW) we build a {@link ParquetField} tree once per + * split via {@link #buildFieldsList(List, List, MessageColumnIO)} and delegate reading to + * {@link NestedColumnReader}. + * + *

    Schema evolution: missing top-level fields are still handled by the caller + * ({@link ParquetColumnarRowSplitReader} patches them with null vectors). Missing fields + * inside a Row are handled here — {@link #constructField} returns {@code null} for a + * child that isn't physically present, and the corresponding child in the pre-allocated vector + * is filled with nulls via {@link #createVectorFromConstant} so the Dremel assembler can + * passthrough the slot (see {@link NestedColumnReader#readToVector}). */ public class ParquetSplitReaderUtil { - /** - * Util for generating partitioned {@link ParquetColumnarRowSplitReader}. - */ + /** Util for generating partitioned {@link ParquetColumnarRowSplitReader}. */ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( boolean utcTimestamp, boolean caseSensitive, @@ -182,10 +201,13 @@ private static ColumnVector createVector( return readVector; } - private static ColumnVector createVectorFromConstant( - LogicalType type, - Object value, - int batchSize) { + /** + * Builds a constant-filled column vector for either a partition column (non-null value) or a + * missing-column slot (null value). Used both at the batch-generator level for partition + * injection and at the row-reader level for fields absent from the Parquet file. + */ + public static ColumnVector createVectorFromConstant( + LogicalType type, Object value, int batchSize) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -278,6 +300,7 @@ private static ColumnVector createVectorFromConstant( value == null ? null : toInternal((Date) value), batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: HeapTimestampVector tv = new HeapTimestampVector(batchSize); if (value == null) { tv.fillWithNulls(); @@ -286,46 +309,41 @@ private static ColumnVector createVectorFromConstant( } return tv; case ARRAY: - ArrayType arrayType = (ArrayType) type; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - HeapArrayGroupColumnVector arrayGroup = new HeapArrayGroupColumnVector(batchSize); - if (value == null) { - arrayGroup.fillWithNulls(); - return arrayGroup; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } - } else { - HeapArrayVector arrayVector = new HeapArrayVector(batchSize); - if (value == null) { - arrayVector.fillWithNulls(); - return arrayVector; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } + if (value != null) { + throw new UnsupportedOperationException("Unsupported create array with default value."); } + HeapArrayVector arrayVector = new HeapArrayVector(batchSize); + arrayVector.fillWithNulls(); + return arrayVector; case MAP: - HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); - if (value == null) { - mapVector.fillWithNulls(); - return mapVector; - } else { - throw new UnsupportedOperationException("Unsupported create map with default value."); + case MULTISET: + if (value != null) { + throw new UnsupportedOperationException( + "Unsupported create " + type.getTypeRoot() + " with default value."); } + HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); + mapVector.fillWithNulls(); + return mapVector; case ROW: - HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize); - if (value == null) { - rowVector.fillWithNulls(); - return rowVector; - } else { + if (value != null) { throw new UnsupportedOperationException("Unsupported create row with default value."); } + RowType rowType = (RowType) type; + WritableColumnVector[] childVectors = new WritableColumnVector[rowType.getFieldCount()]; + for (int i = 0; i < childVectors.length; i++) { + childVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); + } + HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize, childVectors); + rowVector.fillWithNulls(); + return rowVector; default: throw new UnsupportedOperationException("Unsupported type: " + type); } } - private static List filterDescriptors(int depth, Type type, List columns) throws ParquetRuntimeException { + private static List filterDescriptors( + int depth, Type type, List columns) throws ParquetRuntimeException { List filtered = new ArrayList<>(); for (ColumnDescriptor descriptor : columns) { if (depth >= descriptor.getPath().length) { @@ -339,24 +357,61 @@ private static List filterDescriptors(int depth, Type type, Li return filtered; } + /** + * Creates a {@link ColumnReader} for one top-level requested field. For primitive types the + * Hudi-specialized reader path is used. For nested types ({@code ARRAY}, {@code MAP}, + * {@code MULTISET}, {@code ROW}) the Dremel-style {@link NestedColumnReader} is used, driven by + * the supplied pre-built {@link ParquetField} tree. + * + * @param field the {@link ParquetField} tree for this column, built by + * {@link #buildFieldsList(List, List, MessageColumnIO)}. Required (non-null) for nested + * types; ignored for primitives. + */ + public static ColumnReader createColumnReader( + boolean utcTimestamp, + LogicalType fieldType, + Type physicalType, + List descriptors, + PageReadStore pages, + @Nullable ParquetField field) throws IOException { + switch (fieldType.getTypeRoot()) { + case ARRAY: + case MAP: + case MULTISET: + case ROW: + Preconditions.checkNotNull( + field, "ParquetField must be provided for nested type: %s", fieldType); + return new NestedColumnReader(utcTimestamp, pages, field); + default: + return createPrimitiveColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages); + } + } + + /** + * Backward-compat entry point kept for callers that don't project nested types and therefore + * never need a {@link ParquetField} tree. Forwards to the {@link ParquetField}-aware overload + * with a null field; nested types now go through that overload directly. + * + * @deprecated use {@link #createColumnReader(boolean, LogicalType, Type, List, PageReadStore, + * ParquetField)} so nested types take the Dremel path. + */ + @Deprecated public static ColumnReader createColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List descriptors, PageReadStore pages) throws IOException { - return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, - pages, 0); + return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages, null); } - private static ColumnReader createColumnReader( + private static ColumnReader createPrimitiveColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List columns, - PageReadStore pages, - int depth) throws IOException { - List descriptors = filterDescriptors(depth, physicalType, columns); + PageReadStore pages) throws IOException { + List descriptors = filterDescriptors(0, physicalType, columns); ColumnDescriptor descriptor = descriptors.get(0); PageReader pageReader = pages.getPageReader(descriptor); switch (fieldType.getTypeRoot()) { @@ -392,7 +447,9 @@ private static ColumnReader createColumnReader( case INT96: return new TimestampColumnReader(utcTimestamp, descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for TIMESTAMP: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } case DECIMAL: switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { @@ -403,106 +460,23 @@ private static ColumnReader createColumnReader( case BINARY: return new BytesColumnReader(descriptor, pageReader); case FIXED_LEN_BYTE_ARRAY: - return new FixedLenBytesColumnReader( - descriptor, pageReader); + return new FixedLenBytesColumnReader(descriptor, pageReader); default: - throw new AssertionError(); - } - case ARRAY: - ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new ArrayGroupReader(createColumnReader( - utcTimestamp, - arrayType.getElementType(), - elementType, - descriptors, - pages, - elementDepth)); - } else { - return new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - fieldType); + throw new AssertionError( + "Unexpected physical type for DECIMAL: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } - case MAP: - MapType mapType = (MapType) fieldType; - ArrayColumnReader keyReader = - new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - new ArrayType(mapType.getKeyType())); - ColumnReader valueReader; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueReader = new ArrayGroupReader(createColumnReader( - utcTimestamp, - mapType.getValueType(), - physicalType.asGroupType().getType(0).asGroupType().getType(1), // Get the value physical type - descriptors.subList(1, descriptors.size()), // remove the key descriptor - pages, - depth + 2)); // increase the depth by 2, because there's a key_value entry in the path - } else { - valueReader = new ArrayColumnReader( - descriptors.get(1), - pages.getPageReader(descriptors.get(1)), - utcTimestamp, - descriptors.get(1).getPrimitiveType(), - new ArrayType(mapType.getValueType())); - } - return new MapColumnReader(keyReader, valueReader); - case ROW: - RowType rowType = (RowType) fieldType; - GroupType groupType = physicalType.asGroupType(); - List fieldReaders = new ArrayList<>(); - for (int i = 0; i < rowType.getFieldCount(); i++) { - // schema evolution: read the parquet file with a new extended field name. - int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); - if (fieldIndex < 0) { - fieldReaders.add(new EmptyColumnReader()); - } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - fieldReaders.add( - createColumnReader( - utcTimestamp, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } else { - fieldReaders.add( - createColumnReader( - utcTimestamp, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } - } - } - return new RowColumnReader(fieldReaders); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + /** + * Creates the writable column vector that the reader will write into. The returned vector shape + * matches {@code fieldType}; for ROW types missing physical fields are slotted with null-filled + * vectors (sourced from {@link #createVectorFromConstant}) so that the Dremel assembler in + * {@link NestedColumnReader} can pass them through unchanged. + */ public static WritableColumnVector createWritableColumnVector( int batchSize, LogicalType fieldType, @@ -523,33 +497,40 @@ private static WritableColumnVector createWritableColumnVector( switch (fieldType.getTypeRoot()) { case BOOLEAN: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBooleanVector(batchSize); case TINYINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapByteVector(batchSize); case DOUBLE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDoubleVector(batchSize); case FLOAT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.FLOAT, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.FLOAT, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapFloatVector(batchSize); case INTEGER: case DATE: case TIME_WITHOUT_TIME_ZONE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapIntVector(batchSize); case BIGINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT64, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT64, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapLongVector(batchSize); case SMALLINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapShortVector(batchSize); case CHAR: case VARCHAR: @@ -566,112 +547,64 @@ private static WritableColumnVector createWritableColumnVector( case DECIMAL: checkArgument( (typeName == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY - || typeName == PrimitiveType.PrimitiveTypeName.BINARY) + || typeName == PrimitiveType.PrimitiveTypeName.BINARY) && primitiveType.getOriginalType() == OriginalType.DECIMAL, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDecimalVector(batchSize); case ARRAY: ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - elementType, - descriptors, - elementDepth)); - } else { - return new HeapArrayVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - physicalType, - descriptors, - depth)); - } - case MAP: + return new HeapArrayVector( + batchSize, + createWritableColumnVector( + batchSize, arrayType.getElementType(), physicalType, descriptors, depth)); + case MAP: { MapType mapType = (MapType) fieldType; - GroupType repeatedType = physicalType.asGroupType().getType(0).asGroupType(); - // the map column has three level paths. - WritableColumnVector keyColumnVector = createWritableColumnVector( + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( batchSize, - new ArrayType(mapType.getKeyType().isNullable(), mapType.getKeyType()), - repeatedType.getType(0), - descriptors, - depth + 2); - WritableColumnVector valueColumnVector; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueColumnVector = new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - mapType.getValueType(), - repeatedType.getType(1).asGroupType(), - descriptors, - depth + 2)); - } else { - valueColumnVector = createWritableColumnVector( - batchSize, - new ArrayType(mapType.getValueType().isNullable(), mapType.getValueType()), - repeatedType.getType(1), - descriptors, - depth + 2); - } - return new HeapMapColumnVector(batchSize, keyColumnVector, valueColumnVector); + createWritableColumnVector( + batchSize, mapType.getKeyType(), repeatedType.getType(0), descriptors, depth + 2), + createWritableColumnVector( + batchSize, mapType.getValueType(), repeatedType.getType(1), descriptors, depth + 2)); + } + case MULTISET: { + MultisetType multisetType = (MultisetType) fieldType; + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( + batchSize, + createWritableColumnVector( + batchSize, + multisetType.getElementType(), + repeatedType.getType(0), + descriptors, + depth + 2), + createWritableColumnVector( + batchSize, + new IntType(false), + repeatedType.getType(1), + descriptors, + depth + 2)); + } case ROW: RowType rowType = (RowType) fieldType; GroupType groupType = physicalType.asGroupType(); WritableColumnVector[] columnVectors = new WritableColumnVector[rowType.getFieldCount()]; for (int i = 0; i < columnVectors.length; i++) { - // schema evolution: read the file with a new extended field name. int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); if (fieldIndex < 0) { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (groupType.getRepetition().equals(Type.Repetition.REPEATED) && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant( - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), null, batchSize); - } else { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); - } + // Schema evolution: logical field is absent from the Parquet file. Slot a null-filled + // vector of the correct shape; NestedColumnReader.readRow will pass it through when the + // matching ParquetField child is null. + columnVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = - createWritableColumnVector( - batchSize, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } else { - columnVectors[i] = - createWritableColumnVector( - batchSize, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } + columnVectors[i] = + createWritableColumnVector( + batchSize, + rowType.getTypeAt(i), + groupType.getType(fieldIndex), + descriptors, + depth + 1); } } return new HeapRowColumnVector(batchSize, columnVectors); @@ -681,56 +614,245 @@ private static WritableColumnVector createWritableColumnVector( } /** - * Returns the field index with given physical row type {@code groupType} and field name {@code fieldName}. - * - * @return The physical field index or -1 if the field does not exist + * Peels one {@code repeated group key_value} wrapper off a MAP / MULTISET physical type, matching + * Parquet's canonical 3-level map encoding. */ - private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { - // get index from fileSchema type, else, return -1 - return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + private static GroupType unwrapMapRepeatedType(Type physicalType) { + return physicalType.asGroupType().getType(0).asGroupType(); } + // ------------------------------------------------------------------------------------------ + // ParquetField tree construction (vendored from Apache Flink 2.1 ParquetSplitReaderUtil) + // + // The only Hudi-specific divergence is in `constructField`: the ROW branch tolerates children + // missing from the Parquet file by emitting a null ParquetField child (upstream throws). This + // matches the Hudi schema-evolution contract and is the companion to the null-child branch in + // `NestedColumnReader#readRow` and the null-vector slot in `createWritableColumnVector#ROW`. + // ------------------------------------------------------------------------------------------ + /** - * Check whether the given list type is a three-level list type. - *

    - * group (LIST) { - * repeated group list { - * element; - * } - * } - * - * @param type list type - * @return true if the list type is a three-level list type + * Builds {@link ParquetField} trees — one per top-level projected logical column — that feed + * {@link NestedColumnReader}. The returned list mirrors the input {@code children} positionally; + * primitive top-level fields produce {@code null} entries (callers don't need a tree for those). */ - private static boolean isThreeLevelList(Type type) { - if (type.isPrimitive()) { - return false; + public static List buildFieldsList( + List children, List fieldNames, MessageColumnIO columnIO) { + List list = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + RowType.RowField child = children.get(i); + if (isNestedType(child.getType())) { + list.add(constructField(child, lookupColumnByName(columnIO, fieldNames.get(i)))); + } else { + list.add(null); + } } - GroupType groupType = type.asGroupType(); - OriginalType originalType = groupType.getOriginalType(); - return originalType == OriginalType.LIST - && groupType.getType(0).getName().equals("list"); + return list; + } + + private static boolean isNestedType(LogicalType type) { + return type instanceof RowType + || type instanceof ArrayType + || type instanceof MapType + || type instanceof MultisetType; + } + + @Nullable + private static ParquetField constructField(RowType.RowField rowField, ColumnIO columnIO) { + boolean required = columnIO.getType().getRepetition() == REQUIRED; + int repetitionLevel = columnIO.getRepetitionLevel(); + int definitionLevel = columnIO.getDefinitionLevel(); + LogicalType type = rowField.getType(); + String fieldName = rowField.getName(); + if (type instanceof RowType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + RowType rowType = (RowType) type; + List childFields = rowType.getFields(); + List fieldsList = new ArrayList<>(childFields.size()); + for (RowType.RowField childField : childFields) { + // Hudi schema evolution: a logical child may be absent from the Parquet file. In that + // case we emit a null ParquetField so that NestedColumnReader.readRow passes through the + // pre-filled null vector instead of recursing. + ColumnIO childIo = lookupColumnByNameOrNull(groupColumnIO, childField.getName()); + if (childIo == null) { + fieldsList.add(null); + } else { + fieldsList.add(constructField(childField, childIo)); + } + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(fieldsList)); + } + + if (type instanceof MapType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MapType mapType = (MapType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", mapType.getKeyType()), keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", mapType.getValueType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof MultisetType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MultisetType multisetType = (MultisetType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", multisetType.getElementType()), + keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", new IntType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof ArrayType) { + ArrayType arrayType = (ArrayType) type; + ColumnIO elementTypeColumnIO; + if (columnIO instanceof GroupColumnIO) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) { + while (!Objects.equals(groupColumnIO.getName(), fieldName)) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + elementTypeColumnIO = groupColumnIO; + } else { + if (arrayType.getElementType() instanceof RowType) { + elementTypeColumnIO = groupColumnIO; + } else { + elementTypeColumnIO = groupColumnIO.getChild(0); + } + } + } else if (columnIO instanceof PrimitiveColumnIO) { + elementTypeColumnIO = columnIO; + } else { + throw new FlinkRuntimeException(String.format("Unknown ColumnIO, %s", columnIO)); + } + + ParquetField elementField = + constructField( + new RowType.RowField("", arrayType.getElementType()), + getArrayElementColumn(elementTypeColumnIO)); + if (repetitionLevel == elementField.getRepetitionLevel()) { + repetitionLevel = columnIO.getParent().getRepetitionLevel(); + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.singletonList(elementField)); + } + + PrimitiveColumnIO primitiveColumnIO = (PrimitiveColumnIO) columnIO; + return new ParquetPrimitiveField( + type, required, primitiveColumnIO.getColumnDescriptor(), primitiveColumnIO.getId()); } /** - * Construct the error message when primitive type mismatches. - * - * @param primitiveType Primitive type - * @param fieldType Logical field type - * @return The error message + * Parquet column names are case-insensitive in Flink's lookup. Matches upstream + * {@code ParquetSplitReaderUtil.lookupColumnByName}; throws when absent. */ - private static String getPrimitiveTypeCheckFailureMessage(PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { - return String.format("Unexpected type exception. Primitive type: %s. Field type: %s.", primitiveType, fieldType.getTypeRoot().name()); + public static ColumnIO lookupColumnByName(GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = lookupColumnByNameOrNull(groupColumnIO, columnName); + if (columnIO != null) { + return columnIO; + } + throw new FlinkRuntimeException( + "Can not find column io for parquet reader. Column name: " + columnName); } /** - * Construct the error message when original type mismatches. + * Case-insensitive column lookup that returns {@code null} when no match is found — the + * Hudi-specific companion to {@link #lookupColumnByName}, used by {@link #constructField} to + * emit null {@link ParquetField} children for fields absent from the Parquet file. + */ + @Nullable + private static ColumnIO lookupColumnByNameOrNull( + GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = groupColumnIO.getChild(columnName); + if (columnIO != null) { + return columnIO; + } + for (int i = 0; i < groupColumnIO.getChildrenCount(); i++) { + if (groupColumnIO.getChild(i).getName().equalsIgnoreCase(columnName)) { + return groupColumnIO.getChild(i); + } + } + return null; + } + + public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO groupColumnIO) { + while (groupColumnIO.getChildrenCount() == 1) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + return groupColumnIO; + } + + public static ColumnIO getArrayElementColumn(ColumnIO columnIO) { + while (columnIO instanceof GroupColumnIO && !columnIO.getType().isRepetition(REPEATED)) { + columnIO = ((GroupColumnIO) columnIO).getChild(0); + } + + // Three-level list: skip the synthetic `element` / `list` wrapper when present. + if (columnIO instanceof GroupColumnIO + && columnIO.getType().getLogicalTypeAnnotation() == null + && ((GroupColumnIO) columnIO).getChildrenCount() == 1 + && !columnIO.getName().equals("array") + && !columnIO.getName().equals(columnIO.getParent().getName() + "_tuple")) { + return ((GroupColumnIO) columnIO).getChild(0); + } + return columnIO; + } + + /** + * Returns the field index with given physical row type {@code groupType} and field name + * {@code fieldName}. * - * @param originalType Original type - * @param fieldType Logical field type - * @return The error message + * @return the physical field index or -1 if the field does not exist + */ + private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { + return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + } + + private static String getPrimitiveTypeCheckFailureMessage( + PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Primitive type: %s. Field type: %s.", + primitiveType, fieldType.getTypeRoot().name()); + } + + private static String getOriginalTypeCheckFailureMessage( + OriginalType originalType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Original type: %s. Field type: %s.", + originalType, fieldType.getTypeRoot().name()); + } + + /** + * Returns a synthetic null-column reader to fill missing top-level fields. Kept as a convenience + * for callers that need to mirror Hudi's original behaviour where a missing column produces an + * explicit null-valued reader rather than being omitted from the batch. */ - private static String getOriginalTypeCheckFailureMessage(OriginalType originalType, LogicalType fieldType) { - return String.format("Unexpected type exception. Original type: %s. Field type: %s.", originalType, fieldType.getTypeRoot().name()); + public static ColumnReader emptyColumnReader() { + return new EmptyColumnReader(); } } diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java new file mode 100644 index 0000000000000..d51d7ee754b8a --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of booleans. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.runtime.util.BooleanArrayList}) because Flink 1.18 does not ship this helper. + */ +public class BooleanArrayList { + private int size; + private boolean[] array; + + public BooleanArrayList(int capacity) { + this.size = 0; + this.array = new boolean[capacity]; + } + + public int size() { + return size; + } + + public boolean add(boolean element) { + grow(size + 1); + array[size++] = element; + return true; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public boolean[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final boolean[] t = new boolean[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java new file mode 100644 index 0000000000000..4787dbb5b9ddb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; +import java.util.NoSuchElementException; + +/** + * Minimal implementation of an array-backed list of ints. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.IntArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class IntArrayList { + + private int size; + private int[] array; + + public IntArrayList(final int capacity) { + this.size = 0; + this.array = new int[capacity]; + } + + public int size() { + return size; + } + + public boolean add(final int number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public int removeLast() { + if (size == 0) { + throw new NoSuchElementException(); + } + --size; + return array[size]; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return size == 0; + } + + private void grow(final int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final int[] t = new int[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } + + public int[] toArray() { + return Arrays.copyOf(array, size); + } + + public static final IntArrayList EMPTY = + new IntArrayList(0) { + + @Override + public boolean add(int number) { + throw new UnsupportedOperationException(); + } + + @Override + public int removeLast() { + throw new UnsupportedOperationException(); + } + }; +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java new file mode 100644 index 0000000000000..a51291f9d8441 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of longs. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.LongArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class LongArrayList { + + private int size; + private long[] array; + + public LongArrayList(int capacity) { + this.size = 0; + this.array = new long[capacity]; + } + + public int size() { + return size; + } + + public boolean add(long number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public long removeLong(int index) { + if (index >= size) { + throw new IndexOutOfBoundsException( + "Index (" + index + ") is greater than or equal to list size (" + size + ")"); + } + final long old = array[index]; + size--; + if (index != size) { + System.arraycopy(array, index + 1, array, index, size - index); + } + return old; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public long[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final long[] t = new long[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java new file mode 100644 index 0000000000000..3f2f8976b69bf --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.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.hudi.table.format.cow.utils; + +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; + +import static java.lang.String.format; + +/** + * Utils to calculate nested type position. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.utils.NestedPositionUtil}). + */ +public class NestedPositionUtil { + + /** + * Calculate row offsets according to column's max repetition level, definition level, value's + * repetition level and definition level. Each row has three situation: + *

  • Row is not defined,because it's optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Row is null + *
  • Row is defined and not empty. + * + * @param field field that contains the row column message include max repetition level and + * definition level. + * @param fieldRepetitionLevels int array with each value's repetition level. + * @param fieldDefinitionLevels int array with each value's definition level. + * @return {@link RowPosition} contains collections row count and isNull array. + */ + public static RowPosition calculateRowOffsets( + ParquetField field, int[] fieldDefinitionLevels, int[] fieldRepetitionLevels) { + int rowDefinitionLevel = field.getDefinitionLevel(); + int rowRepetitionLevel = field.getRepetitionLevel(); + int nullValuesCount = 0; + BooleanArrayList nullRowFlags = new BooleanArrayList(0); + for (int i = 0; i < fieldDefinitionLevels.length; i++) { + // If a row's last field is an array, the repetition levels for the array's items will + // be larger than the parent row's repetition level, so we need to skip those values. + if (fieldRepetitionLevels[i] > rowRepetitionLevel) { + continue; + } + + if (fieldDefinitionLevels[i] >= rowDefinitionLevel) { + // current row is defined and not empty + nullRowFlags.add(false); + } else { + // current row is null + nullRowFlags.add(true); + nullValuesCount++; + } + } + if (nullValuesCount == 0) { + return new RowPosition(null, fieldDefinitionLevels.length); + } + return new RowPosition(nullRowFlags.toArray(), nullRowFlags.size()); + } + + /** + * Calculate the collection's offsets according to column's max repetition level, definition + * level, value's repetition level and definition level. Each collection (Array or Map) has four + * situation: + *
  • Collection is not defined, because optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Collection is null + *
  • Collection is defined but empty + *
  • Collection is defined and not empty. In this case offset value is increased by the number + * of elements in that collection + * + * @param field field that contains array/map column message include max repetition level and + * definition level. + * @param definitionLevels int array with each value's definition level. + * @param repetitionLevels int array with each value's repetition level. + * @return {@link CollectionPosition} contains collections offset array, length array and isNull + * array. + */ + public static CollectionPosition calculateCollectionOffsets( + ParquetField field, int[] definitionLevels, int[] repetitionLevels) { + int collectionDefinitionLevel = field.getDefinitionLevel(); + int collectionRepetitionLevel = field.getRepetitionLevel() + 1; + int offset = 0; + int valueCount = 0; + LongArrayList offsets = new LongArrayList(0); + offsets.add(offset); + BooleanArrayList emptyCollectionFlags = new BooleanArrayList(0); + BooleanArrayList nullCollectionFlags = new BooleanArrayList(0); + int nullValuesCount = 0; + for (int i = 0; + i < definitionLevels.length; + i = getNextCollectionStartIndex(repetitionLevels, collectionRepetitionLevel, i)) { + valueCount++; + if (definitionLevels[i] >= collectionDefinitionLevel - 1) { + boolean isNull = + isOptionalFieldValueNull(definitionLevels[i], collectionDefinitionLevel); + nullCollectionFlags.add(isNull); + nullValuesCount += isNull ? 1 : 0; + // definitionLevels[i] > collectionDefinitionLevel => Collection is defined and not + // empty + // definitionLevels[i] == collectionDefinitionLevel => Collection is defined but + // empty + if (definitionLevels[i] > collectionDefinitionLevel) { + emptyCollectionFlags.add(false); + offset += getCollectionSize(repetitionLevels, collectionRepetitionLevel, i + 1); + } else if (definitionLevels[i] == collectionDefinitionLevel) { + offset++; + emptyCollectionFlags.add(true); + } else { + offset++; + emptyCollectionFlags.add(false); + } + offsets.add(offset); + } else { + // when definitionLevels[i] < collectionDefinitionLevel - 1, it means the collection + // is + // not defined, but we need to regard it as null to avoid getting value wrong. + nullCollectionFlags.add(true); + nullValuesCount++; + offsets.add(++offset); + emptyCollectionFlags.add(false); + } + } + long[] offsetsArray = offsets.toArray(); + long[] length = calculateLengthByOffsets(emptyCollectionFlags.toArray(), offsetsArray); + if (nullValuesCount == 0) { + return new CollectionPosition(null, offsetsArray, length, valueCount); + } + return new CollectionPosition( + nullCollectionFlags.toArray(), offsetsArray, length, valueCount); + } + + public static boolean isOptionalFieldValueNull(int definitionLevel, int maxDefinitionLevel) { + return definitionLevel == maxDefinitionLevel - 1; + } + + public static long[] calculateLengthByOffsets( + boolean[] collectionIsEmpty, long[] arrayOffsets) { + LongArrayList lengthList = new LongArrayList(arrayOffsets.length); + for (int i = 0; i < arrayOffsets.length - 1; i++) { + long offset = arrayOffsets[i]; + long length = arrayOffsets[i + 1] - offset; + if (length < 0) { + throw new IllegalArgumentException( + format( + "Offset is not monotonically ascending. offsets[%s]=%s, offsets[%s]=%s", + i, arrayOffsets[i], i + 1, arrayOffsets[i + 1])); + } + if (collectionIsEmpty[i]) { + length = 0; + } + lengthList.add(length); + } + return lengthList.toArray(); + } + + private static int getNextCollectionStartIndex( + int[] repetitionLevels, int maxRepetitionLevel, int elementIndex) { + do { + elementIndex++; + } while (hasMoreElements(repetitionLevels, elementIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, elementIndex)); + return elementIndex; + } + + /** This method is only called for non-empty collections. */ + private static int getCollectionSize( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + int size = 1; + while (hasMoreElements(repetitionLevels, nextIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, nextIndex)) { + // Collection elements cannot only be primitive, but also can have nested structure + // Counting only elements which belong to current collection, skipping inner elements of + // nested collections/structs + if (repetitionLevels[nextIndex] <= maxRepetitionLevel) { + size++; + } + nextIndex++; + } + return size; + } + + private static boolean isNotCollectionBeginningMarker( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + return repetitionLevels[nextIndex] >= maxRepetitionLevel; + } + + private static boolean hasMoreElements(int[] repetitionLevels, int nextIndex) { + return nextIndex < repetitionLevels.length; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java deleted file mode 100644 index 4c9275f3b0932..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java +++ /dev/null @@ -1,179 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupArrayData implements ArrayData { - - WritableColumnVector vector; - int rowId; - - public ColumnarGroupArrayData(WritableColumnVector vector, int rowId) { - this.vector = vector; - this.rowId = rowId; - } - - @Override - public int size() { - if (vector == null) { - return 0; - } - - if (vector instanceof HeapRowColumnVector) { - // assume all fields have the same size - if (((HeapRowColumnVector) vector).vectors == null || ((HeapRowColumnVector) vector).vectors.length == 0) { - return 0; - } - return ((HeapArrayVector) ((HeapRowColumnVector) vector).vectors[0]).getArray(rowId).size(); - } - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean isNullAt(int index) { - if (vector == null) { - return true; - } - - if (vector instanceof HeapRowColumnVector) { - return ((HeapRowColumnVector) vector).vectors == null; - } - - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean getBoolean(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte getByte(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short getShort(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int getInt(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long getLong(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float getFloat(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double getDouble(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public StringData getString(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public DecimalData getDecimal(int index, int precision, int scale) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public TimestampData getTimestamp(int index, int precision) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RawValueData getRawValue(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] getBinary(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public ArrayData getArray(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public MapData getMap(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RowData getRow(int index, int numFields) { - return new ColumnarGroupRowData((HeapRowColumnVector) vector, rowId, index); - } - - @Override - public boolean[] toBooleanArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] toByteArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short[] toShortArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int[] toIntArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long[] toLongArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float[] toFloatArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double[] toDoubleArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java deleted file mode 100644 index 69cb6feca13e4..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.MapData; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupMapData implements MapData { - - WritableColumnVector keyVector; - WritableColumnVector valueVector; - int rowId; - - public ColumnarGroupMapData(WritableColumnVector keyVector, WritableColumnVector valueVector, int rowId) { - this.keyVector = keyVector; - this.valueVector = valueVector; - this.rowId = rowId; - } - - @Override - public int size() { - if (keyVector == null) { - return 0; - } - - if (keyVector instanceof HeapArrayVector) { - return ((HeapArrayVector) keyVector).getArray(rowId).size(); - } - throw new UnsupportedOperationException(keyVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector"); - } - - @Override - public ArrayData keyArray() { - return ((HeapArrayVector) keyVector).getArray(rowId); - } - - @Override - public ArrayData valueArray() { - if (valueVector instanceof HeapArrayVector) { - return ((HeapArrayVector) valueVector).getArray(rowId); - } else if (valueVector instanceof HeapArrayGroupColumnVector) { - return ((HeapArrayGroupColumnVector) valueVector).getArray(rowId); - } - throw new UnsupportedOperationException(valueVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector, HeapArrayGroupColumnVector"); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java deleted file mode 100644 index 439c1880823f1..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.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.hudi.table.format.cow.vector; - -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.RawValueData; -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.RowKind; - -public class ColumnarGroupRowData implements RowData { - - HeapRowColumnVector vector; - int rowId; - int index; - - public ColumnarGroupRowData(HeapRowColumnVector vector, int rowId, int index) { - this.vector = vector; - this.rowId = rowId; - this.index = index; - } - - @Override - public int getArity() { - return vector.vectors.length; - } - - @Override - public RowKind getRowKind() { - return RowKind.INSERT; - } - - @Override - public void setRowKind(RowKind rowKind) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public boolean isNullAt(int pos) { - return - vector.vectors[pos].isNullAt(rowId) - || ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).isNullAt(index); - } - - @Override - public boolean getBoolean(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBoolean(index); - } - - @Override - public byte getByte(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getByte(index); - } - - @Override - public short getShort(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getShort(index); - } - - @Override - public int getInt(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getInt(index); - } - - @Override - public long getLong(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getLong(index); - } - - @Override - public float getFloat(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getFloat(index); - } - - @Override - public double getDouble(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDouble(index); - } - - @Override - public StringData getString(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getString(index); - } - - @Override - public DecimalData getDecimal(int pos, int i1, int i2) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDecimal(index, i1, i2); - } - - @Override - public TimestampData getTimestamp(int pos, int i1) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getTimestamp(index, i1); - } - - @Override - public RawValueData getRawValue(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRawValue(index); - } - - @Override - public byte[] getBinary(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBinary(index); - } - - @Override - public ArrayData getArray(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getArray(index); - } - - @Override - public MapData getMap(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getMap(index); - } - - @Override - public RowData getRow(int pos, int numFields) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRow(index, numFields); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java deleted file mode 100644 index 3d7d8b1f0de0f..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java +++ /dev/null @@ -1,53 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.columnar.vector.ArrayColumnVector; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -/** - * This class represents a nullable heap row column vector. - */ -public class HeapArrayGroupColumnVector extends AbstractHeapVector - implements WritableColumnVector, ArrayColumnVector { - - public WritableColumnVector vector; - - public HeapArrayGroupColumnVector(int len) { - super(len); - } - - public HeapArrayGroupColumnVector(int len, WritableColumnVector vector) { - super(len); - this.vector = vector; - } - - @Override - public ArrayData getArray(int rowId) { - return new ColumnarGroupArrayData(vector, rowId); - } - - @Override - public void reset() { - super.reset(); - vector.reset(); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java index a0dced01e5e8d..2f21a323302f1 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java @@ -57,6 +57,37 @@ public int getLen() { return this.isNull.length; } + // --------------------------------------------------------------------------------------------- + // Flink 2.1-compatible accessors. Backed by the existing public {@code offsets}, {@code lengths} + // and {@code child} fields so legacy callers continue to work; the new {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + // future Flink-2.1-style caller use these accessors. + // --------------------------------------------------------------------------------------------- + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public ColumnVector getChild() { + return child; + } + + public void setChild(ColumnVector child) { + this.child = child; + } + @Override public ArrayData getArray(int i) { long offset = offsets[i]; diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java index 0d83f82baedf3..14aad22039e0a 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java @@ -20,29 +20,97 @@ import lombok.Getter; import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.columnar.ColumnarMapData; +import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.MapColumnVector; import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; /** * This class represents a nullable heap map column vector. + * + *

    Mirrors {@code org.apache.flink.table.data.columnar.vector.heap.HeapMapVector} from + * Flink 2.1 (FLINK-35702). One deliberate divergence from upstream is preserved for backward + * compatibility: the {@code keys} / {@code values} fields are typed + * {@link WritableColumnVector} rather than upstream's {@link ColumnVector}, so the existing + * Lombok-generated {@code getKeys()} / {@code getValues()} accessors keep their original + * signature. Callers wanting the Flink-2.1 contract (a {@code ColumnVector}) use + * {@link #getKeyColumnVector()} / {@link #getValueColumnVector()}. */ public class HeapMapColumnVector extends AbstractHeapVector implements WritableColumnVector, MapColumnVector { @Getter - private final WritableColumnVector keys; + private WritableColumnVector keys; @Getter - private final WritableColumnVector values; + private WritableColumnVector values; + + // --------------------------------------------------------------------------------------------- + // Flink 2.1 Dremel-style state. Populated by {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and + // consumed by {@link #getMap(int)}. + // --------------------------------------------------------------------------------------------- + private long[] offsets; + private long[] lengths; + private int size; public HeapMapColumnVector(int len, WritableColumnVector keys, WritableColumnVector values) { super(len); + this.offsets = new long[len]; + this.lengths = new long[len]; + this.keys = keys; + this.values = values; + } + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public void setKeys(WritableColumnVector keys) { this.keys = keys; + } + + public void setValues(WritableColumnVector values) { this.values = values; } + /** + * Returns the keys child vector typed as {@link ColumnVector}, matching the Flink 2.1 contract + * consumed by {@code NestedColumnReader}. Functionally equivalent to {@link #getKeys()}. + */ + public ColumnVector getKeyColumnVector() { + return keys; + } + + /** Counterpart of {@link #getKeyColumnVector()} for the values child vector. */ + public ColumnVector getValueColumnVector() { + return values; + } + @Override public MapData getMap(int rowId) { - return new ColumnarGroupMapData(keys, values, rowId); + long offset = offsets[rowId]; + long length = lengths[rowId]; + return new ColumnarMapData(keys, values, (int) offset, (int) length); } } diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java index ae194e4e6ab05..0c640ce92ee40 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java @@ -37,6 +37,21 @@ public HeapRowColumnVector(int len, WritableColumnVector... vectors) { this.vectors = vectors; } + /** + * Flink 2.1-compatible accessor for the children vectors. Backed by the existing public {@code + * vectors} field so legacy callers continue to work; the new {@link + * org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + * future Flink-2.1-style caller use this accessor. + */ + public WritableColumnVector[] getFields() { + return vectors; + } + + /** Counterpart of {@link #getFields()}. */ + public void setFields(WritableColumnVector[] fields) { + this.vectors = fields; + } + @Override public ColumnarRowData getRow(int i) { ColumnarRowData columnarRowData = new ColumnarRowData(new VectorizedColumnBatch(vectors)); diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java index 98b5e61050898..a37b88352cf52 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java @@ -18,21 +18,29 @@ package org.apache.hudi.table.format.cow.vector; +import org.apache.flink.formats.parquet.utils.ParquetSchemaConverter; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.columnar.vector.BytesColumnVector; import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.DecimalColumnVector; +import org.apache.flink.table.data.columnar.vector.Dictionary; +import org.apache.flink.table.data.columnar.vector.IntColumnVector; +import org.apache.flink.table.data.columnar.vector.LongColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableBytesVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableIntVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableLongVector; + +import static org.apache.flink.util.Preconditions.checkArgument; /** - * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to - * provide {@link DecimalColumnVector} interface. - * - *

    Reference Flink release 1.11.2 {@link org.apache.flink.formats.parquet.vector.ParquetDecimalVector} - * because it is not public. + * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to provide + * {@link DecimalColumnVector} interface. */ -public class ParquetDecimalVector implements DecimalColumnVector { +public class ParquetDecimalVector + implements DecimalColumnVector, WritableLongVector, WritableIntVector, WritableBytesVector { - public final ColumnVector vector; + private final ColumnVector vector; public ParquetDecimalVector(ColumnVector vector) { this.vector = vector; @@ -40,15 +48,180 @@ public ParquetDecimalVector(ColumnVector vector) { @Override public DecimalData getDecimal(int i, int precision, int scale) { - return DecimalData.fromUnscaledBytes( - ((BytesColumnVector) vector).getBytes(i).getBytes(), - precision, - scale); + if (ParquetSchemaConverter.is32BitDecimal(precision) && vector instanceof IntColumnVector) { + return DecimalData.fromUnscaledLong(((IntColumnVector) vector).getInt(i), precision, scale); + } else if (ParquetSchemaConverter.is64BitDecimal(precision) + && vector instanceof LongColumnVector) { + return DecimalData.fromUnscaledLong(((LongColumnVector) vector).getLong(i), precision, scale); + } else { + checkArgument( + vector instanceof BytesColumnVector, + "Reading decimal type occur unsupported vector type: %s", + vector.getClass()); + return DecimalData.fromUnscaledBytes( + ((BytesColumnVector) vector).getBytes(i).getBytes(), precision, scale); + } + } + + public ColumnVector getVector() { + return vector; } @Override public boolean isNullAt(int i) { return vector.isNullAt(i); } -} + @Override + public void reset() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).reset(); + } + } + + @Override + public void setNullAt(int rowId) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNullAt(rowId); + } + } + + @Override + public void setNulls(int rowId, int count) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNulls(rowId, count); + } + } + + @Override + public void fillWithNulls() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).fillWithNulls(); + } + } + + @Override + public void setDictionary(Dictionary dictionary) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setDictionary(dictionary); + } + } + + @Override + public boolean hasDictionary() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).hasDictionary(); + } + return false; + } + + @Override + public WritableIntVector reserveDictionaryIds(int capacity) { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).reserveDictionaryIds(capacity); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public WritableIntVector getDictionaryIds() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).getDictionaryIds(); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public Bytes getBytes(int i) { + if (vector instanceof WritableBytesVector) { + return ((WritableBytesVector) vector).getBytes(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void appendBytes(int rowId, byte[] value, int offset, int length) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).appendBytes(rowId, value, offset, length); + } + } + + @Override + public void fill(byte[] value) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).fill(value); + } + } + + @Override + public int getInt(int i) { + if (vector instanceof WritableIntVector) { + return ((WritableIntVector) vector).getInt(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setInt(int rowId, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInt(rowId, value); + } + } + + @Override + public void setIntsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setIntsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void setInts(int rowId, int count, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, value); + } + } + + @Override + public void setInts(int rowId, int count, int[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).fill(value); + } + } + + @Override + public long getLong(int i) { + if (vector instanceof WritableLongVector) { + return ((WritableLongVector) vector).getLong(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setLong(int rowId, long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLong(rowId, value); + } + } + + @Override + public void setLongsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLongsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).fill(value); + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java new file mode 100644 index 0000000000000..fcdedfbc9d71d --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent collection's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.CollectionPosition}). + */ +public class CollectionPosition { + @Nullable private final boolean[] isNull; + private final long[] offsets; + private final long[] length; + private final int valueCount; + + public CollectionPosition(boolean[] isNull, long[] offsets, long[] length, int valueCount) { + this.isNull = isNull; + this.offsets = offsets; + this.length = length; + this.valueCount = valueCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public long[] getOffsets() { + return offsets; + } + + public long[] getLength() { + return length; + } + + public int getValueCount() { + return valueCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java new file mode 100644 index 0000000000000..fe95419ac3218 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.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.hudi.table.format.cow.vector.position; + +/** + * To delegate repetition level and definition level. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.LevelDelegation}). + */ +public class LevelDelegation { + private final int[] repetitionLevel; + private final int[] definitionLevel; + + public LevelDelegation(int[] repetitionLevel, int[] definitionLevel) { + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + } + + public int[] getRepetitionLevel() { + return repetitionLevel; + } + + public int[] getDefinitionLevel() { + return definitionLevel; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java new file mode 100644 index 0000000000000..5438b67973238 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent struct's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.RowPosition}). + */ +public class RowPosition { + @Nullable private final boolean[] isNull; + private final int positionsCount; + + public RowPosition(boolean[] isNull, int positionsCount) { + this.isNull = isNull; + this.positionsCount = positionsCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public int getPositionsCount() { + return positionsCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java deleted file mode 100644 index 6a8a01b74946a..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java +++ /dev/null @@ -1,473 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayVector; -import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.TimestampData; -import org.apache.flink.table.data.columnar.vector.VectorizedColumnBatch; -import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.table.types.logical.ArrayType; -import org.apache.flink.table.types.logical.LogicalType; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Array {@link ColumnReader}. - */ -public class ArrayColumnReader extends BaseVectorizedColumnReader { - - // The value read in last time - private Object lastValue; - - // flag to indicate if there is no data in parquet data page - private boolean eof = false; - - // flag to indicate if it's the first time to read parquet data page with this instance - boolean isFirstRow = true; - - public ArrayColumnReader( - ColumnDescriptor descriptor, - PageReader pageReader, - boolean isUtcTimestamp, - Type type, - LogicalType logicalType) - throws IOException { - super(descriptor, pageReader, isUtcTimestamp, type, logicalType); - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayVector lcv = (HeapArrayVector) vector; - // before readBatch, initial the size of offsets & lengths as the default value, - // the actual size will be assigned in setChildrenInfo() after reading complete. - lcv.offsets = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - lcv.lengths = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - // Because the length of ListColumnVector.child can't be known now, - // the valueList will save all data for ListColumnVector temporary. - List valueList = new ArrayList<>(); - - LogicalType category = ((ArrayType) logicalType).getElementType(); - - // read the first row in parquet data page, this will be only happened once for this - // instance - if (isFirstRow) { - if (!fetchNextValue(category)) { - return; - } - isFirstRow = false; - } - - int index = collectDataFromParquetPage(readNumber, lcv, valueList, category); - - // Convert valueList to array for the ListColumnVector.child - fillColumnVector(category, lcv, valueList, index); - } - - /** - * Reads a single value from parquet page, puts it into lastValue. Returns a boolean indicating - * if there is more values to read (true). - * - * @param category - * @return boolean - * @throws IOException - */ - private boolean fetchNextValue(LogicalType category) throws IOException { - int left = readPageIfNeed(); - if (left > 0) { - // get the values of repetition and definitionLevel - readRepetitionAndDefinitionLevels(); - // read the data if it isn't null - if (definitionLevel == maxDefLevel) { - if (isCurrentPageDictionaryEncoded) { - lastValue = dataColumn.readValueDictionaryId(); - } else { - lastValue = readPrimitiveTypedRow(category); - } - } else { - lastValue = null; - } - return true; - } else { - eof = true; - return false; - } - } - - private int readPageIfNeed() throws IOException { - // Compute the number of values we want to read in this page. - int leftInPage = (int) (endOfPageValueCount - valuesRead); - if (leftInPage == 0) { - // no data left in current page, load data from new page - readPage(); - leftInPage = (int) (endOfPageValueCount - valuesRead); - } - return leftInPage; - } - - // Need to be in consistent with that VectorizedPrimitiveColumnReader#readBatchHelper - // TODO Reduce the duplicated code - private Object readPrimitiveTypedRow(LogicalType category) { - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dataColumn.readString(); - case BOOLEAN: - return dataColumn.readBoolean(); - case TIME_WITHOUT_TIME_ZONE: - case DATE: - case INTEGER: - return dataColumn.readInteger(); - case TINYINT: - return dataColumn.readTinyInt(); - case SMALLINT: - return dataColumn.readSmallInt(); - case BIGINT: - return dataColumn.readLong(); - case FLOAT: - return dataColumn.readFloat(); - case DOUBLE: - return dataColumn.readDouble(); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dataColumn.readInteger(); - case INT64: - return dataColumn.readLong(); - case BINARY: - case FIXED_LEN_BYTE_ARRAY: - return dataColumn.readString(); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dataColumn.readTimestamp(); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { - if (dictionaryValue == null) { - return null; - } - - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dictionary.readString(dictionaryValue); - case DATE: - case TIME_WITHOUT_TIME_ZONE: - case INTEGER: - return dictionary.readInteger(dictionaryValue); - case BOOLEAN: - return dictionary.readBoolean(dictionaryValue) ? 1 : 0; - case DOUBLE: - return dictionary.readDouble(dictionaryValue); - case FLOAT: - return dictionary.readFloat(dictionaryValue); - case TINYINT: - return dictionary.readTinyInt(dictionaryValue); - case SMALLINT: - return dictionary.readSmallInt(dictionaryValue); - case BIGINT: - return dictionary.readLong(dictionaryValue); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dictionary.readInteger(dictionaryValue); - case INT64: - return dictionary.readLong(dictionaryValue); - case FIXED_LEN_BYTE_ARRAY: - case BINARY: - return dictionary.readString(dictionaryValue); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dictionary.readTimestamp(dictionaryValue); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - /** - * Collects data from a parquet page and returns the final row index where it stopped. The - * returned index can be equal to or less than total. - * - * @param total maximum number of rows to collect - * @param lcv column vector to do initial setup in data collection time - * @param valueList collection of values that will be fed into the vector later - * @param category - * @return int - * @throws IOException - */ - private int collectDataFromParquetPage( - int total, HeapArrayVector lcv, List valueList, LogicalType category) - throws IOException { - int index = 0; - /* - * Here is a nested loop for collecting all values from a parquet page. - * A column of array type can be considered as a list of lists, so the two loops are as below: - * 1. The outer loop iterates on rows (index is a row index, so points to a row in the batch), e.g.: - * [0, 2, 3] <- index: 0 - * [NULL, 3, 4] <- index: 1 - * - * 2. The inner loop iterates on values within a row (sets all data from parquet data page - * for an element in ListColumnVector), so fetchNextValue returns values one-by-one: - * 0, 2, 3, NULL, 3, 4 - * - * As described below, the repetition level (repetitionLevel != 0) - * can be used to decide when we'll start to read values for the next list. - */ - while (!eof && index < total) { - // add element to ListColumnVector one by one - lcv.offsets[index] = valueList.size(); - /* - * Let's collect all values for a single list. - * Repetition level = 0 means that a new list started there in the parquet page, - * in that case, let's exit from the loop, and start to collect value for a new list. - */ - do { - /* - * Definition level = 0 when a NULL value was returned instead of a list - * (this is not the same as a NULL value in of a list). - */ - if (definitionLevel == 0) { - lcv.setNullAt(index); - } - valueList.add( - isCurrentPageDictionaryEncoded - ? dictionaryDecodeValue(category, (Integer) lastValue) - : lastValue); - } while (fetchNextValue(category) && (repetitionLevel != 0)); - - lcv.lengths[index] = valueList.size() - lcv.offsets[index]; - index++; - } - return index; - } - - /** - * The lengths & offsets will be initialized as default size (1024), it should be set to the - * actual size according to the element number. - */ - private void setChildrenInfo(HeapArrayVector lcv, int itemNum, int elementNum) { - lcv.setSize(itemNum); - long[] lcvLength = new long[elementNum]; - long[] lcvOffset = new long[elementNum]; - System.arraycopy(lcv.lengths, 0, lcvLength, 0, elementNum); - System.arraycopy(lcv.offsets, 0, lcvOffset, 0, elementNum); - lcv.lengths = lcvLength; - lcv.offsets = lcvOffset; - } - - private void fillColumnVector( - LogicalType category, HeapArrayVector lcv, List valueList, int elementNum) { - int total = valueList.size(); - setChildrenInfo(lcv, total, elementNum); - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - lcv.child = new HeapBytesVector(total); - ((HeapBytesVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (src == null) { - ((HeapBytesVector) lcv.child).setNullAt(i); - } else { - ((HeapBytesVector) lcv.child).appendBytes(i, src, 0, src.length); - } - } - break; - case BOOLEAN: - lcv.child = new HeapBooleanVector(total); - ((HeapBooleanVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapBooleanVector) lcv.child).setNullAt(i); - } else { - ((HeapBooleanVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TINYINT: - lcv.child = new HeapByteVector(total); - ((HeapByteVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapByteVector) lcv.child).setNullAt(i); - } else { - ((HeapByteVector) lcv.child).vector[i] = - (byte) ((List) valueList).get(i).intValue(); - } - } - break; - case SMALLINT: - lcv.child = new HeapShortVector(total); - ((HeapShortVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapShortVector) lcv.child).setNullAt(i); - } else { - ((HeapShortVector) lcv.child).vector[i] = - (short) ((List) valueList).get(i).intValue(); - } - } - break; - case INTEGER: - case DATE: - case TIME_WITHOUT_TIME_ZONE: - lcv.child = new HeapIntVector(total); - ((HeapIntVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) lcv.child).setNullAt(i); - } else { - ((HeapIntVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case FLOAT: - lcv.child = new HeapFloatVector(total); - ((HeapFloatVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapFloatVector) lcv.child).setNullAt(i); - } else { - ((HeapFloatVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case BIGINT: - lcv.child = new HeapLongVector(total); - ((HeapLongVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) lcv.child).setNullAt(i); - } else { - ((HeapLongVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case DOUBLE: - lcv.child = new HeapDoubleVector(total); - ((HeapDoubleVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapDoubleVector) lcv.child).setNullAt(i); - } else { - ((HeapDoubleVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - lcv.child = new HeapTimestampVector(total); - ((HeapTimestampVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapTimestampVector) lcv.child).setNullAt(i); - } else { - ((HeapTimestampVector) lcv.child) - .setTimestamp(i, ((List) valueList).get(i)); - } - } - break; - case DECIMAL: - PrimitiveType.PrimitiveTypeName primitiveTypeName = - descriptor.getPrimitiveType().getPrimitiveTypeName(); - switch (primitiveTypeName) { - case INT32: - lcv.child = new ParquetDecimalVector(new HeapIntVector(total)); - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - case INT64: - lcv.child = new ParquetDecimalVector(new HeapLongVector(total)); - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - default: - lcv.child = new ParquetDecimalVector(new HeapBytesVector(total)); - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (valueList.get(i) == null) { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .appendBytes(i, src, 0, src.length); - } - } - break; - } - break; - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java deleted file mode 100644 index df7c5d85bc4ab..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java +++ /dev/null @@ -1,45 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Array of a Group type (Array, Map, Row, etc.) {@link ColumnReader}. - */ -public class ArrayGroupReader implements ColumnReader { - - private final ColumnReader fieldReader; - - public ArrayGroupReader(ColumnReader fieldReader) { - this.fieldReader = fieldReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayGroupColumnVector rowColumnVector = (HeapArrayGroupColumnVector) vector; - - fieldReader.readToVector(readNumber, rowColumnVector.vector); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java index 7c9fd994a0c25..700d7505fbc73 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java @@ -226,12 +226,7 @@ private void readPageV2(DataPageV2 page) { this.definitionLevelColumn = newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); try { - log.debug( - "page data size " - + page.getData().size() - + " bytes and " - + pageValueCount - + " records"); + log.debug("page data size {} bytes and {} records", page.getData().size(), pageValueCount); initDataReader( page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); } catch (IOException e) { diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java deleted file mode 100644 index 6d743530fccc7..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java +++ /dev/null @@ -1,55 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Map {@link ColumnReader}. - */ -public class MapColumnReader implements ColumnReader { - - private final ArrayColumnReader keyReader; - private final ColumnReader valueReader; - - public MapColumnReader( - ArrayColumnReader keyReader, ColumnReader valueReader) { - this.keyReader = keyReader; - this.valueReader = valueReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapMapColumnVector mapColumnVector = (HeapMapColumnVector) vector; - AbstractHeapVector keyArrayColumnVector = (AbstractHeapVector) (mapColumnVector.getKeys()); - keyReader.readToVector(readNumber, mapColumnVector.getKeys()); - valueReader.readToVector(readNumber, mapColumnVector.getValues()); - for (int i = 0; i < keyArrayColumnVector.getLen(); i++) { - if (keyArrayColumnVector.isNullAt(i)) { - mapColumnVector.setNullAt(i); - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java new file mode 100644 index 0000000000000..60575f148cc45 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.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.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.NestedPositionUtil; +import org.apache.hudi.table.format.cow.vector.HeapArrayVector; +import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; +import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; + +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.ArrayType; +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.util.FlinkRuntimeException; +import org.apache.flink.util.Preconditions; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.page.PageReadStore; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ColumnReader used to read a {@code Group} type in Parquet ({@code Map}, {@code Array}, {@code + * Row}). Resolves nested structures using Dremel striping/assembly; see the + * striping and assembly algorithms from the Dremel paper. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedColumnReader}). Differences vs. upstream: + * + *

      + *
    • Uses Hudi-local {@code HeapRowColumnVector}/{@code HeapMapColumnVector}/{@code + * HeapArrayVector} instead of the Flink-private {@code HeapRowVector}/{@code + * HeapMapVector}/{@code HeapArrayVector}. + *
    • Supports Hudi's schema-evolution contract: a {@code ParquetGroupField} representing a + * {@link RowType} may contain {@code null} children — meaning the corresponding logical + * field is absent from the Parquet file. Those slots are passed through unchanged and do + * not contribute to the row's repetition/definition-level stream. + *
    + */ +public class NestedColumnReader implements ColumnReader { + + private final Map columnReaders; + private final boolean isUtcTimestamp; + + private final PageReadStore pages; + + private final ParquetField field; + + public NestedColumnReader(boolean isUtcTimestamp, PageReadStore pages, ParquetField field) { + this.isUtcTimestamp = isUtcTimestamp; + this.pages = pages; + this.field = field; + this.columnReaders = new HashMap<>(); + } + + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + readData(field, readNumber, vector, false); + } + + private Tuple2 readData( + ParquetField field, int readNumber, ColumnVector vector, boolean inside) throws IOException { + if (field.getType() instanceof RowType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof MapType || field.getType() instanceof MultisetType) { + return readMap((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof ArrayType) { + return readArray((ParquetGroupField) field, readNumber, vector, inside); + } else { + return readPrimitive((ParquetPrimitiveField) field, readNumber, vector); + } + } + + private Tuple2 readRow( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapRowColumnVector heapRowVector = (HeapRowColumnVector) vector; + LevelDelegation levelDelegation = null; + List children = field.getChildren(); + WritableColumnVector[] childrenVectors = heapRowVector.getFields(); + WritableColumnVector[] finalChildrenVectors = new WritableColumnVector[childrenVectors.length]; + for (int i = 0; i < children.size(); i++) { + ParquetField child = children.get(i); + if (child == null) { + // Schema-evolution: the logical field is not present in the Parquet file. The slot + // vector was pre-populated with nulls by ParquetSplitReaderUtil#createWritableColumnVector + // (ROW branch), but HeapRowColumnVector#reset() (invoked once per batch by + // ParquetColumnarRowSplitReader#nextBatch) cascades to the children and clears those null + // flags. Since an absent field is never re-read, re-apply the nulls here so the column stays + // NULL instead of reverting to the type's zero value. Skip contributing to the level stream. + childrenVectors[i].fillWithNulls(); + finalChildrenVectors[i] = childrenVectors[i]; + continue; + } + Tuple2 tuple = + readData(child, readNumber, childrenVectors[i], true); + levelDelegation = tuple.f0; + finalChildrenVectors[i] = tuple.f1; + } + if (levelDelegation == null) { + throw new FlinkRuntimeException( + String.format("Row field does not have any non-null children: %s.", field)); + } + + RowPosition rowPosition = + NestedPositionUtil.calculateRowOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If row was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + heapRowVector = new HeapRowColumnVector(rowPosition.getPositionsCount(), finalChildrenVectors); + } else { + heapRowVector.setFields(finalChildrenVectors); + } + + if (rowPosition.getIsNull() != null) { + setFieldNullFlag(rowPosition.getIsNull(), heapRowVector); + } + + // Hudi-specific: collapse a present row whose every child is null into a null row, so that a + // SQL value like `row(null, null)` round-trips to NULL on read. This was the behaviour of the + // legacy RowColumnReader (deleted alongside the Dremel rewire) and existing Hudi tables rely + // on it. Diverges from Flink 2.1, which would surface it as Row(null, null). Pinned by the + // integration test ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes. + // positionsCount comes from the Dremel definition/repetition level stream + // (NestedPositionUtil#calculateRowOffsets). On a full, non-final batch that stream carries a + // one-record lookahead (NestedPrimitiveColumnReader#readAndNewVector reads one value past the + // batch in its do/while, and #getLevelDelegation keeps that trailing level for the next batch), + // so positionsCount can be one larger than the materialized vector lengths. When inside==true + // the row vector is renewed to positionsCount but its children are sized to their value count; + // when inside==false the row vector keeps its batch capacity. Either way, iterating all the way + // to positionsCount can read one element past a shorter vector and throw + // ArrayIndexOutOfBoundsException. Clamp to the shortest vector this loop indexes -- the phantom + // trailing position is never surfaced downstream (ParquetColumnarRowSplitReader caps the batch + // at num). + int rowCount = Math.min(rowPosition.getPositionsCount(), heapRowVector.getLen()); + for (WritableColumnVector child : finalChildrenVectors) { + rowCount = Math.min(rowCount, vectorLength(child)); + } + for (int j = 0; j < rowCount; j++) { + if (heapRowVector.isNullAt(j)) { + continue; + } + boolean allChildrenNull = true; + for (WritableColumnVector child : finalChildrenVectors) { + if (!child.isNullAt(j)) { + allChildrenNull = false; + break; + } + } + if (allChildrenNull) { + heapRowVector.setNullAt(j); + } + } + return Tuple2.of(levelDelegation, heapRowVector); + } + + private Tuple2 readMap( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapMapColumnVector mapVector = (HeapMapColumnVector) vector; + mapVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 2, + "Maps must have two type parameters, found %s", + children.size()); + Tuple2 keyTuple = + readData(children.get(0), readNumber, mapVector.getKeyColumnVector(), true); + Tuple2 valueTuple = + readData(children.get(1), readNumber, mapVector.getValueColumnVector(), true); + + LevelDelegation levelDelegation = keyTuple.f0; + + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If map was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + mapVector = new HeapMapColumnVector(collectionPosition.getValueCount(), keyTuple.f1, valueTuple.f1); + } else { + mapVector.setKeys(keyTuple.f1); + mapVector.setValues(valueTuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), mapVector); + } + + mapVector.setLengths(collectionPosition.getLength()); + mapVector.setOffsets(collectionPosition.getOffsets()); + + return Tuple2.of(levelDelegation, mapVector); + } + + private Tuple2 readArray( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapArrayVector arrayVector = (HeapArrayVector) vector; + arrayVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 1, + "Arrays must have a single type parameter, found %s", + children.size()); + Tuple2 tuple = + readData(children.get(0), readNumber, arrayVector.getChild(), true); + + LevelDelegation levelDelegation = tuple.f0; + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If array was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + arrayVector = new HeapArrayVector(collectionPosition.getValueCount(), tuple.f1); + } else { + arrayVector.setChild(tuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), arrayVector); + } + arrayVector.setLengths(collectionPosition.getLength()); + arrayVector.setOffsets(collectionPosition.getOffsets()); + return Tuple2.of(levelDelegation, arrayVector); + } + + private Tuple2 readPrimitive( + ParquetPrimitiveField field, int readNumber, ColumnVector vector) throws IOException { + ColumnDescriptor descriptor = field.getDescriptor(); + NestedPrimitiveColumnReader reader = columnReaders.get(descriptor); + if (reader == null) { + reader = + new NestedPrimitiveColumnReader( + descriptor, + pages.getPageReader(descriptor), + isUtcTimestamp, + descriptor.getPrimitiveType(), + field.getType()); + columnReaders.put(descriptor, reader); + } + WritableColumnVector writableColumnVector = + reader.readAndNewVector(readNumber, (WritableColumnVector) vector); + return Tuple2.of(reader.getLevelDelegation(), writableColumnVector); + } + + /** + * The length of the {@code isNull}-backed storage that {@code vector} (a row child) is indexed + * against by the null-collapse loop in {@link #readRow}. Every row child is an {@link + * AbstractHeapVector} (nested rows/arrays/maps and all non-decimal primitives) or a {@link + * ParquetDecimalVector} wrapping one (DECIMAL leaves; see {@code + * NestedPrimitiveColumnReader#fillColumnVector}); unwrapping the latter yields an {@code + * AbstractHeapVector} in all cases. + */ + private static int vectorLength(ColumnVector vector) { + ColumnVector storage = + vector instanceof ParquetDecimalVector + ? ((ParquetDecimalVector) vector).getVector() + : vector; + return ((AbstractHeapVector) storage).getLen(); + } + + private static void setFieldNullFlag(boolean[] nullFlags, AbstractHeapVector vector) { + for (int index = 0; index < vector.getLen() && index < nullFlags.length; index++) { + if (nullFlags[index]) { + vector.setNullAt(index); + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java new file mode 100644 index 0000000000000..a18520c3b5cd5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java @@ -0,0 +1,638 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.IntArrayList; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; + +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +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.DictionaryPage; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.parquet.column.ValuesType.DEFINITION_LEVEL; +import static org.apache.parquet.column.ValuesType.REPETITION_LEVEL; +import static org.apache.parquet.column.ValuesType.VALUES; + +/** + * Reader to read a single primitive leaf column that participates in a nested (Dremel) structure. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedPrimitiveColumnReader}). Only the package + * and the Hudi-local {@link ParquetDecimalVector} / {@link LevelDelegation} / {@link IntArrayList} + * imports are changed; the algorithm is untouched. The companion Hudi-specific {@code + * Int64TimestampColumnReader} / {@code FixedLenBytesColumnReader} behaviours stay at the leaf- + * reader creation boundary in {@code ParquetSplitReaderUtil}, not inside this class — keeping it + * a faithful copy of upstream. + */ +public class NestedPrimitiveColumnReader implements ColumnReader { + private static final Logger LOG = LoggerFactory.getLogger(NestedPrimitiveColumnReader.class); + + private final IntArrayList repetitionLevelList = new IntArrayList(0); + private final IntArrayList definitionLevelList = new IntArrayList(0); + + private final PageReader pageReader; + private final ColumnDescriptor descriptor; + private final Type type; + private final LogicalType logicalType; + + /** The dictionary, if this column has dictionary encoding. */ + private final ParquetDataColumnReader dictionary; + + /** Maximum definition level for this column. */ + private final int maxDefLevel; + + private boolean isUtcTimestamp; + + /** Total number of values read. */ + private long valuesRead; + + /** + * value that indicates the end of the current page. That is, if valuesRead == + * endOfPageValueCount, we are at the end of the page. + */ + private long endOfPageValueCount; + + /** If true, the current page is dictionary encoded. */ + private boolean isCurrentPageDictionaryEncoded; + + private int definitionLevel; + private int repetitionLevel; + + /** Repetition/Definition/Value readers. */ + private IntIterator repetitionLevelColumn; + + private IntIterator definitionLevelColumn; + private ParquetDataColumnReader dataColumn; + + /** Total values in the current page. */ + private int pageValueCount; + + // flag to indicate if there is no data in parquet data page + private boolean eof = false; + + private boolean isFirstRow = true; + + private Object lastValue; + + public NestedPrimitiveColumnReader( + ColumnDescriptor descriptor, + PageReader pageReader, + boolean isUtcTimestamp, + Type parquetType, + LogicalType logicalType) + throws IOException { + this.descriptor = descriptor; + this.type = parquetType; + this.pageReader = pageReader; + this.maxDefLevel = descriptor.getMaxDefinitionLevel(); + this.isUtcTimestamp = isUtcTimestamp; + this.logicalType = logicalType; + + DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); + if (dictionaryPage != null) { + try { + this.dictionary = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + parquetType.asPrimitiveType(), + dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } catch (IOException e) { + throw new IOException( + String.format("Could not decode the dictionary for %s", descriptor), e); + } + } else { + this.dictionary = null; + this.isCurrentPageDictionaryEncoded = false; + } + } + + // Not invoked directly; callers use readAndNewVector instead. + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + throw new UnsupportedOperationException("This function should not be called."); + } + + public WritableColumnVector readAndNewVector(int readNumber, WritableColumnVector vector) + throws IOException { + if (isFirstRow) { + if (!readValue()) { + return vector; + } + isFirstRow = false; + } + + // index to set value. + int index = 0; + int valueIndex = 0; + List valueList = new ArrayList<>(); + + // repeated type need two loops to read data. + while (!eof && index < readNumber) { + do { + valueList.add(lastValue); + valueIndex++; + } while (readValue() && (repetitionLevel != 0)); + index++; + } + + return fillColumnVector(valueIndex, valueList); + } + + public LevelDelegation getLevelDelegation() { + int[] repetition = repetitionLevelList.toArray(); + int[] definition = definitionLevelList.toArray(); + repetitionLevelList.clear(); + definitionLevelList.clear(); + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + return new LevelDelegation(repetition, definition); + } + + private boolean readValue() throws IOException { + int left = readPageIfNeed(); + if (left > 0) { + // get the values of repetition and definitionLevel + readAndSaveRepetitionAndDefinitionLevels(); + // read the data if it isn't null + if (definitionLevel == maxDefLevel) { + if (isCurrentPageDictionaryEncoded) { + int dictionaryId = dataColumn.readValueDictionaryId(); + lastValue = dictionaryDecodeValue(logicalType, dictionaryId); + } else { + lastValue = readPrimitiveTypedRow(logicalType); + } + } else { + lastValue = null; + } + return true; + } else { + eof = true; + return false; + } + } + + private void readAndSaveRepetitionAndDefinitionLevels() { + // get the values of repetition and definitionLevel + repetitionLevel = repetitionLevelColumn.nextInt(); + definitionLevel = definitionLevelColumn.nextInt(); + valuesRead++; + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + } + + private int readPageIfNeed() throws IOException { + // Compute the number of values we want to read in this page. + int leftInPage = (int) (endOfPageValueCount - valuesRead); + if (leftInPage == 0) { + // no data left in current page, load data from new page + readPage(); + leftInPage = (int) (endOfPageValueCount - valuesRead); + } + return leftInPage; + } + + private Object readPrimitiveTypedRow(LogicalType category) { + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dataColumn.readBytes(); + case BOOLEAN: + return dataColumn.readBoolean(); + case TIME_WITHOUT_TIME_ZONE: + case DATE: + case INTEGER: + return dataColumn.readInteger(); + case TINYINT: + return dataColumn.readTinyInt(); + case SMALLINT: + return dataColumn.readSmallInt(); + case BIGINT: + return dataColumn.readLong(); + case FLOAT: + return dataColumn.readFloat(); + case DOUBLE: + return dataColumn.readDouble(); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dataColumn.readInteger(); + case INT64: + return dataColumn.readLong(); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return dataColumn.readBytes(); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dataColumn.readTimestamp(); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { + if (dictionaryValue == null) { + return null; + } + + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dictionary.readBytes(dictionaryValue); + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTEGER: + return dictionary.readInteger(dictionaryValue); + case BOOLEAN: + return dictionary.readBoolean(dictionaryValue) ? 1 : 0; + case DOUBLE: + return dictionary.readDouble(dictionaryValue); + case FLOAT: + return dictionary.readFloat(dictionaryValue); + case TINYINT: + return dictionary.readTinyInt(dictionaryValue); + case SMALLINT: + return dictionary.readSmallInt(dictionaryValue); + case BIGINT: + return dictionary.readLong(dictionaryValue); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dictionary.readInteger(dictionaryValue); + case INT64: + return dictionary.readLong(dictionaryValue); + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return dictionary.readBytes(dictionaryValue); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dictionary.readTimestamp(dictionaryValue); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WritableColumnVector fillColumnVector(int total, List valueList) { + switch (logicalType.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + HeapBytesVector heapBytesVector = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (src == null) { + heapBytesVector.setNullAt(i); + } else { + heapBytesVector.appendBytes(i, src, 0, src.length); + } + } + return heapBytesVector; + case BOOLEAN: + HeapBooleanVector heapBooleanVector = new HeapBooleanVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapBooleanVector.setNullAt(i); + } else { + heapBooleanVector.vector[i] = ((List) valueList).get(i); + } + } + return heapBooleanVector; + case TINYINT: + HeapByteVector heapByteVector = new HeapByteVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapByteVector.setNullAt(i); + } else { + heapByteVector.vector[i] = (byte) ((List) valueList).get(i).intValue(); + } + } + return heapByteVector; + case SMALLINT: + HeapShortVector heapShortVector = new HeapShortVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapShortVector.setNullAt(i); + } else { + heapShortVector.vector[i] = (short) ((List) valueList).get(i).intValue(); + } + } + return heapShortVector; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + HeapIntVector heapIntVector = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapIntVector.setNullAt(i); + } else { + heapIntVector.vector[i] = ((List) valueList).get(i); + } + } + return heapIntVector; + case FLOAT: + HeapFloatVector heapFloatVector = new HeapFloatVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapFloatVector.setNullAt(i); + } else { + heapFloatVector.vector[i] = ((List) valueList).get(i); + } + } + return heapFloatVector; + case BIGINT: + HeapLongVector heapLongVector = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapLongVector.setNullAt(i); + } else { + heapLongVector.vector[i] = ((List) valueList).get(i); + } + } + return heapLongVector; + case DOUBLE: + HeapDoubleVector heapDoubleVector = new HeapDoubleVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapDoubleVector.setNullAt(i); + } else { + heapDoubleVector.vector[i] = ((List) valueList).get(i); + } + } + return heapDoubleVector; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + HeapTimestampVector heapTimestampVector = new HeapTimestampVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapTimestampVector.setNullAt(i); + } else { + heapTimestampVector.setTimestamp(i, ((List) valueList).get(i)); + } + } + return heapTimestampVector; + case DECIMAL: + PrimitiveType.PrimitiveTypeName primitiveTypeName = + descriptor.getPrimitiveType().getPrimitiveTypeName(); + switch (primitiveTypeName) { + case INT32: + HeapIntVector phiv = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phiv.setNullAt(i); + } else { + phiv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phiv); + case INT64: + HeapLongVector phlv = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phlv.setNullAt(i); + } else { + phlv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phlv); + default: + HeapBytesVector phbv = getHeapBytesVector(total, valueList); + return new ParquetDecimalVector(phbv); + } + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HeapBytesVector getHeapBytesVector(int total, List valueList) { + HeapBytesVector phbv = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (valueList.get(i) == null) { + phbv.setNullAt(i); + } else { + phbv.appendBytes(i, src, 0, src.length); + } + } + return phbv; + } + + protected void readPage() { + DataPage page = pageReader.readPage(); + + if (page == null) { + return; + } + + page.accept( + new DataPage.Visitor() { + @Override + public Void visit(DataPageV1 dataPageV1) { + readPageV1(dataPageV1); + return null; + } + + @Override + public Void visit(DataPageV2 dataPageV2) { + readPageV2(dataPageV2); + return null; + } + }); + } + + private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int valueCount) + throws IOException { + this.pageValueCount = valueCount; + this.endOfPageValueCount = valuesRead + pageValueCount; + if (dataEncoding.usesDictionary()) { + this.dataColumn = null; + if (dictionary == null) { + throw new IOException( + String.format( + "Could not read page in col %s because the dictionary was missing for encoding %s.", + descriptor, dataEncoding)); + } + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getDictionaryBasedValuesReader( + descriptor, VALUES, dictionary.getDictionary()), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } else { + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getValuesReader(descriptor, VALUES), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = false; + } + + try { + dataColumn.initFromPage(pageValueCount, in); + } catch (IOException e) { + throw new IOException(String.format("Could not read page in col %s.", descriptor), e); + } + } + + private void readPageV1(DataPageV1 page) { + ValuesReader rlReader = page.getRlEncoding().getValuesReader(descriptor, REPETITION_LEVEL); + ValuesReader dlReader = page.getDlEncoding().getValuesReader(descriptor, DEFINITION_LEVEL); + this.repetitionLevelColumn = new ValuesReaderIntIterator(rlReader); + this.definitionLevelColumn = new ValuesReaderIntIterator(dlReader); + try { + BytesInput bytes = page.getBytes(); + LOG.debug("Page size {} bytes and {} records.", bytes.size(), pageValueCount); + ByteBufferInputStream in = bytes.toInputStream(); + LOG.debug("Reading repetition levels at {}.", in.position()); + rlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading definition levels at {}.", in.position()); + dlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading data at {}.", in.position()); + initDataReader(page.getValueEncoding(), in, page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private void readPageV2(DataPageV2 page) { + this.pageValueCount = page.getValueCount(); + this.repetitionLevelColumn = + newRLEIterator(descriptor.getMaxRepetitionLevel(), page.getRepetitionLevels()); + this.definitionLevelColumn = + newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); + try { + LOG.debug( + "Page data size {} bytes and {} records.", page.getData().size(), pageValueCount); + initDataReader( + page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private IntIterator newRLEIterator(int maxLevel, BytesInput bytes) { + try { + if (maxLevel == 0) { + return new NullIntIterator(); + } + return new RLEIntIterator( + new RunLengthBitPackingHybridDecoder( + BytesUtils.getWidthFromMaxInt(maxLevel), + new ByteArrayInputStream(bytes.toByteArray()))); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read levels in page for col %s.", descriptor), e); + } + } + + /** Utility interface to abstract over different way to read ints with different encodings. */ + interface IntIterator { + int nextInt(); + } + + /** Reading int from {@link ValuesReader}. */ + protected static final class ValuesReaderIntIterator implements IntIterator { + ValuesReader delegate; + + public ValuesReaderIntIterator(ValuesReader delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + return delegate.readInteger(); + } + } + + /** Reading int from {@link RunLengthBitPackingHybridDecoder}. */ + protected static final class RLEIntIterator implements IntIterator { + RunLengthBitPackingHybridDecoder delegate; + + public RLEIntIterator(RunLengthBitPackingHybridDecoder delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + try { + return delegate.readInt(); + } catch (IOException e) { + throw new ParquetDecodingException(e); + } + } + } + + /** Reading zero always. */ + protected static final class NullIntIterator implements IntIterator { + @Override + public int nextInt() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java index 3572b117a6313..1826419db5d44 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java @@ -18,7 +18,9 @@ package org.apache.hudi.table.format.cow.vector.reader; +import org.apache.hudi.table.format.cow.ParquetSplitReaderUtil; import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; import org.apache.flink.formats.parquet.vector.reader.ColumnReader; import org.apache.flink.table.data.RowData; @@ -28,6 +30,7 @@ import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.util.FlinkRuntimeException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -39,6 +42,8 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.Type; @@ -46,6 +51,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,7 +59,6 @@ import java.util.Map; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createColumnReader; import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createWritableColumnVector; import static org.apache.parquet.filter2.compat.FilterCompat.get; import static org.apache.parquet.filter2.compat.RowGroupFilter.filterRowGroups; @@ -77,6 +82,14 @@ public class ParquetColumnarRowSplitReader implements Closeable { private final MessageType requestedSchema; + /** + * {@link ParquetField} tree per top-level requested column, used by + * {@link ParquetSplitReaderUtil#createColumnReader(boolean, LogicalType, Type, List, + * PageReadStore, ParquetField)} to drive the Dremel-style {@link NestedColumnReader} for + * nested types. Entries are {@code null} for primitive top-level fields. Built once per split. + */ + private final List requestedFields; + /** * The total number of rows this RecordReader will eventually read. The sum of the rows of all * the row groups. @@ -158,6 +171,20 @@ public ParquetColumnarRowSplitReader( checkSchema(); + // Build the ParquetField tree once per split (the Dremel-style nested reader reuses it across + // row groups). Only columns with nested logical type get a non-null entry — primitive columns + // still use Hudi's specialized ColumnReaders. + MessageColumnIO messageColumnIO = new ColumnIOFactory().getColumnIO(requestedSchema); + List requestedRowFields = new ArrayList<>(requestedTypes.length); + List requestedFieldNames = new ArrayList<>(requestedTypes.length); + for (int i = 0; i < requestedTypes.length; i++) { + String name = requestedSchema.getFieldName(i); + requestedRowFields.add(new RowType.RowField(name, requestedTypes[i])); + requestedFieldNames.add(name); + } + this.requestedFields = ParquetSplitReaderUtil.buildFieldsList( + requestedRowFields, requestedFieldNames, messageColumnIO); + this.writableVectors = createWritableVectors(); ColumnVector[] columnVectors = patchedVector(selectedFieldNames.length, createReadableVectors(), requestedIndices); this.columnarBatch = generator.generate(columnVectors); @@ -340,12 +367,13 @@ private void readNextRowGroup() throws IOException { List columns = requestedSchema.getColumns(); columnReaders = new ColumnReader[types.size()]; for (int i = 0; i < types.size(); ++i) { - columnReaders[i] = createColumnReader( + columnReaders[i] = ParquetSplitReaderUtil.createColumnReader( utcTimestamp, requestedTypes[i], types.get(i), columns, - pages); + pages, + requestedFields.get(i)); } totalCountLoadedSoFar += pages.getRowCount(); } diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java index fdfe5d6fa3a33..1abc6ed56c0db 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java @@ -26,12 +26,16 @@ import org.apache.parquet.column.Dictionary; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; @@ -252,21 +256,115 @@ public TimestampData readTimestamp() { } } + /** + * Reader for Parquet INT64 timestamp values (MILLIS / MICROS / NANOS), i.e. the standard + * timestamp encoding defined by Parquet's + * {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and the legacy + * {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} annotations. + * (The older INT96 encoding is marked deprecated by the Parquet format spec — see + * + * LogicalTypes.md — but is still supported here via {@link TypesFromInt96PageReader} for + * backwards compatibility with files written by older Hive / Spark / Impala versions.) + * + *

    Used by {@link NestedPrimitiveColumnReader} when a TIMESTAMP column sits inside a + * {@code Row}, {@code Array} or {@code Map}; the top-level path continues to use + * {@link Int64TimestampColumnReader} for batched-vector efficiency. + */ + public static class TypesFromInt64PageReader extends DefaultParquetDataColumnReader { + private final boolean isUtcTimestamp; + private final ChronoUnit chronoUnit; + + public TypesFromInt64PageReader( + ValuesReader realReader, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(realReader); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + public TypesFromInt64PageReader( + Dictionary dict, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(dict); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + @Override + public TimestampData readTimestamp() { + return int64ToTimestamp(isUtcTimestamp, valuesReader.readLong(), chronoUnit); + } + + @Override + public TimestampData readTimestamp(int id) { + return int64ToTimestamp(isUtcTimestamp, dict.decodeToLong(id), chronoUnit); + } + } + private static ParquetDataColumnReader getDataColumnReaderByTypeHelper( boolean isDictionary, PrimitiveType parquetType, Dictionary dictionary, ValuesReader valuesReader, boolean isUtcTimestamp) { - if (parquetType.getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.INT96) { + PrimitiveType.PrimitiveTypeName typeName = parquetType.getPrimitiveTypeName(); + if (typeName == PrimitiveType.PrimitiveTypeName.INT96) { return isDictionary ? new TypesFromInt96PageReader(dictionary, isUtcTimestamp) : new TypesFromInt96PageReader(valuesReader, isUtcTimestamp); - } else { - return isDictionary - ? new DefaultParquetDataColumnReader(dictionary) - : new DefaultParquetDataColumnReader(valuesReader); } + if (typeName == PrimitiveType.PrimitiveTypeName.INT64) { + ChronoUnit unit = resolveInt64TimestampUnit(parquetType); + if (unit != null) { + return isDictionary + ? new TypesFromInt64PageReader(dictionary, isUtcTimestamp, unit) + : new TypesFromInt64PageReader(valuesReader, isUtcTimestamp, unit); + } + } + return isDictionary + ? new DefaultParquetDataColumnReader(dictionary) + : new DefaultParquetDataColumnReader(valuesReader); + } + + /** + * Returns the {@link ChronoUnit} for a Parquet INT64 TIMESTAMP column, or {@code null} if the + * column is a plain INT64 (not a timestamp). + * + *

    Supports both the modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and + * the legacy {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} + * encodings. + */ + private static ChronoUnit resolveInt64TimestampUnit(PrimitiveType parquetType) { + LogicalTypeAnnotation annotation = parquetType.getLogicalTypeAnnotation(); + if (annotation instanceof LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) { + LogicalTypeAnnotation.TimeUnit unit = + ((LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation).getUnit(); + switch (unit) { + case MILLIS: + return ChronoUnit.MILLIS; + case MICROS: + return ChronoUnit.MICROS; + case NANOS: + return ChronoUnit.NANOS; + default: + return null; + } + } + OriginalType originalType = parquetType.getOriginalType(); + if (originalType == OriginalType.TIMESTAMP_MILLIS) { + return ChronoUnit.MILLIS; + } + if (originalType == OriginalType.TIMESTAMP_MICROS) { + return ChronoUnit.MICROS; + } + return null; + } + + private static TimestampData int64ToTimestamp( + boolean isUtcTimestamp, long value, ChronoUnit unit) { + Instant instant = Instant.EPOCH.plus(value, unit); + if (isUtcTimestamp) { + return TimestampData.fromInstant(instant); + } + return TimestampData.fromTimestamp(Timestamp.from(instant)); } public static ParquetDataColumnReader getDataColumnReaderByTypeOnDictionary( @@ -281,10 +379,10 @@ public static ParquetDataColumnReader getDataColumnReaderByType( } private static TimestampData int96ToTimestamp( - boolean utcTimestamp, long nanosOfDay, int julianDay) { + boolean isUtcTimestamp, long nanosOfDay, int julianDay) { long millisecond = julianDayToMillis(julianDay) + (nanosOfDay / NANOS_PER_MILLISECOND); - if (utcTimestamp) { + if (isUtcTimestamp) { int nanoOfMillisecond = (int) (nanosOfDay % NANOS_PER_MILLISECOND); return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); } else { diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java deleted file mode 100644 index 79b50487f13c1..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; -import java.util.List; - -/** - * Row {@link ColumnReader}. - */ -public class RowColumnReader implements ColumnReader { - - private final List fieldReaders; - - public RowColumnReader(List fieldReaders) { - this.fieldReaders = fieldReaders; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapRowColumnVector rowColumnVector = (HeapRowColumnVector) vector; - WritableColumnVector[] vectors = rowColumnVector.vectors; - // row vector null array - boolean[] isNulls = new boolean[readNumber]; - for (int i = 0; i < vectors.length; i++) { - fieldReaders.get(i).readToVector(readNumber, vectors[i]); - - for (int j = 0; j < readNumber; j++) { - if (i == 0) { - isNulls[j] = vectors[i].isNullAt(j); - } else { - isNulls[j] = isNulls[j] && vectors[i].isNullAt(j); - } - if (i == vectors.length - 1 && isNulls[j]) { - // rowColumnVector[j] is null only when all fields[j] of rowColumnVector[j] is - // null - rowColumnVector.setNullAt(j); - } - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java new file mode 100644 index 0000000000000..0f5e00779a2f5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +/** + * Field that represent parquet's field type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetField}). + */ +public abstract class ParquetField { + private final LogicalType type; + private final int repetitionLevel; + private final int definitionLevel; + private final boolean required; + + public ParquetField( + LogicalType type, int repetitionLevel, int definitionLevel, boolean required) { + this.type = type; + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + this.required = required; + } + + public LogicalType getType() { + return type; + } + + public int getRepetitionLevel() { + return repetitionLevel; + } + + public int getDefinitionLevel() { + return definitionLevel; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "Field{" + + "type=" + + type + + ", repetitionLevel=" + + repetitionLevel + + ", definitionLevel=" + + definitionLevel + + ", required=" + + required + + '}'; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java new file mode 100644 index 0000000000000..f91dcca965d64 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's Group Field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetGroupField}) with a Hudi-specific extension: + * entries in the {@code children} list may be {@code null} to denote a Row child that is absent + * from the parquet file but present in the requested logical schema (schema evolution). This + * replaces Hudi's previous {@code EmptyColumnReader} branch for Row subtrees. + */ +public class ParquetGroupField extends ParquetField { + + private final List children; + + public ParquetGroupField( + LogicalType type, + int repetitionLevel, + int definitionLevel, + boolean required, + List children) { + super(type, repetitionLevel, definitionLevel, required); + // Use a plain unmodifiable list (not ImmutableList) so that null entries are allowed for + // schema-evolution missing children in ROW types. + this.children = + Collections.unmodifiableList(new ArrayList<>(requireNonNull(children, "children is null"))); + } + + /** Children of this group. Entries may be {@code null} for absent-in-file Row fields. */ + public List getChildren() { + return children; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java new file mode 100644 index 0000000000000..f6af6f9ff479e --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.column.ColumnDescriptor; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's primitive field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetPrimitiveField}). + */ +public class ParquetPrimitiveField extends ParquetField { + + private final ColumnDescriptor descriptor; + private final int id; + + public ParquetPrimitiveField( + LogicalType type, boolean required, ColumnDescriptor descriptor, int id) { + super( + type, + descriptor.getMaxRepetitionLevel(), + descriptor.getMaxDefinitionLevel(), + required); + this.descriptor = requireNonNull(descriptor, "descriptor is required"); + this.id = id; + } + + public ColumnDescriptor getDescriptor() { + return descriptor; + } + + public int getId() { + return id; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java index 1a256ffe7bba6..aac9a4651aff3 100644 --- a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java @@ -34,6 +34,9 @@ public class KryoSerializerSnapshot implements TypeSerializerSnapshot { private Class type; + public KryoSerializerSnapshot() { + } + public KryoSerializerSnapshot(Class type) { this.type = type; } diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..ae2e4107d6ea7 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + throw new UnsupportedOperationException("Variant is not supported yet."); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java new file mode 100644 index 0000000000000..aff1c32917cf9 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests for the Flink 2.1-compatible accessors added on {@link HeapArrayVector}, + * {@link HeapMapColumnVector} and {@link HeapRowColumnVector} when vendoring Flink 2.1's + * nested-Parquet reader (FLINK-35702). + * + *

    The accessors are wrappers over the existing public fields so legacy callers continue to + * work. These tests exist solely to pin down that wrapper contract — runtime correctness of the + * Dremel-style read path is exercised end-to-end by integration tests in + * {@code ITTestHoodieDataSource} (testParquetComplexTypes / testParquetComplexNestedRowTypes / + * testParquetArrayMapOfRowTypes / testParquetNullChildColumnsRowTypes). + */ +class TestHeapColumnVectorAccessors { + + // ----------------------------------------------------------------------------------------------- + // HeapArrayVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapArrayVectorAccessorsReflectPublicFields() { + HeapIntVector child = new HeapIntVector(4); + HeapArrayVector vector = new HeapArrayVector(2, child); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector replacementChild = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setChild(replacementChild); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(replacementChild, vector.getChild()); + assertEquals(2, vector.getSize()); + + // Backing public fields are kept in sync — preserves backward compatibility. + assertSame(offsets, vector.offsets); + assertSame(lengths, vector.lengths); + assertSame(replacementChild, vector.child); + } + + // ----------------------------------------------------------------------------------------------- + // HeapMapColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapMapColumnVectorConstructorInitializesOffsetsAndLengths() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + + HeapMapColumnVector vector = new HeapMapColumnVector(3, keys, values); + + assertEquals(3, vector.getOffsets().length); + assertEquals(3, vector.getLengths().length); + } + + @Test + void heapMapColumnVectorAccessorsReflectInternalState() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + HeapMapColumnVector vector = new HeapMapColumnVector(2, keys, values); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector newKeys = new HeapLongVector(4); + HeapLongVector newValues = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setKeys(newKeys); + vector.setValues(newValues); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(newKeys, vector.getKeys()); + assertSame(newValues, vector.getValues()); + // The Flink-2.1-style ColumnVector accessors return the same underlying child. + assertSame(newKeys, vector.getKeyColumnVector()); + assertSame(newValues, vector.getValueColumnVector()); + assertEquals(2, vector.getSize()); + } + + // ----------------------------------------------------------------------------------------------- + // HeapRowColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapRowColumnVectorFieldsAccessorsReflectPublicVectors() { + HeapIntVector intField = new HeapIntVector(2); + HeapLongVector longField = new HeapLongVector(2); + HeapRowColumnVector vector = new HeapRowColumnVector(2, intField, longField); + + WritableColumnVector[] originalFields = vector.getFields(); + assertEquals(2, originalFields.length); + assertSame(intField, originalFields[0]); + assertSame(longField, originalFields[1]); + // Backing public field is kept in sync — preserves backward compatibility. + assertSame(originalFields, vector.vectors); + + HeapIntVector replacement = new HeapIntVector(2); + WritableColumnVector[] replacementFields = {replacement, longField}; + vector.setFields(replacementFields); + + assertSame(replacementFields, vector.getFields()); + assertSame(replacementFields, vector.vectors); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java new file mode 100644 index 0000000000000..04f5809b9dac4 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.columnar.vector.BytesColumnVector; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ParquetDecimalVector}. + */ +public class TestParquetDecimalVector { + + @Test + void testGetDecimalFromInt32Vector() { + // precision <= 9 => ParquetSchemaConverter.is32BitDecimal(precision) == true + HeapIntVector intVector = new HeapIntVector(1); + intVector.vector[0] = 12345; + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(new BigDecimal("123.45"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromInt64Vector() { + // 9 < precision <= 18 => ParquetSchemaConverter.is64BitDecimal(precision) == true + HeapLongVector longVector = new HeapLongVector(1); + longVector.vector[0] = 1234567890123456L; + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + DecimalData decoded = wrapped.getDecimal(0, 18, 4); + + assertEquals(new BigDecimal("123456789012.3456"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtLargePrecision() { + // precision > 18 => BINARY / FIXED_LEN_BYTE_ARRAY path + BigDecimal original = new BigDecimal("12345678901234567890.1234567890"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 30, 10); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtSmallPrecision() { + // A Parquet file can legally encode a small-precision decimal as BINARY. In that case the + // dispatch must fall through to the bytes branch rather than require an IntColumnVector. + BigDecimal original = new BigDecimal("123.45"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalThrowsOnUnsupportedVectorType() { + // A large-precision request must have a bytes-backed child; any other writable child is an + // illegal combination and must be surfaced via Preconditions.checkArgument. + ColumnVector unsupported = new HeapShortVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(unsupported); + + assertThrows(IllegalArgumentException.class, () -> wrapped.getDecimal(0, 30, 10)); + } + + @Test + void testIsNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + intVector.vector[0] = 1; + intVector.setNullAt(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + assertFalse(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testWritableIntRoundTrip() { + HeapIntVector intVector = new HeapIntVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setInt(0, 42); + + assertEquals(42, wrapped.getInt(0)); + assertEquals(42, intVector.vector[0]); + } + + @Test + void testWritableLongRoundTrip() { + HeapLongVector longVector = new HeapLongVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + wrapped.setLong(0, 9876543210L); + + assertEquals(9876543210L, wrapped.getLong(0)); + assertEquals(9876543210L, longVector.vector[0]); + } + + @Test + void testWritableBytesRoundTrip() { + HeapBytesVector bytesVector = new HeapBytesVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + byte[] payload = new byte[] {0x01, 0x02, 0x03}; + + wrapped.appendBytes(0, payload, 0, payload.length); + + BytesColumnVector.Bytes out = wrapped.getBytes(0); + assertEquals(payload.length, out.len); + assertEquals(0x01, out.data[out.offset]); + assertEquals(0x02, out.data[out.offset + 1]); + assertEquals(0x03, out.data[out.offset + 2]); + } + + @Test + void testResetDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(1); + intVector.setNullAt(0); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + assertTrue(wrapped.isNullAt(0)); + + wrapped.reset(); + + assertFalse(wrapped.isNullAt(0)); + } + + @Test + void testFillWithNullsDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.fillWithNulls(); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testSetNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setNullAt(0); + wrapped.setNulls(1, 1); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java new file mode 100644 index 0000000000000..ea222dad576a5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.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.hudi.table.format.cow.vector.reader; + +import org.apache.flink.table.data.TimestampData; +import org.apache.parquet.column.Dictionary; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for the {@link ParquetDataColumnReaderFactory} INT64 timestamp dispatch added when + * vendoring Flink 2.1's nested-Parquet reader (FLINK-35702). + * + *

    The factory is exercised end-to-end by integration tests through + * {@link NestedPrimitiveColumnReader}; this unit test focuses on the small, deterministic piece + * that was added by this PR — selecting the right {@code ParquetDataColumnReader} for each + * supported INT64 TIMESTAMP encoding (modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} + * MILLIS / MICROS / NANOS plus the legacy {@link OriginalType} encodings) and decoding values + * using both the values-reader and dictionary code paths. + */ +class TestParquetDataColumnReaderFactory { + + // ----------------------------------------------------------------------------------------------- + // Type dispatch + // ----------------------------------------------------------------------------------------------- + + @Test + void valuesReaderDispatchInt96TimestampUsesInt96Reader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT96).named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt96PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64WithoutAnnotationUsesDefaultReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT64).named("plainLong"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMicrosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampNanosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMillisOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MILLIS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMicrosOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MICROS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt32DoesNotUseTimestampReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT32).named("i"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void dictionaryReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + // ----------------------------------------------------------------------------------------------- + // INT64 → TimestampData decoding (per ChronoUnit, both UTC and local-time-zone branches) + // ----------------------------------------------------------------------------------------------- + + @Test + void int64ReaderReadsTimestampMillisFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_123L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMillis), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + assertEquals(0, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMicrosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + long epochMicros = 1_700_000_000_123_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMicros), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMicros / 1_000L, ts.getMillisecond()); + // 456 microseconds remain → 456_000 nanoseconds within the millisecond + assertEquals(456_000, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampNanosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + long epochNanos = 1_700_000_000_123_456_789L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochNanos), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochNanos / 1_000_000L, ts.getMillisecond()); + assertEquals(456_789, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMillisFromDictionaryInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(epochMillis), true); + + TimestampData ts = reader.readTimestamp(0); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + } + + // ----------------------------------------------------------------------------------------------- + // Stubs (only the methods exercised by the dispatch + decoding tests above) + // ----------------------------------------------------------------------------------------------- + + /** Minimal {@link ValuesReader} returning a fixed long; other methods throw. */ + private static final class StubValuesReader extends ValuesReader { + private final long fixedLong; + + StubValuesReader() { + this(0L); + } + + StubValuesReader(long fixedLong) { + this.fixedLong = fixedLong; + } + + @Override + public long readLong() { + return fixedLong; + } + + @Override + public void skip() { + // unused + } + } + + /** Minimal {@link Dictionary} returning a fixed long for any id; other methods throw. */ + private static final class StubDictionary extends Dictionary { + private final long fixedLong; + + StubDictionary() { + this(0L); + } + + StubDictionary(long fixedLong) { + super(null); + this.fixedLong = fixedLong; + } + + @Override + public Binary decodeToBinary(int id) { + throw new UnsupportedOperationException(); + } + + @Override + public long decodeToLong(int id) { + return fixedLong; + } + + @Override + public int getMaxId() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java new file mode 100644 index 0000000000000..2b71bae4dc152 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.0.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.VarCharType; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link ParquetGroupField}. + */ +public class TestParquetGroupField { + + @Test + void testChildrenWithAllNonNullEntriesAreRetained() { + ParquetField c0 = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetField c1 = new ParquetPrimitiveField(new VarCharType(), true, descriptor(), 1); + List children = Arrays.asList(c0, c1); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertSame(c0, group.getChildren().get(0)); + assertSame(c1, group.getChildren().get(1)); + } + + @Test + void testChildrenMayContainNullForSchemaEvolution() { + // A ROW field present in the requested Flink schema but absent from the Parquet file is + // represented by a null slot in `children`. The group must allow this (Hudi-specific + // extension over Flink's ImmutableList-backed equivalent). + ParquetField present = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List children = Arrays.asList(present, null); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertNotNull(group.getChildren().get(0)); + assertNull(group.getChildren().get(1)); + } + + @Test + void testChildrenListIsUnmodifiable() { + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.singletonList(child)); + + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().add(null)); + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().remove(0)); + } + + @Test + void testChildrenListIsDefensivelyCopied() { + // Mutations to the caller-supplied list must not be visible through the group. + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List mutable = new ArrayList<>(); + mutable.add(child); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, mutable); + mutable.add(null); + + assertEquals(1, group.getChildren().size()); + } + + @Test + void testNullChildrenListThrows() { + assertThrows( + NullPointerException.class, + () -> new ParquetGroupField(rowType(), 0, 1, true, null)); + } + + @Test + void testEmptyChildrenListIsAllowed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.emptyList()); + + assertEquals(0, group.getChildren().size()); + } + + @Test + void testFieldMetadataIsExposed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 2, 5, false, Collections.emptyList()); + + assertEquals(2, group.getRepetitionLevel()); + assertEquals(5, group.getDefinitionLevel()); + assertFalse(group.isRequired()); + } + + private static LogicalType rowType() { + return RowType.of(new IntType()); + } + + private static ColumnDescriptor descriptor() { + PrimitiveType primitive = Types.required(PrimitiveTypeName.INT32).named("f"); + return new ColumnDescriptor(new String[] {"f"}, primitive, 0, 0); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/pom.xml b/hudi-flink-datasource/hudi-flink2.1.x/pom.xml index f6665d3c42b35..5eccf6ad3eaed 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/pom.xml +++ b/hudi-flink-datasource/hudi-flink2.1.x/pom.xml @@ -40,7 +40,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j @@ -121,12 +121,6 @@ ${flink2.1.version} provided - - org.apache.flink - flink-table-planner_2.12 - ${flink2.1.version} - provided - diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.java new file mode 100644 index 0000000000000..77d6f708ece31 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/adapter/DataTypeAdapter.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.hudi.adapter; + +import org.apache.flink.table.api.DataTypes; +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.LogicalTypeRoot; +import org.apache.flink.types.variant.BinaryVariant; +import org.apache.flink.types.variant.Variant; +import org.apache.hudi.common.util.Option; +import org.apache.parquet.schema.LogicalTypeAnnotation; + +import java.lang.reflect.Method; + +/** + * Adapter utils to provide {@code DataType} utilities. + */ +public class DataTypeAdapter { + + /** + * The Parquet Variant binary format specification version passed to + * {@code LogicalTypeAnnotation.variantType(byte)}. Version 1 is the initial spec + * defined by the Parquet Variant proposal (parquet-format 2.11.0 / parquet-java 1.16.0). + */ + private static final byte VARIANT_SPEC_VERSION = 1; + + /** + * Cached VARIANT annotation resolved via reflection. Empty if parquet-java + * on the classpath predates {@code LogicalTypeAnnotation.variantType()} (< 1.16.0). + */ + private static final Option VARIANT_ANNOTATION = resolveVariantAnnotation(); + + private static Option resolveVariantAnnotation() { + try { + Method factory = LogicalTypeAnnotation.class.getMethod("variantType", byte.class); + return Option.of((LogicalTypeAnnotation) factory.invoke(null, VARIANT_SPEC_VERSION)); + } catch (Exception e) { + return Option.empty(); + } + } + + /** + * Returns the Parquet VARIANT {@link LogicalTypeAnnotation} if parquet-java 1.16.0+ is on the + * classpath, or empty if the annotation class is unavailable. + */ + public static Option variantParquetAnnotation() { + return VARIANT_ANNOTATION; + } + + public static Variant getVariant(RowData rowData, int pos) { + return rowData.getVariant(pos); + } + + public static Object createVariant(byte[] value, byte[] metadata) { + return new BinaryVariant(value, metadata); + } + + public static boolean isVariantType(LogicalType logicalType) { + return logicalType.getTypeRoot() == LogicalTypeRoot.VARIANT; + } + + public static DataType createVariantType() { + return DataTypes.VARIANT(); + } + + public static byte[] getVariantMetadata(Object obj) { + return ((BinaryVariant) obj).getMetadata(); + } + + public static byte[] getVariantValue(Object obj) { + return ((BinaryVariant) obj).getValue(); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java index bb5d0c55b81de..d9479bcd658a9 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/ParquetSplitReaderUtil.java @@ -18,20 +18,20 @@ package org.apache.hudi.table.format.cow; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; import org.apache.hudi.table.format.cow.vector.HeapArrayVector; import org.apache.hudi.table.format.cow.vector.HeapDecimalVector; import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; -import org.apache.hudi.table.format.cow.vector.reader.ArrayColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.ArrayGroupReader; import org.apache.hudi.table.format.cow.vector.reader.EmptyColumnReader; import org.apache.hudi.table.format.cow.vector.reader.FixedLenBytesColumnReader; import org.apache.hudi.table.format.cow.vector.reader.Int64TimestampColumnReader; -import org.apache.hudi.table.format.cow.vector.reader.MapColumnReader; +import org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader; import org.apache.hudi.table.format.cow.vector.reader.ParquetColumnarRowSplitReader; -import org.apache.hudi.table.format.cow.vector.reader.RowColumnReader; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; import org.apache.flink.core.fs.Path; import org.apache.flink.formats.parquet.vector.reader.BooleanColumnReader; @@ -64,12 +64,15 @@ 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.LogicalTypeRoot; 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.TimestampType; +import org.apache.flink.table.types.logical.VarBinaryType; +import org.apache.flink.table.types.logical.VariantType; +import org.apache.flink.util.FlinkRuntimeException; import org.apache.flink.util.Preconditions; +import org.apache.flink.util.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.parquet.ParquetRuntimeException; import org.apache.parquet.column.ColumnDescriptor; @@ -77,12 +80,18 @@ import org.apache.parquet.column.page.PageReader; import org.apache.parquet.filter.UnboundRecordFilter; import org.apache.parquet.filter2.predicate.FilterPredicate; +import org.apache.parquet.io.ColumnIO; +import org.apache.parquet.io.GroupColumnIO; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.PrimitiveColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.InvalidSchemaException; import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; +import javax.annotation.Nullable; + import java.io.IOException; import java.math.BigDecimal; import java.sql.Date; @@ -90,25 +99,38 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.apache.flink.table.utils.DateTimeUtils.toInternal; import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; import static org.apache.parquet.Preconditions.checkArgument; +import static org.apache.parquet.schema.Type.Repetition.REPEATED; +import static org.apache.parquet.schema.Type.Repetition.REQUIRED; /** * Util for generating {@link ParquetColumnarRowSplitReader}. * - *

    NOTE: reference from Flink release 1.11.2 {@code ParquetSplitReaderUtil}, modify to support INT64 - * based TIMESTAMP_MILLIS as ConvertedType, should remove when Flink supports that. + *

    Uses the Dremel-style nested reader ported from Apache Flink 2.1 (FLINK-35702). For primitive + * top-level columns we keep Hudi's specialized readers — {@link Int64TimestampColumnReader}, + * {@link FixedLenBytesColumnReader}, and the Hudi {@link HeapDecimalVector} — unchanged. For + * nested types (ARRAY / MAP / MULTISET / ROW / VARIANT) we build a {@link ParquetField} tree once + * per split via {@link #buildFieldsList(List, List, MessageColumnIO)} and delegate reading to + * {@link NestedColumnReader}. + * + *

    Schema evolution: missing top-level fields are still handled by the caller + * ({@link ParquetColumnarRowSplitReader} patches them with null vectors). Missing fields + * inside a Row are handled here — {@link #constructField} returns {@code null} for a + * child that isn't physically present, and the corresponding child in the pre-allocated vector + * is filled with nulls via {@link #createVectorFromConstant} so the Dremel assembler can + * passthrough the slot (see {@link NestedColumnReader#readToVector}). */ public class ParquetSplitReaderUtil { - /** - * Util for generating partitioned {@link ParquetColumnarRowSplitReader}. - */ + /** Util for generating partitioned {@link ParquetColumnarRowSplitReader}. */ public static ParquetColumnarRowSplitReader genPartColumnarRowReader( boolean utcTimestamp, boolean caseSensitive, @@ -182,10 +204,13 @@ private static ColumnVector createVector( return readVector; } - private static ColumnVector createVectorFromConstant( - LogicalType type, - Object value, - int batchSize) { + /** + * Builds a constant-filled column vector for either a partition column (non-null value) or a + * missing-column slot (null value). Used both at the batch-generator level for partition + * injection and at the row-reader level for fields absent from the Parquet file. + */ + public static ColumnVector createVectorFromConstant( + LogicalType type, Object value, int batchSize) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: @@ -278,6 +303,7 @@ private static ColumnVector createVectorFromConstant( value == null ? null : toInternal((Date) value), batchSize); case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: HeapTimestampVector tv = new HeapTimestampVector(batchSize); if (value == null) { tv.fillWithNulls(); @@ -286,46 +312,49 @@ private static ColumnVector createVectorFromConstant( } return tv; case ARRAY: - ArrayType arrayType = (ArrayType) type; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - HeapArrayGroupColumnVector arrayGroup = new HeapArrayGroupColumnVector(batchSize); - if (value == null) { - arrayGroup.fillWithNulls(); - return arrayGroup; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } - } else { - HeapArrayVector arrayVector = new HeapArrayVector(batchSize); - if (value == null) { - arrayVector.fillWithNulls(); - return arrayVector; - } else { - throw new UnsupportedOperationException("Unsupported create array with default value."); - } + if (value != null) { + throw new UnsupportedOperationException("Unsupported create array with default value."); } + HeapArrayVector arrayVector = new HeapArrayVector(batchSize); + arrayVector.fillWithNulls(); + return arrayVector; case MAP: - HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); - if (value == null) { - mapVector.fillWithNulls(); - return mapVector; - } else { - throw new UnsupportedOperationException("Unsupported create map with default value."); + case MULTISET: + if (value != null) { + throw new UnsupportedOperationException( + "Unsupported create " + type.getTypeRoot() + " with default value."); } + HeapMapColumnVector mapVector = new HeapMapColumnVector(batchSize, null, null); + mapVector.fillWithNulls(); + return mapVector; case ROW: - HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize); - if (value == null) { - rowVector.fillWithNulls(); - return rowVector; - } else { + if (value != null) { throw new UnsupportedOperationException("Unsupported create row with default value."); } + RowType rowType = (RowType) type; + WritableColumnVector[] childVectors = new WritableColumnVector[rowType.getFieldCount()]; + for (int i = 0; i < childVectors.length; i++) { + childVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); + } + HeapRowColumnVector rowVector = new HeapRowColumnVector(batchSize, childVectors); + rowVector.fillWithNulls(); + return rowVector; + case VARIANT: + if (value != null) { + throw new UnsupportedOperationException("Unsupported create variant with default value."); + } + HeapRowColumnVector variantVector = new HeapRowColumnVector( + batchSize, new HeapBytesVector(batchSize), new HeapBytesVector(batchSize)); + variantVector.fillWithNulls(); + return variantVector; default: throw new UnsupportedOperationException("Unsupported type: " + type); } } - private static List filterDescriptors(int depth, Type type, List columns) throws ParquetRuntimeException { + private static List filterDescriptors( + int depth, Type type, List columns) throws ParquetRuntimeException { List filtered = new ArrayList<>(); for (ColumnDescriptor descriptor : columns) { if (depth >= descriptor.getPath().length) { @@ -339,24 +368,65 @@ private static List filterDescriptors(int depth, Type type, Li return filtered; } + /** + * Creates a {@link ColumnReader} for one top-level requested field. For primitive types the + * Hudi-specialized reader path is used. For nested types ({@code ARRAY}, {@code MAP}, + * {@code MULTISET}, {@code ROW}) the Dremel-style {@link NestedColumnReader} is used, driven by + * the supplied pre-built {@link ParquetField} tree. + * + * @param field the {@link ParquetField} tree for this column, built by + * {@link #buildFieldsList(List, List, MessageColumnIO)}. Required (non-null) for nested + * types; ignored for primitives. + */ + public static ColumnReader createColumnReader( + boolean utcTimestamp, + LogicalType fieldType, + Type physicalType, + List descriptors, + PageReadStore pages, + @Nullable ParquetField field) throws IOException { + switch (fieldType.getTypeRoot()) { + case ARRAY: + case MAP: + case MULTISET: + case ROW: + case VARIANT: + // VARIANT is physically a non-shredded group of two binary leaves (value, metadata), so it + // is assembled like a two-field row by the Dremel-style NestedColumnReader (see + // NestedColumnReader#readVariant). The ParquetField tree is built by #constructField. + Preconditions.checkNotNull( + field, "ParquetField must be provided for nested type: %s", fieldType); + return new NestedColumnReader(utcTimestamp, pages, field); + default: + return createPrimitiveColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages); + } + } + + /** + * Backward-compat entry point kept for callers that don't project nested types and therefore + * never need a {@link ParquetField} tree. Forwards to the {@link ParquetField}-aware overload + * with a null field; nested types now go through that overload directly. + * + * @deprecated use {@link #createColumnReader(boolean, LogicalType, Type, List, PageReadStore, + * ParquetField)} so nested types take the Dremel path. + */ + @Deprecated public static ColumnReader createColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List descriptors, PageReadStore pages) throws IOException { - return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, - pages, 0); + return createColumnReader(utcTimestamp, fieldType, physicalType, descriptors, pages, null); } - private static ColumnReader createColumnReader( + private static ColumnReader createPrimitiveColumnReader( boolean utcTimestamp, LogicalType fieldType, Type physicalType, List columns, - PageReadStore pages, - int depth) throws IOException { - List descriptors = filterDescriptors(depth, physicalType, columns); + PageReadStore pages) throws IOException { + List descriptors = filterDescriptors(0, physicalType, columns); ColumnDescriptor descriptor = descriptors.get(0); PageReader pageReader = pages.getPageReader(descriptor); switch (fieldType.getTypeRoot()) { @@ -392,7 +462,9 @@ private static ColumnReader createColumnReader( case INT96: return new TimestampColumnReader(utcTimestamp, descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for TIMESTAMP: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } case DECIMAL: switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { @@ -403,106 +475,23 @@ private static ColumnReader createColumnReader( case BINARY: return new BytesColumnReader(descriptor, pageReader); case FIXED_LEN_BYTE_ARRAY: - return new FixedLenBytesColumnReader( - descriptor, pageReader); + return new FixedLenBytesColumnReader(descriptor, pageReader); default: - throw new AssertionError(); + throw new AssertionError( + "Unexpected physical type for DECIMAL: " + + descriptor.getPrimitiveType().getPrimitiveTypeName()); } - case ARRAY: - ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new ArrayGroupReader(createColumnReader( - utcTimestamp, - arrayType.getElementType(), - elementType, - descriptors, - pages, - elementDepth)); - } else { - return new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - fieldType); - } - case MAP: - MapType mapType = (MapType) fieldType; - ArrayColumnReader keyReader = - new ArrayColumnReader( - descriptor, - pageReader, - utcTimestamp, - descriptor.getPrimitiveType(), - new ArrayType(mapType.getKeyType())); - ColumnReader valueReader; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueReader = new ArrayGroupReader(createColumnReader( - utcTimestamp, - mapType.getValueType(), - physicalType.asGroupType().getType(0).asGroupType().getType(1), // Get the value physical type - descriptors.subList(1, descriptors.size()), // remove the key descriptor - pages, - depth + 2)); // increase the depth by 2, because there's a key_value entry in the path - } else { - valueReader = new ArrayColumnReader( - descriptors.get(1), - pages.getPageReader(descriptors.get(1)), - utcTimestamp, - descriptors.get(1).getPrimitiveType(), - new ArrayType(mapType.getValueType())); - } - return new MapColumnReader(keyReader, valueReader); - case ROW: - RowType rowType = (RowType) fieldType; - GroupType groupType = physicalType.asGroupType(); - List fieldReaders = new ArrayList<>(); - for (int i = 0; i < rowType.getFieldCount(); i++) { - // schema evolution: read the parquet file with a new extended field name. - int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); - if (fieldIndex < 0) { - fieldReaders.add(new EmptyColumnReader()); - } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - fieldReaders.add( - createColumnReader( - utcTimestamp, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } else { - fieldReaders.add( - createColumnReader( - utcTimestamp, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - pages, - depth + 1)); - } - } - } - return new RowColumnReader(fieldReaders); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + /** + * Creates the writable column vector that the reader will write into. The returned vector shape + * matches {@code fieldType}; for ROW types missing physical fields are slotted with null-filled + * vectors (sourced from {@link #createVectorFromConstant}) so that the Dremel assembler in + * {@link NestedColumnReader} can pass them through unchanged. + */ public static WritableColumnVector createWritableColumnVector( int batchSize, LogicalType fieldType, @@ -523,33 +512,40 @@ private static WritableColumnVector createWritableColumnVector( switch (fieldType.getTypeRoot()) { case BOOLEAN: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.BOOLEAN, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapBooleanVector(batchSize); case TINYINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapByteVector(batchSize); case DOUBLE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.DOUBLE, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDoubleVector(batchSize); case FLOAT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.FLOAT, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.FLOAT, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapFloatVector(batchSize); case INTEGER: case DATE: case TIME_WITHOUT_TIME_ZONE: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapIntVector(batchSize); case BIGINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT64, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT64, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapLongVector(batchSize); case SMALLINT: checkArgument( - typeName == PrimitiveType.PrimitiveTypeName.INT32, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); + typeName == PrimitiveType.PrimitiveTypeName.INT32, + getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapShortVector(batchSize); case CHAR: case VARCHAR: @@ -566,171 +562,380 @@ private static WritableColumnVector createWritableColumnVector( case DECIMAL: checkArgument( (typeName == PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY - || typeName == PrimitiveType.PrimitiveTypeName.BINARY) + || typeName == PrimitiveType.PrimitiveTypeName.BINARY) && primitiveType.getOriginalType() == OriginalType.DECIMAL, getPrimitiveTypeCheckFailureMessage(typeName, fieldType)); return new HeapDecimalVector(batchSize); case ARRAY: ArrayType arrayType = (ArrayType) fieldType; - if (arrayType.getElementType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - boolean isThreeLevelList = isThreeLevelList(physicalType); - // 3-level List structure, drill down 2 level to get type for `element` - Type elementType = isThreeLevelList - ? physicalType.asGroupType().getType(0).asGroupType().getType(0) - : physicalType.asGroupType().getType(0); - int elementDepth = isThreeLevelList ? depth + 2 : depth + 1; - return new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - elementType, - descriptors, - elementDepth)); - } else { - return new HeapArrayVector( - batchSize, - createWritableColumnVector( - batchSize, - arrayType.getElementType(), - physicalType, - descriptors, - depth)); - } - case MAP: + return new HeapArrayVector( + batchSize, + createWritableColumnVector( + batchSize, arrayType.getElementType(), physicalType, descriptors, depth)); + case MAP: { MapType mapType = (MapType) fieldType; - GroupType repeatedType = physicalType.asGroupType().getType(0).asGroupType(); - // the map column has three level paths. - WritableColumnVector keyColumnVector = createWritableColumnVector( + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( batchSize, - new ArrayType(mapType.getKeyType().isNullable(), mapType.getKeyType()), - repeatedType.getType(0), - descriptors, - depth + 2); - WritableColumnVector valueColumnVector; - if (mapType.getValueType().isAnyOf(LogicalTypeFamily.CONSTRUCTED)) { - valueColumnVector = new HeapArrayGroupColumnVector( - batchSize, - createWritableColumnVector( - batchSize, - mapType.getValueType(), - repeatedType.getType(1).asGroupType(), - descriptors, - depth + 2)); - } else { - valueColumnVector = createWritableColumnVector( - batchSize, - new ArrayType(mapType.getValueType().isNullable(), mapType.getValueType()), - repeatedType.getType(1), - descriptors, - depth + 2); - } - return new HeapMapColumnVector(batchSize, keyColumnVector, valueColumnVector); + createWritableColumnVector( + batchSize, mapType.getKeyType(), repeatedType.getType(0), descriptors, depth + 2), + createWritableColumnVector( + batchSize, mapType.getValueType(), repeatedType.getType(1), descriptors, depth + 2)); + } + case MULTISET: { + MultisetType multisetType = (MultisetType) fieldType; + GroupType repeatedType = unwrapMapRepeatedType(physicalType); + return new HeapMapColumnVector( + batchSize, + createWritableColumnVector( + batchSize, + multisetType.getElementType(), + repeatedType.getType(0), + descriptors, + depth + 2), + createWritableColumnVector( + batchSize, + new IntType(false), + repeatedType.getType(1), + descriptors, + depth + 2)); + } case ROW: RowType rowType = (RowType) fieldType; GroupType groupType = physicalType.asGroupType(); WritableColumnVector[] columnVectors = new WritableColumnVector[rowType.getFieldCount()]; for (int i = 0; i < columnVectors.length; i++) { - // schema evolution: read the file with a new extended field name. int fieldIndex = getFieldIndexInPhysicalType(rowType.getFields().get(i).getName(), groupType); if (fieldIndex < 0) { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (groupType.getRepetition().equals(Type.Repetition.REPEATED) && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant( - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), null, batchSize); - } else { - columnVectors[i] = (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); - } + // Schema evolution: logical field is absent from the Parquet file. Slot a null-filled + // vector of the correct shape; NestedColumnReader.readRow will pass it through when the + // matching ParquetField child is null. + columnVectors[i] = + (WritableColumnVector) createVectorFromConstant(rowType.getTypeAt(i), null, batchSize); } else { - // Check for nested row in array with atomic field type. - - // This is done to meet the Parquet field algorithm that pushes multiplicity and structures down to individual fields. - // In Parquet, an array of rows is stored as separate arrays for each field. - - // Limitations: It won't work for multiple nested arrays and maps. - // The main problem is that the Flink classes and interface don't follow that pattern. - if (descriptors.get(fieldIndex).getMaxRepetitionLevel() > 0 && !rowType.getTypeAt(i).is(LogicalTypeRoot.ARRAY)) { - columnVectors[i] = - createWritableColumnVector( - batchSize, - new ArrayType(rowType.getTypeAt(i).isNullable(), rowType.getTypeAt(i)), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } else { - columnVectors[i] = - createWritableColumnVector( - batchSize, - rowType.getTypeAt(i), - groupType.getType(fieldIndex), - descriptors, - depth + 1); - } + columnVectors[i] = + createWritableColumnVector( + batchSize, + rowType.getTypeAt(i), + groupType.getType(fieldIndex), + descriptors, + depth + 1); } } return new HeapRowColumnVector(batchSize, columnVectors); + case VARIANT: + validateVariantType(physicalType); + return new HeapRowColumnVector( + batchSize, + new HeapBytesVector(batchSize), + new HeapBytesVector(batchSize)); default: throw new UnsupportedOperationException(fieldType + " is not supported now."); } } + private static void validateVariantType(Type physicalType) { + if (!physicalType.isPrimitive()) { + GroupType groupType = physicalType.asGroupType(); + if (isShreddedVariant(groupType)) { + throw new UnsupportedOperationException( + "Shredded Variant is not supported in Flink. " + + "The Parquet group '" + groupType.getName() + "' contains a '" + + HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD + + "' field indicating a shredded layout."); + } + validateVariantField(groupType, HoodieSchema.Variant.VARIANT_VALUE_FIELD); + validateVariantField(groupType, HoodieSchema.Variant.VARIANT_METADATA_FIELD); + } else { + throw new IllegalArgumentException( + "Type mismatch, expected Variant but got '" + physicalType + "'."); + } + } + /** - * Returns the field index with given physical row type {@code groupType} and field name {@code fieldName}. - * - * @return The physical field index or -1 if the field does not exist + * Checks whether a variant group contains a {@code typed_value} field, indicating a shredded + * layout. */ - private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { - // get index from fileSchema type, else, return -1 - return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + private static boolean isShreddedVariant(GroupType groupType) { + return groupType.containsField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD); + } + + private static void validateVariantField(GroupType groupType, String fieldName) { + if (groupType.containsField(fieldName)) { + Type fieldType = groupType.getType(fieldName); + if (fieldType.isPrimitive() + && fieldType.asPrimitiveType().getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.BINARY) { + return; + } + } + throw new IllegalArgumentException( + "Invalid Variant Parquet schema: missing binary field '" + fieldName + "'."); } /** - * Check whether the given list type is a three-level list type. - *

    - * group (LIST) { - * repeated group list { - * element; - * } - * } - * - * @param type list type - * @return true if the list type is a three-level list type + * Peels one {@code repeated group key_value} wrapper off a MAP / MULTISET physical type, matching + * Parquet's canonical 3-level map encoding. + */ + private static GroupType unwrapMapRepeatedType(Type physicalType) { + return physicalType.asGroupType().getType(0).asGroupType(); + } + + // ------------------------------------------------------------------------------------------ + // ParquetField tree construction (vendored from Apache Flink 2.1 ParquetSplitReaderUtil) + // + // The only Hudi-specific divergence is in `constructField`: the ROW branch tolerates children + // missing from the Parquet file by emitting a null ParquetField child (upstream throws). This + // matches the Hudi schema-evolution contract and is the companion to the null-child branch in + // `NestedColumnReader#readRow` and the null-vector slot in `createWritableColumnVector#ROW`. + // ------------------------------------------------------------------------------------------ + + /** + * Builds {@link ParquetField} trees — one per top-level projected logical column — that feed + * {@link NestedColumnReader}. The returned list mirrors the input {@code children} positionally; + * primitive top-level fields produce {@code null} entries (callers don't need a tree for those). */ - private static boolean isThreeLevelList(Type type) { - if (type.isPrimitive()) { - return false; + public static List buildFieldsList( + List children, List fieldNames, MessageColumnIO columnIO) { + List list = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + RowType.RowField child = children.get(i); + if (isNestedType(child.getType())) { + list.add(constructField(child, lookupColumnByName(columnIO, fieldNames.get(i)))); + } else { + list.add(null); + } } - GroupType groupType = type.asGroupType(); - OriginalType originalType = groupType.getOriginalType(); - return originalType == OriginalType.LIST - && groupType.getType(0).getName().equals("list"); + return list; + } + + private static boolean isNestedType(LogicalType type) { + return type instanceof RowType + || type instanceof ArrayType + || type instanceof MapType + || type instanceof MultisetType + || type instanceof VariantType; + } + + @Nullable + private static ParquetField constructField(RowType.RowField rowField, ColumnIO columnIO) { + boolean required = columnIO.getType().getRepetition() == REQUIRED; + int repetitionLevel = columnIO.getRepetitionLevel(); + int definitionLevel = columnIO.getDefinitionLevel(); + LogicalType type = rowField.getType(); + String fieldName = rowField.getName(); + if (type instanceof RowType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + RowType rowType = (RowType) type; + List childFields = rowType.getFields(); + List fieldsList = new ArrayList<>(childFields.size()); + for (RowType.RowField childField : childFields) { + // Hudi schema evolution: a logical child may be absent from the Parquet file. In that + // case we emit a null ParquetField so that NestedColumnReader.readRow passes through the + // pre-filled null vector instead of recursing. + ColumnIO childIo = lookupColumnByNameOrNull(groupColumnIO, childField.getName()); + if (childIo == null) { + fieldsList.add(null); + } else { + fieldsList.add(constructField(childField, childIo)); + } + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(fieldsList)); + } + + if (type instanceof VariantType) { + // A variant is physically a non-shredded group of two binary leaves (value, metadata). Build + // it as a two-field group of binary primitives so NestedColumnReader#readVariant can assemble + // it like a row. Shredded variants (with a typed_value field) are rejected here. + validateVariantType(columnIO.getType()); + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + LogicalType binaryType = new VarBinaryType(VarBinaryType.MAX_LENGTH); + ParquetField valueField = + constructField( + new RowType.RowField(HoodieSchema.Variant.VARIANT_VALUE_FIELD, binaryType), + lookupColumnByName(groupColumnIO, HoodieSchema.Variant.VARIANT_VALUE_FIELD)); + ParquetField metadataField = + constructField( + new RowType.RowField(HoodieSchema.Variant.VARIANT_METADATA_FIELD, binaryType), + lookupColumnByName(groupColumnIO, HoodieSchema.Variant.VARIANT_METADATA_FIELD)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(valueField, metadataField))); + } + + if (type instanceof MapType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MapType mapType = (MapType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", mapType.getKeyType()), keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", mapType.getValueType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof MultisetType) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + GroupColumnIO keyValueColumnIO = getMapKeyValueColumn(groupColumnIO); + MultisetType multisetType = (MultisetType) type; + ParquetField keyField = + constructField( + new RowType.RowField("", multisetType.getElementType()), + keyValueColumnIO.getChild(0)); + ParquetField valueField = + constructField( + new RowType.RowField("", new IntType()), keyValueColumnIO.getChild(1)); + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.unmodifiableList(Arrays.asList(keyField, valueField))); + } + + if (type instanceof ArrayType) { + ArrayType arrayType = (ArrayType) type; + ColumnIO elementTypeColumnIO; + if (columnIO instanceof GroupColumnIO) { + GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO; + if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) { + while (!Objects.equals(groupColumnIO.getName(), fieldName)) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + elementTypeColumnIO = groupColumnIO; + } else { + if (arrayType.getElementType() instanceof RowType) { + elementTypeColumnIO = groupColumnIO; + } else { + elementTypeColumnIO = groupColumnIO.getChild(0); + } + } + } else if (columnIO instanceof PrimitiveColumnIO) { + elementTypeColumnIO = columnIO; + } else { + throw new FlinkRuntimeException(String.format("Unknown ColumnIO, %s", columnIO)); + } + + ParquetField elementField = + constructField( + new RowType.RowField("", arrayType.getElementType()), + getArrayElementColumn(elementTypeColumnIO)); + if (repetitionLevel == elementField.getRepetitionLevel()) { + repetitionLevel = columnIO.getParent().getRepetitionLevel(); + } + return new ParquetGroupField( + type, + repetitionLevel, + definitionLevel, + required, + Collections.singletonList(elementField)); + } + + PrimitiveColumnIO primitiveColumnIO = (PrimitiveColumnIO) columnIO; + return new ParquetPrimitiveField( + type, required, primitiveColumnIO.getColumnDescriptor(), primitiveColumnIO.getId()); } /** - * Construct the error message when primitive type mismatches. - * - * @param primitiveType Primitive type - * @param fieldType Logical field type - * @return The error message + * Parquet column names are case-insensitive in Flink's lookup. Matches upstream + * {@code ParquetSplitReaderUtil.lookupColumnByName}; throws when absent. */ - private static String getPrimitiveTypeCheckFailureMessage(PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { - return String.format("Unexpected type exception. Primitive type: %s. Field type: %s.", primitiveType, fieldType.getTypeRoot().name()); + public static ColumnIO lookupColumnByName(GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = lookupColumnByNameOrNull(groupColumnIO, columnName); + if (columnIO != null) { + return columnIO; + } + throw new FlinkRuntimeException( + "Can not find column io for parquet reader. Column name: " + columnName); } /** - * Construct the error message when original type mismatches. + * Case-insensitive column lookup that returns {@code null} when no match is found — the + * Hudi-specific companion to {@link #lookupColumnByName}, used by {@link #constructField} to + * emit null {@link ParquetField} children for fields absent from the Parquet file. + */ + @Nullable + private static ColumnIO lookupColumnByNameOrNull( + GroupColumnIO groupColumnIO, String columnName) { + ColumnIO columnIO = groupColumnIO.getChild(columnName); + if (columnIO != null) { + return columnIO; + } + for (int i = 0; i < groupColumnIO.getChildrenCount(); i++) { + if (groupColumnIO.getChild(i).getName().equalsIgnoreCase(columnName)) { + return groupColumnIO.getChild(i); + } + } + return null; + } + + public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO groupColumnIO) { + while (groupColumnIO.getChildrenCount() == 1) { + groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0); + } + return groupColumnIO; + } + + public static ColumnIO getArrayElementColumn(ColumnIO columnIO) { + while (columnIO instanceof GroupColumnIO && !columnIO.getType().isRepetition(REPEATED)) { + columnIO = ((GroupColumnIO) columnIO).getChild(0); + } + + // Three-level list: skip the synthetic `element` / `list` wrapper when present. + if (columnIO instanceof GroupColumnIO + && columnIO.getType().getLogicalTypeAnnotation() == null + && ((GroupColumnIO) columnIO).getChildrenCount() == 1 + && !columnIO.getName().equals("array") + && !columnIO.getName().equals(columnIO.getParent().getName() + "_tuple")) { + return ((GroupColumnIO) columnIO).getChild(0); + } + return columnIO; + } + + /** + * Returns the field index with given physical row type {@code groupType} and field name + * {@code fieldName}. * - * @param originalType Original type - * @param fieldType Logical field type - * @return The error message + * @return the physical field index or -1 if the field does not exist + */ + private static int getFieldIndexInPhysicalType(String fieldName, GroupType groupType) { + return groupType.containsField(fieldName) ? groupType.getFieldIndex(fieldName) : -1; + } + + private static String getPrimitiveTypeCheckFailureMessage( + PrimitiveType.PrimitiveTypeName primitiveType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Primitive type: %s. Field type: %s.", + primitiveType, fieldType.getTypeRoot().name()); + } + + private static String getOriginalTypeCheckFailureMessage( + OriginalType originalType, LogicalType fieldType) { + return String.format( + "Unexpected type exception. Original type: %s. Field type: %s.", + originalType, fieldType.getTypeRoot().name()); + } + + /** + * Returns a synthetic null-column reader to fill missing top-level fields. Kept as a convenience + * for callers that need to mirror Hudi's original behaviour where a missing column produces an + * explicit null-valued reader rather than being omitted from the batch. */ - private static String getOriginalTypeCheckFailureMessage(OriginalType originalType, LogicalType fieldType) { - return String.format("Unexpected type exception. Original type: %s. Field type: %s.", originalType, fieldType.getTypeRoot().name()); + public static ColumnReader emptyColumnReader() { + return new EmptyColumnReader(); } } diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.java new file mode 100644 index 0000000000000..d51d7ee754b8a --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/BooleanArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of booleans. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.runtime.util.BooleanArrayList}) because Flink 1.18 does not ship this helper. + */ +public class BooleanArrayList { + private int size; + private boolean[] array; + + public BooleanArrayList(int capacity) { + this.size = 0; + this.array = new boolean[capacity]; + } + + public int size() { + return size; + } + + public boolean add(boolean element) { + grow(size + 1); + array[size++] = element; + return true; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public boolean[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final boolean[] t = new boolean[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.java new file mode 100644 index 0000000000000..4787dbb5b9ddb --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/IntArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; +import java.util.NoSuchElementException; + +/** + * Minimal implementation of an array-backed list of ints. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.IntArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class IntArrayList { + + private int size; + private int[] array; + + public IntArrayList(final int capacity) { + this.size = 0; + this.array = new int[capacity]; + } + + public int size() { + return size; + } + + public boolean add(final int number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public int removeLast() { + if (size == 0) { + throw new NoSuchElementException(); + } + --size; + return array[size]; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return size == 0; + } + + private void grow(final int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final int[] t = new int[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } + + public int[] toArray() { + return Arrays.copyOf(array, size); + } + + public static final IntArrayList EMPTY = + new IntArrayList(0) { + + @Override + public boolean add(int number) { + throw new UnsupportedOperationException(); + } + + @Override + public int removeLast() { + throw new UnsupportedOperationException(); + } + }; +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.java new file mode 100644 index 0000000000000..a51291f9d8441 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/LongArrayList.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.hudi.table.format.cow.utils; + +import java.util.Arrays; + +/** + * Minimal implementation of an array-backed list of longs. + * + *

    Note: Vendored from Apache Flink ({@code org.apache.flink.runtime.util.LongArrayList}) to + * avoid depending on {@code @Internal} Flink runtime classes from Hudi's parquet reader. + */ +public class LongArrayList { + + private int size; + private long[] array; + + public LongArrayList(int capacity) { + this.size = 0; + this.array = new long[capacity]; + } + + public int size() { + return size; + } + + public boolean add(long number) { + grow(size + 1); + array[size++] = number; + return true; + } + + public long removeLong(int index) { + if (index >= size) { + throw new IndexOutOfBoundsException( + "Index (" + index + ") is greater than or equal to list size (" + size + ")"); + } + final long old = array[index]; + size--; + if (index != size) { + System.arraycopy(array, index + 1, array, index, size - index); + } + return old; + } + + public void clear() { + size = 0; + } + + public boolean isEmpty() { + return (size == 0); + } + + public long[] toArray() { + return Arrays.copyOf(array, size); + } + + private void grow(int length) { + if (length > array.length) { + final int newLength = + (int) Math.max(Math.min(2L * array.length, Integer.MAX_VALUE - 8), length); + final long[] t = new long[newLength]; + System.arraycopy(array, 0, t, 0, size); + array = t; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.java new file mode 100644 index 0000000000000..3f2f8976b69bf --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/utils/NestedPositionUtil.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.hudi.table.format.cow.utils; + +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; + +import static java.lang.String.format; + +/** + * Utils to calculate nested type position. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.utils.NestedPositionUtil}). + */ +public class NestedPositionUtil { + + /** + * Calculate row offsets according to column's max repetition level, definition level, value's + * repetition level and definition level. Each row has three situation: + *

  • Row is not defined,because it's optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Row is null + *
  • Row is defined and not empty. + * + * @param field field that contains the row column message include max repetition level and + * definition level. + * @param fieldRepetitionLevels int array with each value's repetition level. + * @param fieldDefinitionLevels int array with each value's definition level. + * @return {@link RowPosition} contains collections row count and isNull array. + */ + public static RowPosition calculateRowOffsets( + ParquetField field, int[] fieldDefinitionLevels, int[] fieldRepetitionLevels) { + int rowDefinitionLevel = field.getDefinitionLevel(); + int rowRepetitionLevel = field.getRepetitionLevel(); + int nullValuesCount = 0; + BooleanArrayList nullRowFlags = new BooleanArrayList(0); + for (int i = 0; i < fieldDefinitionLevels.length; i++) { + // If a row's last field is an array, the repetition levels for the array's items will + // be larger than the parent row's repetition level, so we need to skip those values. + if (fieldRepetitionLevels[i] > rowRepetitionLevel) { + continue; + } + + if (fieldDefinitionLevels[i] >= rowDefinitionLevel) { + // current row is defined and not empty + nullRowFlags.add(false); + } else { + // current row is null + nullRowFlags.add(true); + nullValuesCount++; + } + } + if (nullValuesCount == 0) { + return new RowPosition(null, fieldDefinitionLevels.length); + } + return new RowPosition(nullRowFlags.toArray(), nullRowFlags.size()); + } + + /** + * Calculate the collection's offsets according to column's max repetition level, definition + * level, value's repetition level and definition level. Each collection (Array or Map) has four + * situation: + *
  • Collection is not defined, because optional parent fields is null, this is decided by its + * parent's repetition level + *
  • Collection is null + *
  • Collection is defined but empty + *
  • Collection is defined and not empty. In this case offset value is increased by the number + * of elements in that collection + * + * @param field field that contains array/map column message include max repetition level and + * definition level. + * @param definitionLevels int array with each value's definition level. + * @param repetitionLevels int array with each value's repetition level. + * @return {@link CollectionPosition} contains collections offset array, length array and isNull + * array. + */ + public static CollectionPosition calculateCollectionOffsets( + ParquetField field, int[] definitionLevels, int[] repetitionLevels) { + int collectionDefinitionLevel = field.getDefinitionLevel(); + int collectionRepetitionLevel = field.getRepetitionLevel() + 1; + int offset = 0; + int valueCount = 0; + LongArrayList offsets = new LongArrayList(0); + offsets.add(offset); + BooleanArrayList emptyCollectionFlags = new BooleanArrayList(0); + BooleanArrayList nullCollectionFlags = new BooleanArrayList(0); + int nullValuesCount = 0; + for (int i = 0; + i < definitionLevels.length; + i = getNextCollectionStartIndex(repetitionLevels, collectionRepetitionLevel, i)) { + valueCount++; + if (definitionLevels[i] >= collectionDefinitionLevel - 1) { + boolean isNull = + isOptionalFieldValueNull(definitionLevels[i], collectionDefinitionLevel); + nullCollectionFlags.add(isNull); + nullValuesCount += isNull ? 1 : 0; + // definitionLevels[i] > collectionDefinitionLevel => Collection is defined and not + // empty + // definitionLevels[i] == collectionDefinitionLevel => Collection is defined but + // empty + if (definitionLevels[i] > collectionDefinitionLevel) { + emptyCollectionFlags.add(false); + offset += getCollectionSize(repetitionLevels, collectionRepetitionLevel, i + 1); + } else if (definitionLevels[i] == collectionDefinitionLevel) { + offset++; + emptyCollectionFlags.add(true); + } else { + offset++; + emptyCollectionFlags.add(false); + } + offsets.add(offset); + } else { + // when definitionLevels[i] < collectionDefinitionLevel - 1, it means the collection + // is + // not defined, but we need to regard it as null to avoid getting value wrong. + nullCollectionFlags.add(true); + nullValuesCount++; + offsets.add(++offset); + emptyCollectionFlags.add(false); + } + } + long[] offsetsArray = offsets.toArray(); + long[] length = calculateLengthByOffsets(emptyCollectionFlags.toArray(), offsetsArray); + if (nullValuesCount == 0) { + return new CollectionPosition(null, offsetsArray, length, valueCount); + } + return new CollectionPosition( + nullCollectionFlags.toArray(), offsetsArray, length, valueCount); + } + + public static boolean isOptionalFieldValueNull(int definitionLevel, int maxDefinitionLevel) { + return definitionLevel == maxDefinitionLevel - 1; + } + + public static long[] calculateLengthByOffsets( + boolean[] collectionIsEmpty, long[] arrayOffsets) { + LongArrayList lengthList = new LongArrayList(arrayOffsets.length); + for (int i = 0; i < arrayOffsets.length - 1; i++) { + long offset = arrayOffsets[i]; + long length = arrayOffsets[i + 1] - offset; + if (length < 0) { + throw new IllegalArgumentException( + format( + "Offset is not monotonically ascending. offsets[%s]=%s, offsets[%s]=%s", + i, arrayOffsets[i], i + 1, arrayOffsets[i + 1])); + } + if (collectionIsEmpty[i]) { + length = 0; + } + lengthList.add(length); + } + return lengthList.toArray(); + } + + private static int getNextCollectionStartIndex( + int[] repetitionLevels, int maxRepetitionLevel, int elementIndex) { + do { + elementIndex++; + } while (hasMoreElements(repetitionLevels, elementIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, elementIndex)); + return elementIndex; + } + + /** This method is only called for non-empty collections. */ + private static int getCollectionSize( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + int size = 1; + while (hasMoreElements(repetitionLevels, nextIndex) + && isNotCollectionBeginningMarker( + repetitionLevels, maxRepetitionLevel, nextIndex)) { + // Collection elements cannot only be primitive, but also can have nested structure + // Counting only elements which belong to current collection, skipping inner elements of + // nested collections/structs + if (repetitionLevels[nextIndex] <= maxRepetitionLevel) { + size++; + } + nextIndex++; + } + return size; + } + + private static boolean isNotCollectionBeginningMarker( + int[] repetitionLevels, int maxRepetitionLevel, int nextIndex) { + return repetitionLevels[nextIndex] >= maxRepetitionLevel; + } + + private static boolean hasMoreElements(int[] repetitionLevels, int nextIndex) { + return nextIndex < repetitionLevels.length; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java deleted file mode 100644 index bed7c71a1848b..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupArrayData.java +++ /dev/null @@ -1,185 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.types.variant.Variant; - -public class ColumnarGroupArrayData implements ArrayData { - - WritableColumnVector vector; - int rowId; - - public ColumnarGroupArrayData(WritableColumnVector vector, int rowId) { - this.vector = vector; - this.rowId = rowId; - } - - @Override - public int size() { - if (vector == null) { - return 0; - } - - if (vector instanceof HeapRowColumnVector) { - // assume all fields have the same size - if (((HeapRowColumnVector) vector).vectors == null || ((HeapRowColumnVector) vector).vectors.length == 0) { - return 0; - } - return ((HeapArrayVector) ((HeapRowColumnVector) vector).vectors[0]).getArray(rowId).size(); - } - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean isNullAt(int index) { - if (vector == null) { - return true; - } - - if (vector instanceof HeapRowColumnVector) { - return ((HeapRowColumnVector) vector).vectors == null; - } - - throw new UnsupportedOperationException(vector.getClass().getName() + " is not supported. Supported vector types: HeapRowColumnVector"); - } - - @Override - public boolean getBoolean(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte getByte(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short getShort(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int getInt(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long getLong(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float getFloat(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double getDouble(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public StringData getString(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public DecimalData getDecimal(int index, int precision, int scale) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public TimestampData getTimestamp(int index, int precision) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RawValueData getRawValue(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public Variant getVariant(int i) { - throw new UnsupportedOperationException("Variant is not supported yet."); - } - - @Override - public byte[] getBinary(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public ArrayData getArray(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public MapData getMap(int index) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public RowData getRow(int index, int numFields) { - return new ColumnarGroupRowData((HeapRowColumnVector) vector, rowId, index); - } - - @Override - public boolean[] toBooleanArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public byte[] toByteArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public short[] toShortArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public int[] toIntArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public long[] toLongArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public float[] toFloatArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public double[] toDoubleArray() { - throw new UnsupportedOperationException("Not support the operation!"); - } - -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java deleted file mode 100644 index 69cb6feca13e4..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupMapData.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.MapData; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -public class ColumnarGroupMapData implements MapData { - - WritableColumnVector keyVector; - WritableColumnVector valueVector; - int rowId; - - public ColumnarGroupMapData(WritableColumnVector keyVector, WritableColumnVector valueVector, int rowId) { - this.keyVector = keyVector; - this.valueVector = valueVector; - this.rowId = rowId; - } - - @Override - public int size() { - if (keyVector == null) { - return 0; - } - - if (keyVector instanceof HeapArrayVector) { - return ((HeapArrayVector) keyVector).getArray(rowId).size(); - } - throw new UnsupportedOperationException(keyVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector"); - } - - @Override - public ArrayData keyArray() { - return ((HeapArrayVector) keyVector).getArray(rowId); - } - - @Override - public ArrayData valueArray() { - if (valueVector instanceof HeapArrayVector) { - return ((HeapArrayVector) valueVector).getArray(rowId); - } else if (valueVector instanceof HeapArrayGroupColumnVector) { - return ((HeapArrayGroupColumnVector) valueVector).getArray(rowId); - } - throw new UnsupportedOperationException(valueVector.getClass().getName() + " is not supported. Supported vector types: HeapArrayVector, HeapArrayGroupColumnVector"); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java deleted file mode 100644 index bfd0978f31565..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ColumnarGroupRowData.java +++ /dev/null @@ -1,144 +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.hudi.table.format.cow.vector; - -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.RawValueData; -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.RowKind; -import org.apache.flink.types.variant.Variant; - -public class ColumnarGroupRowData implements RowData { - - HeapRowColumnVector vector; - int rowId; - int index; - - public ColumnarGroupRowData(HeapRowColumnVector vector, int rowId, int index) { - this.vector = vector; - this.rowId = rowId; - this.index = index; - } - - @Override - public int getArity() { - return vector.vectors.length; - } - - @Override - public RowKind getRowKind() { - return RowKind.INSERT; - } - - @Override - public void setRowKind(RowKind rowKind) { - throw new UnsupportedOperationException("Not support the operation!"); - } - - @Override - public boolean isNullAt(int pos) { - return - vector.vectors[pos].isNullAt(rowId) - || ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).isNullAt(index); - } - - @Override - public boolean getBoolean(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBoolean(index); - } - - @Override - public byte getByte(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getByte(index); - } - - @Override - public short getShort(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getShort(index); - } - - @Override - public int getInt(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getInt(index); - } - - @Override - public long getLong(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getLong(index); - } - - @Override - public float getFloat(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getFloat(index); - } - - @Override - public double getDouble(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDouble(index); - } - - @Override - public StringData getString(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getString(index); - } - - @Override - public DecimalData getDecimal(int pos, int i1, int i2) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getDecimal(index, i1, i2); - } - - @Override - public TimestampData getTimestamp(int pos, int i1) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getTimestamp(index, i1); - } - - @Override - public RawValueData getRawValue(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRawValue(index); - } - - @Override - public byte[] getBinary(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getBinary(index); - } - - @Override - public ArrayData getArray(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getArray(index); - } - - @Override - public MapData getMap(int pos) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getMap(index); - } - - @Override - public RowData getRow(int pos, int numFields) { - return ((HeapArrayVector) (vector.vectors[pos])).getArray(rowId).getRow(index, numFields); - } - - @Override - public Variant getVariant(int i) { - throw new UnsupportedOperationException("Variant is not supported yet."); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java deleted file mode 100644 index 3d7d8b1f0de0f..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayGroupColumnVector.java +++ /dev/null @@ -1,53 +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.hudi.table.format.cow.vector; - -import org.apache.flink.table.data.ArrayData; -import org.apache.flink.table.data.columnar.vector.ArrayColumnVector; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -/** - * This class represents a nullable heap row column vector. - */ -public class HeapArrayGroupColumnVector extends AbstractHeapVector - implements WritableColumnVector, ArrayColumnVector { - - public WritableColumnVector vector; - - public HeapArrayGroupColumnVector(int len) { - super(len); - } - - public HeapArrayGroupColumnVector(int len, WritableColumnVector vector) { - super(len); - this.vector = vector; - } - - @Override - public ArrayData getArray(int rowId) { - return new ColumnarGroupArrayData(vector, rowId); - } - - @Override - public void reset() { - super.reset(); - vector.reset(); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java index 7db66d23d6fc8..c597bafad1ed2 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapArrayVector.java @@ -49,6 +49,10 @@ public HeapArrayVector(int len, ColumnVector vector) { this.child = vector; } + public int getLen() { + return this.isNull.length; + } + public int getSize() { return size; } @@ -57,8 +61,35 @@ public void setSize(int size) { this.size = size; } - public int getLen() { - return this.isNull.length; + // --------------------------------------------------------------------------------------------- + // Flink 2.1-compatible accessors. Backed by the existing public {@code offsets}, {@code lengths} + // and {@code child} fields so legacy callers continue to work; the new {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + // future Flink-2.1-style caller use these accessors. + // --------------------------------------------------------------------------------------------- + + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public ColumnVector getChild() { + return child; + } + + public void setChild(ColumnVector child) { + this.child = child; } @Override diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java index f828ae9dffa78..293604e02c4aa 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapMapColumnVector.java @@ -19,35 +19,103 @@ package org.apache.hudi.table.format.cow.vector; import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.columnar.ColumnarMapData; +import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.MapColumnVector; import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; /** * This class represents a nullable heap map column vector. + * + *

    Mirrors {@code org.apache.flink.table.data.columnar.vector.heap.HeapMapVector} from + * Flink 2.1 (FLINK-35702). One deliberate divergence from upstream is preserved for backward + * compatibility: the {@code keys} / {@code values} fields are typed + * {@link WritableColumnVector} rather than upstream's {@link ColumnVector}, so the existing + * Lombok-generated {@code getKeys()} / {@code getValues()} accessors keep their original + * signature. Callers wanting the Flink-2.1 contract (a {@code ColumnVector}) use + * {@link #getKeyColumnVector()} / {@link #getValueColumnVector()}. */ public class HeapMapColumnVector extends AbstractHeapVector implements WritableColumnVector, MapColumnVector { - private final WritableColumnVector keys; - private final WritableColumnVector values; + private WritableColumnVector keys; + private WritableColumnVector values; + + // --------------------------------------------------------------------------------------------- + // Flink 2.1 Dremel-style state. Populated by {@link + // org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and + // consumed by {@link #getMap(int)}. + // --------------------------------------------------------------------------------------------- + private long[] offsets; + private long[] lengths; + private int size; public HeapMapColumnVector(int len, WritableColumnVector keys, WritableColumnVector values) { super(len); + this.offsets = new long[len]; + this.lengths = new long[len]; this.keys = keys; this.values = values; } + public long[] getOffsets() { + return offsets; + } + + public void setOffsets(long[] offsets) { + this.offsets = offsets; + } + + public long[] getLengths() { + return lengths; + } + + public void setLengths(long[] lengths) { + this.lengths = lengths; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + public WritableColumnVector getKeys() { return keys; } + public void setKeys(WritableColumnVector keys) { + this.keys = keys; + } + public WritableColumnVector getValues() { return values; } + public void setValues(WritableColumnVector values) { + this.values = values; + } + + /** + * Returns the keys child vector typed as {@link ColumnVector}, matching the Flink 2.1 contract + * consumed by {@code NestedColumnReader}. Functionally equivalent to {@link #getKeys()}. + */ + public ColumnVector getKeyColumnVector() { + return keys; + } + + /** Counterpart of {@link #getKeyColumnVector()} for the values child vector. */ + public ColumnVector getValueColumnVector() { + return values; + } + @Override public MapData getMap(int rowId) { - return new ColumnarGroupMapData(keys, values, rowId); + long offset = offsets[rowId]; + long length = lengths[rowId]; + return new ColumnarMapData(keys, values, (int) offset, (int) length); } } diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java index ae194e4e6ab05..0c640ce92ee40 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/HeapRowColumnVector.java @@ -37,6 +37,21 @@ public HeapRowColumnVector(int len, WritableColumnVector... vectors) { this.vectors = vectors; } + /** + * Flink 2.1-compatible accessor for the children vectors. Backed by the existing public {@code + * vectors} field so legacy callers continue to work; the new {@link + * org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader} (FLINK-35702 port) and any + * future Flink-2.1-style caller use this accessor. + */ + public WritableColumnVector[] getFields() { + return vectors; + } + + /** Counterpart of {@link #getFields()}. */ + public void setFields(WritableColumnVector[] fields) { + this.vectors = fields; + } + @Override public ColumnarRowData getRow(int i) { ColumnarRowData columnarRowData = new ColumnarRowData(new VectorizedColumnBatch(vectors)); diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java index 98b5e61050898..a37b88352cf52 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/ParquetDecimalVector.java @@ -18,21 +18,29 @@ package org.apache.hudi.table.format.cow.vector; +import org.apache.flink.formats.parquet.utils.ParquetSchemaConverter; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.columnar.vector.BytesColumnVector; import org.apache.flink.table.data.columnar.vector.ColumnVector; import org.apache.flink.table.data.columnar.vector.DecimalColumnVector; +import org.apache.flink.table.data.columnar.vector.Dictionary; +import org.apache.flink.table.data.columnar.vector.IntColumnVector; +import org.apache.flink.table.data.columnar.vector.LongColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableBytesVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableIntVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableLongVector; + +import static org.apache.flink.util.Preconditions.checkArgument; /** - * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to - * provide {@link DecimalColumnVector} interface. - * - *

    Reference Flink release 1.11.2 {@link org.apache.flink.formats.parquet.vector.ParquetDecimalVector} - * because it is not public. + * Parquet write decimal as int32 and int64 and binary, this class wrap the real vector to provide + * {@link DecimalColumnVector} interface. */ -public class ParquetDecimalVector implements DecimalColumnVector { +public class ParquetDecimalVector + implements DecimalColumnVector, WritableLongVector, WritableIntVector, WritableBytesVector { - public final ColumnVector vector; + private final ColumnVector vector; public ParquetDecimalVector(ColumnVector vector) { this.vector = vector; @@ -40,15 +48,180 @@ public ParquetDecimalVector(ColumnVector vector) { @Override public DecimalData getDecimal(int i, int precision, int scale) { - return DecimalData.fromUnscaledBytes( - ((BytesColumnVector) vector).getBytes(i).getBytes(), - precision, - scale); + if (ParquetSchemaConverter.is32BitDecimal(precision) && vector instanceof IntColumnVector) { + return DecimalData.fromUnscaledLong(((IntColumnVector) vector).getInt(i), precision, scale); + } else if (ParquetSchemaConverter.is64BitDecimal(precision) + && vector instanceof LongColumnVector) { + return DecimalData.fromUnscaledLong(((LongColumnVector) vector).getLong(i), precision, scale); + } else { + checkArgument( + vector instanceof BytesColumnVector, + "Reading decimal type occur unsupported vector type: %s", + vector.getClass()); + return DecimalData.fromUnscaledBytes( + ((BytesColumnVector) vector).getBytes(i).getBytes(), precision, scale); + } + } + + public ColumnVector getVector() { + return vector; } @Override public boolean isNullAt(int i) { return vector.isNullAt(i); } -} + @Override + public void reset() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).reset(); + } + } + + @Override + public void setNullAt(int rowId) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNullAt(rowId); + } + } + + @Override + public void setNulls(int rowId, int count) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setNulls(rowId, count); + } + } + + @Override + public void fillWithNulls() { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).fillWithNulls(); + } + } + + @Override + public void setDictionary(Dictionary dictionary) { + if (vector instanceof WritableColumnVector) { + ((WritableColumnVector) vector).setDictionary(dictionary); + } + } + + @Override + public boolean hasDictionary() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).hasDictionary(); + } + return false; + } + + @Override + public WritableIntVector reserveDictionaryIds(int capacity) { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).reserveDictionaryIds(capacity); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public WritableIntVector getDictionaryIds() { + if (vector instanceof WritableColumnVector) { + return ((WritableColumnVector) vector).getDictionaryIds(); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public Bytes getBytes(int i) { + if (vector instanceof WritableBytesVector) { + return ((WritableBytesVector) vector).getBytes(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void appendBytes(int rowId, byte[] value, int offset, int length) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).appendBytes(rowId, value, offset, length); + } + } + + @Override + public void fill(byte[] value) { + if (vector instanceof WritableBytesVector) { + ((WritableBytesVector) vector).fill(value); + } + } + + @Override + public int getInt(int i) { + if (vector instanceof WritableIntVector) { + return ((WritableIntVector) vector).getInt(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setInt(int rowId, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInt(rowId, value); + } + } + + @Override + public void setIntsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setIntsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void setInts(int rowId, int count, int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, value); + } + } + + @Override + public void setInts(int rowId, int count, int[] src, int srcIndex) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).setInts(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(int value) { + if (vector instanceof WritableIntVector) { + ((WritableIntVector) vector).fill(value); + } + } + + @Override + public long getLong(int i) { + if (vector instanceof WritableLongVector) { + return ((WritableLongVector) vector).getLong(i); + } + throw new RuntimeException("Child vector must be instance of WritableColumnVector"); + } + + @Override + public void setLong(int rowId, long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLong(rowId, value); + } + } + + @Override + public void setLongsFromBinary(int rowId, int count, byte[] src, int srcIndex) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).setLongsFromBinary(rowId, count, src, srcIndex); + } + } + + @Override + public void fill(long value) { + if (vector instanceof WritableLongVector) { + ((WritableLongVector) vector).fill(value); + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.java new file mode 100644 index 0000000000000..fcdedfbc9d71d --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/CollectionPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent collection's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.CollectionPosition}). + */ +public class CollectionPosition { + @Nullable private final boolean[] isNull; + private final long[] offsets; + private final long[] length; + private final int valueCount; + + public CollectionPosition(boolean[] isNull, long[] offsets, long[] length, int valueCount) { + this.isNull = isNull; + this.offsets = offsets; + this.length = length; + this.valueCount = valueCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public long[] getOffsets() { + return offsets; + } + + public long[] getLength() { + return length; + } + + public int getValueCount() { + return valueCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.java new file mode 100644 index 0000000000000..fe95419ac3218 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/LevelDelegation.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.hudi.table.format.cow.vector.position; + +/** + * To delegate repetition level and definition level. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.LevelDelegation}). + */ +public class LevelDelegation { + private final int[] repetitionLevel; + private final int[] definitionLevel; + + public LevelDelegation(int[] repetitionLevel, int[] definitionLevel) { + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + } + + public int[] getRepetitionLevel() { + return repetitionLevel; + } + + public int[] getDefinitionLevel() { + return definitionLevel; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.java new file mode 100644 index 0000000000000..5438b67973238 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/position/RowPosition.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.hudi.table.format.cow.vector.position; + +import javax.annotation.Nullable; + +/** + * To represent struct's position in repeated type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.position.RowPosition}). + */ +public class RowPosition { + @Nullable private final boolean[] isNull; + private final int positionsCount; + + public RowPosition(boolean[] isNull, int positionsCount) { + this.isNull = isNull; + this.positionsCount = positionsCount; + } + + public boolean[] getIsNull() { + return isNull; + } + + public int getPositionsCount() { + return positionsCount; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java deleted file mode 100644 index 6a8a01b74946a..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayColumnReader.java +++ /dev/null @@ -1,473 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayVector; -import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.TimestampData; -import org.apache.flink.table.data.columnar.vector.VectorizedColumnBatch; -import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; -import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; -import org.apache.flink.table.types.logical.ArrayType; -import org.apache.flink.table.types.logical.LogicalType; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Array {@link ColumnReader}. - */ -public class ArrayColumnReader extends BaseVectorizedColumnReader { - - // The value read in last time - private Object lastValue; - - // flag to indicate if there is no data in parquet data page - private boolean eof = false; - - // flag to indicate if it's the first time to read parquet data page with this instance - boolean isFirstRow = true; - - public ArrayColumnReader( - ColumnDescriptor descriptor, - PageReader pageReader, - boolean isUtcTimestamp, - Type type, - LogicalType logicalType) - throws IOException { - super(descriptor, pageReader, isUtcTimestamp, type, logicalType); - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayVector lcv = (HeapArrayVector) vector; - // before readBatch, initial the size of offsets & lengths as the default value, - // the actual size will be assigned in setChildrenInfo() after reading complete. - lcv.offsets = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - lcv.lengths = new long[VectorizedColumnBatch.DEFAULT_SIZE]; - // Because the length of ListColumnVector.child can't be known now, - // the valueList will save all data for ListColumnVector temporary. - List valueList = new ArrayList<>(); - - LogicalType category = ((ArrayType) logicalType).getElementType(); - - // read the first row in parquet data page, this will be only happened once for this - // instance - if (isFirstRow) { - if (!fetchNextValue(category)) { - return; - } - isFirstRow = false; - } - - int index = collectDataFromParquetPage(readNumber, lcv, valueList, category); - - // Convert valueList to array for the ListColumnVector.child - fillColumnVector(category, lcv, valueList, index); - } - - /** - * Reads a single value from parquet page, puts it into lastValue. Returns a boolean indicating - * if there is more values to read (true). - * - * @param category - * @return boolean - * @throws IOException - */ - private boolean fetchNextValue(LogicalType category) throws IOException { - int left = readPageIfNeed(); - if (left > 0) { - // get the values of repetition and definitionLevel - readRepetitionAndDefinitionLevels(); - // read the data if it isn't null - if (definitionLevel == maxDefLevel) { - if (isCurrentPageDictionaryEncoded) { - lastValue = dataColumn.readValueDictionaryId(); - } else { - lastValue = readPrimitiveTypedRow(category); - } - } else { - lastValue = null; - } - return true; - } else { - eof = true; - return false; - } - } - - private int readPageIfNeed() throws IOException { - // Compute the number of values we want to read in this page. - int leftInPage = (int) (endOfPageValueCount - valuesRead); - if (leftInPage == 0) { - // no data left in current page, load data from new page - readPage(); - leftInPage = (int) (endOfPageValueCount - valuesRead); - } - return leftInPage; - } - - // Need to be in consistent with that VectorizedPrimitiveColumnReader#readBatchHelper - // TODO Reduce the duplicated code - private Object readPrimitiveTypedRow(LogicalType category) { - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dataColumn.readString(); - case BOOLEAN: - return dataColumn.readBoolean(); - case TIME_WITHOUT_TIME_ZONE: - case DATE: - case INTEGER: - return dataColumn.readInteger(); - case TINYINT: - return dataColumn.readTinyInt(); - case SMALLINT: - return dataColumn.readSmallInt(); - case BIGINT: - return dataColumn.readLong(); - case FLOAT: - return dataColumn.readFloat(); - case DOUBLE: - return dataColumn.readDouble(); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dataColumn.readInteger(); - case INT64: - return dataColumn.readLong(); - case BINARY: - case FIXED_LEN_BYTE_ARRAY: - return dataColumn.readString(); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dataColumn.readTimestamp(); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { - if (dictionaryValue == null) { - return null; - } - - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - return dictionary.readString(dictionaryValue); - case DATE: - case TIME_WITHOUT_TIME_ZONE: - case INTEGER: - return dictionary.readInteger(dictionaryValue); - case BOOLEAN: - return dictionary.readBoolean(dictionaryValue) ? 1 : 0; - case DOUBLE: - return dictionary.readDouble(dictionaryValue); - case FLOAT: - return dictionary.readFloat(dictionaryValue); - case TINYINT: - return dictionary.readTinyInt(dictionaryValue); - case SMALLINT: - return dictionary.readSmallInt(dictionaryValue); - case BIGINT: - return dictionary.readLong(dictionaryValue); - case DECIMAL: - switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { - case INT32: - return dictionary.readInteger(dictionaryValue); - case INT64: - return dictionary.readLong(dictionaryValue); - case FIXED_LEN_BYTE_ARRAY: - case BINARY: - return dictionary.readString(dictionaryValue); - default: - throw new AssertionError(); - } - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return dictionary.readTimestamp(dictionaryValue); - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } - - /** - * Collects data from a parquet page and returns the final row index where it stopped. The - * returned index can be equal to or less than total. - * - * @param total maximum number of rows to collect - * @param lcv column vector to do initial setup in data collection time - * @param valueList collection of values that will be fed into the vector later - * @param category - * @return int - * @throws IOException - */ - private int collectDataFromParquetPage( - int total, HeapArrayVector lcv, List valueList, LogicalType category) - throws IOException { - int index = 0; - /* - * Here is a nested loop for collecting all values from a parquet page. - * A column of array type can be considered as a list of lists, so the two loops are as below: - * 1. The outer loop iterates on rows (index is a row index, so points to a row in the batch), e.g.: - * [0, 2, 3] <- index: 0 - * [NULL, 3, 4] <- index: 1 - * - * 2. The inner loop iterates on values within a row (sets all data from parquet data page - * for an element in ListColumnVector), so fetchNextValue returns values one-by-one: - * 0, 2, 3, NULL, 3, 4 - * - * As described below, the repetition level (repetitionLevel != 0) - * can be used to decide when we'll start to read values for the next list. - */ - while (!eof && index < total) { - // add element to ListColumnVector one by one - lcv.offsets[index] = valueList.size(); - /* - * Let's collect all values for a single list. - * Repetition level = 0 means that a new list started there in the parquet page, - * in that case, let's exit from the loop, and start to collect value for a new list. - */ - do { - /* - * Definition level = 0 when a NULL value was returned instead of a list - * (this is not the same as a NULL value in of a list). - */ - if (definitionLevel == 0) { - lcv.setNullAt(index); - } - valueList.add( - isCurrentPageDictionaryEncoded - ? dictionaryDecodeValue(category, (Integer) lastValue) - : lastValue); - } while (fetchNextValue(category) && (repetitionLevel != 0)); - - lcv.lengths[index] = valueList.size() - lcv.offsets[index]; - index++; - } - return index; - } - - /** - * The lengths & offsets will be initialized as default size (1024), it should be set to the - * actual size according to the element number. - */ - private void setChildrenInfo(HeapArrayVector lcv, int itemNum, int elementNum) { - lcv.setSize(itemNum); - long[] lcvLength = new long[elementNum]; - long[] lcvOffset = new long[elementNum]; - System.arraycopy(lcv.lengths, 0, lcvLength, 0, elementNum); - System.arraycopy(lcv.offsets, 0, lcvOffset, 0, elementNum); - lcv.lengths = lcvLength; - lcv.offsets = lcvOffset; - } - - private void fillColumnVector( - LogicalType category, HeapArrayVector lcv, List valueList, int elementNum) { - int total = valueList.size(); - setChildrenInfo(lcv, total, elementNum); - switch (category.getTypeRoot()) { - case CHAR: - case VARCHAR: - case BINARY: - case VARBINARY: - lcv.child = new HeapBytesVector(total); - ((HeapBytesVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (src == null) { - ((HeapBytesVector) lcv.child).setNullAt(i); - } else { - ((HeapBytesVector) lcv.child).appendBytes(i, src, 0, src.length); - } - } - break; - case BOOLEAN: - lcv.child = new HeapBooleanVector(total); - ((HeapBooleanVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapBooleanVector) lcv.child).setNullAt(i); - } else { - ((HeapBooleanVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TINYINT: - lcv.child = new HeapByteVector(total); - ((HeapByteVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapByteVector) lcv.child).setNullAt(i); - } else { - ((HeapByteVector) lcv.child).vector[i] = - (byte) ((List) valueList).get(i).intValue(); - } - } - break; - case SMALLINT: - lcv.child = new HeapShortVector(total); - ((HeapShortVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapShortVector) lcv.child).setNullAt(i); - } else { - ((HeapShortVector) lcv.child).vector[i] = - (short) ((List) valueList).get(i).intValue(); - } - } - break; - case INTEGER: - case DATE: - case TIME_WITHOUT_TIME_ZONE: - lcv.child = new HeapIntVector(total); - ((HeapIntVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) lcv.child).setNullAt(i); - } else { - ((HeapIntVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case FLOAT: - lcv.child = new HeapFloatVector(total); - ((HeapFloatVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapFloatVector) lcv.child).setNullAt(i); - } else { - ((HeapFloatVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case BIGINT: - lcv.child = new HeapLongVector(total); - ((HeapLongVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) lcv.child).setNullAt(i); - } else { - ((HeapLongVector) lcv.child).vector[i] = ((List) valueList).get(i); - } - } - break; - case DOUBLE: - lcv.child = new HeapDoubleVector(total); - ((HeapDoubleVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapDoubleVector) lcv.child).setNullAt(i); - } else { - ((HeapDoubleVector) lcv.child).vector[i] = - ((List) valueList).get(i); - } - } - break; - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - lcv.child = new HeapTimestampVector(total); - ((HeapTimestampVector) lcv.child).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapTimestampVector) lcv.child).setNullAt(i); - } else { - ((HeapTimestampVector) lcv.child) - .setTimestamp(i, ((List) valueList).get(i)); - } - } - break; - case DECIMAL: - PrimitiveType.PrimitiveTypeName primitiveTypeName = - descriptor.getPrimitiveType().getPrimitiveTypeName(); - switch (primitiveTypeName) { - case INT32: - lcv.child = new ParquetDecimalVector(new HeapIntVector(total)); - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapIntVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - case INT64: - lcv.child = new ParquetDecimalVector(new HeapLongVector(total)); - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - if (valueList.get(i) == null) { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapLongVector) ((ParquetDecimalVector) lcv.child).vector) - .vector[i] = - ((List) valueList).get(i); - } - } - break; - default: - lcv.child = new ParquetDecimalVector(new HeapBytesVector(total)); - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector).reset(); - for (int i = 0; i < valueList.size(); i++) { - byte[] src = ((List) valueList).get(i); - if (valueList.get(i) == null) { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .setNullAt(i); - } else { - ((HeapBytesVector) ((ParquetDecimalVector) lcv.child).vector) - .appendBytes(i, src, 0, src.length); - } - } - break; - } - break; - default: - throw new RuntimeException("Unsupported type in the list: " + type); - } - } -} - diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java deleted file mode 100644 index df7c5d85bc4ab..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ArrayGroupReader.java +++ /dev/null @@ -1,45 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapArrayGroupColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Array of a Group type (Array, Map, Row, etc.) {@link ColumnReader}. - */ -public class ArrayGroupReader implements ColumnReader { - - private final ColumnReader fieldReader; - - public ArrayGroupReader(ColumnReader fieldReader) { - this.fieldReader = fieldReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapArrayGroupColumnVector rowColumnVector = (HeapArrayGroupColumnVector) vector; - - fieldReader.readToVector(readNumber, rowColumnVector.vector); - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java index fbb09823e9b96..c5a170c33e104 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/BaseVectorizedColumnReader.java @@ -228,12 +228,7 @@ private void readPageV2(DataPageV2 page) { this.definitionLevelColumn = newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); try { - LOG.debug( - "page data size " - + page.getData().size() - + " bytes and " - + pageValueCount - + " records"); + LOG.debug("page data size {} bytes and {} records", page.getData().size(), pageValueCount); initDataReader( page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); } catch (IOException e) { diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java deleted file mode 100644 index 6d743530fccc7..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/MapColumnReader.java +++ /dev/null @@ -1,55 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; - -/** - * Map {@link ColumnReader}. - */ -public class MapColumnReader implements ColumnReader { - - private final ArrayColumnReader keyReader; - private final ColumnReader valueReader; - - public MapColumnReader( - ArrayColumnReader keyReader, ColumnReader valueReader) { - this.keyReader = keyReader; - this.valueReader = valueReader; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapMapColumnVector mapColumnVector = (HeapMapColumnVector) vector; - AbstractHeapVector keyArrayColumnVector = (AbstractHeapVector) (mapColumnVector.getKeys()); - keyReader.readToVector(readNumber, mapColumnVector.getKeys()); - valueReader.readToVector(readNumber, mapColumnVector.getValues()); - for (int i = 0; i < keyArrayColumnVector.getLen(); i++) { - if (keyArrayColumnVector.isNullAt(i)) { - mapColumnVector.setNullAt(i); - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java new file mode 100644 index 0000000000000..7be03dacc2183 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedColumnReader.java @@ -0,0 +1,315 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.NestedPositionUtil; +import org.apache.hudi.table.format.cow.vector.HeapArrayVector; +import org.apache.hudi.table.format.cow.vector.HeapMapColumnVector; +import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.CollectionPosition; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; +import org.apache.hudi.table.format.cow.vector.position.RowPosition; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; +import org.apache.hudi.table.format.cow.vector.type.ParquetGroupField; +import org.apache.hudi.table.format.cow.vector.type.ParquetPrimitiveField; + +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.AbstractHeapVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.ArrayType; +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.VariantType; +import org.apache.flink.util.FlinkRuntimeException; +import org.apache.flink.util.Preconditions; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.page.PageReadStore; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ColumnReader used to read a {@code Group} type in Parquet ({@code Map}, {@code Array}, {@code + * Row}). Resolves nested structures using Dremel striping/assembly; see the + * striping and assembly algorithms from the Dremel paper. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedColumnReader}). Differences vs. upstream: + * + *

      + *
    • Uses Hudi-local {@code HeapRowColumnVector}/{@code HeapMapColumnVector}/{@code + * HeapArrayVector} instead of the Flink-private {@code HeapRowVector}/{@code + * HeapMapVector}/{@code HeapArrayVector}. + *
    • Supports Hudi's schema-evolution contract: a {@code ParquetGroupField} representing a + * {@link RowType} may contain {@code null} children — meaning the corresponding logical + * field is absent from the Parquet file. Those slots are passed through unchanged and do + * not contribute to the row's repetition/definition-level stream. + *
    + */ +public class NestedColumnReader implements ColumnReader { + + private final Map columnReaders; + private final boolean isUtcTimestamp; + + private final PageReadStore pages; + + private final ParquetField field; + + public NestedColumnReader(boolean isUtcTimestamp, PageReadStore pages, ParquetField field) { + this.isUtcTimestamp = isUtcTimestamp; + this.pages = pages; + this.field = field; + this.columnReaders = new HashMap<>(); + } + + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + readData(field, readNumber, vector, false); + } + + private Tuple2 readData( + ParquetField field, int readNumber, ColumnVector vector, boolean inside) throws IOException { + if (field.getType() instanceof RowType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof VariantType) { + return readRow((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof MapType || field.getType() instanceof MultisetType) { + return readMap((ParquetGroupField) field, readNumber, vector, inside); + } else if (field.getType() instanceof ArrayType) { + return readArray((ParquetGroupField) field, readNumber, vector, inside); + } else { + return readPrimitive((ParquetPrimitiveField) field, readNumber, vector); + } + } + + private Tuple2 readRow( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapRowColumnVector heapRowVector = (HeapRowColumnVector) vector; + LevelDelegation levelDelegation = null; + List children = field.getChildren(); + WritableColumnVector[] childrenVectors = heapRowVector.getFields(); + WritableColumnVector[] finalChildrenVectors = new WritableColumnVector[childrenVectors.length]; + for (int i = 0; i < children.size(); i++) { + ParquetField child = children.get(i); + if (child == null) { + // Schema-evolution: the logical field is not present in the Parquet file. The slot + // vector was pre-populated with nulls by ParquetSplitReaderUtil#createWritableColumnVector + // (ROW branch), but HeapRowColumnVector#reset() (invoked once per batch by + // ParquetColumnarRowSplitReader#nextBatch) cascades to the children and clears those null + // flags. Since an absent field is never re-read, re-apply the nulls here so the column stays + // NULL instead of reverting to the type's zero value. Skip contributing to the level stream. + childrenVectors[i].fillWithNulls(); + finalChildrenVectors[i] = childrenVectors[i]; + continue; + } + Tuple2 tuple = + readData(child, readNumber, childrenVectors[i], true); + levelDelegation = tuple.f0; + finalChildrenVectors[i] = tuple.f1; + } + if (levelDelegation == null) { + throw new FlinkRuntimeException( + String.format("Row field does not have any non-null children: %s.", field)); + } + + RowPosition rowPosition = + NestedPositionUtil.calculateRowOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If row was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + heapRowVector = new HeapRowColumnVector(rowPosition.getPositionsCount(), finalChildrenVectors); + } else { + heapRowVector.setFields(finalChildrenVectors); + } + + if (rowPosition.getIsNull() != null) { + setFieldNullFlag(rowPosition.getIsNull(), heapRowVector); + } + + // Hudi-specific: collapse a present row whose every child is null into a null row, so that a + // SQL value like `row(null, null)` round-trips to NULL on read. This was the behaviour of the + // legacy RowColumnReader (deleted alongside the Dremel rewire) and existing Hudi tables rely + // on it. Diverges from Flink 2.1, which would surface it as Row(null, null). Pinned by the + // integration test ITTestHoodieDataSource#testParquetNullChildColumnsRowTypes. + // positionsCount comes from the Dremel definition/repetition level stream + // (NestedPositionUtil#calculateRowOffsets). On a full, non-final batch that stream carries a + // one-record lookahead (NestedPrimitiveColumnReader#readAndNewVector reads one value past the + // batch in its do/while, and #getLevelDelegation keeps that trailing level for the next batch), + // so positionsCount can be one larger than the materialized vector lengths. When inside==true + // the row vector is renewed to positionsCount but its children are sized to their value count; + // when inside==false the row vector keeps its batch capacity. Either way, iterating all the way + // to positionsCount can read one element past a shorter vector and throw + // ArrayIndexOutOfBoundsException. Clamp to the shortest vector this loop indexes -- the phantom + // trailing position is never surfaced downstream (ParquetColumnarRowSplitReader caps the batch + // at num). + int rowCount = Math.min(rowPosition.getPositionsCount(), heapRowVector.getLen()); + for (WritableColumnVector child : finalChildrenVectors) { + rowCount = Math.min(rowCount, vectorLength(child)); + } + for (int j = 0; j < rowCount; j++) { + if (heapRowVector.isNullAt(j)) { + continue; + } + boolean allChildrenNull = true; + for (WritableColumnVector child : finalChildrenVectors) { + if (!child.isNullAt(j)) { + allChildrenNull = false; + break; + } + } + if (allChildrenNull) { + heapRowVector.setNullAt(j); + } + } + return Tuple2.of(levelDelegation, heapRowVector); + } + + private Tuple2 readMap( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapMapColumnVector mapVector = (HeapMapColumnVector) vector; + mapVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 2, + "Maps must have two type parameters, found %s", + children.size()); + Tuple2 keyTuple = + readData(children.get(0), readNumber, mapVector.getKeyColumnVector(), true); + Tuple2 valueTuple = + readData(children.get(1), readNumber, mapVector.getValueColumnVector(), true); + + LevelDelegation levelDelegation = keyTuple.f0; + + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If map was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + mapVector = new HeapMapColumnVector(collectionPosition.getValueCount(), keyTuple.f1, valueTuple.f1); + } else { + mapVector.setKeys(keyTuple.f1); + mapVector.setValues(valueTuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), mapVector); + } + + mapVector.setLengths(collectionPosition.getLength()); + mapVector.setOffsets(collectionPosition.getOffsets()); + + return Tuple2.of(levelDelegation, mapVector); + } + + private Tuple2 readArray( + ParquetGroupField field, int readNumber, ColumnVector vector, boolean inside) + throws IOException { + HeapArrayVector arrayVector = (HeapArrayVector) vector; + arrayVector.reset(); + List children = field.getChildren(); + Preconditions.checkArgument( + children.size() == 1, + "Arrays must have a single type parameter, found %s", + children.size()); + Tuple2 tuple = + readData(children.get(0), readNumber, arrayVector.getChild(), true); + + LevelDelegation levelDelegation = tuple.f0; + CollectionPosition collectionPosition = + NestedPositionUtil.calculateCollectionOffsets( + field, + levelDelegation.getDefinitionLevel(), + levelDelegation.getRepetitionLevel()); + + // If array was inside the structure, then we need to renew the vector to reset the + // capacity. + if (inside) { + arrayVector = new HeapArrayVector(collectionPosition.getValueCount(), tuple.f1); + } else { + arrayVector.setChild(tuple.f1); + } + + if (collectionPosition.getIsNull() != null) { + setFieldNullFlag(collectionPosition.getIsNull(), arrayVector); + } + arrayVector.setLengths(collectionPosition.getLength()); + arrayVector.setOffsets(collectionPosition.getOffsets()); + return Tuple2.of(levelDelegation, arrayVector); + } + + private Tuple2 readPrimitive( + ParquetPrimitiveField field, int readNumber, ColumnVector vector) throws IOException { + ColumnDescriptor descriptor = field.getDescriptor(); + NestedPrimitiveColumnReader reader = columnReaders.get(descriptor); + if (reader == null) { + reader = + new NestedPrimitiveColumnReader( + descriptor, + pages.getPageReader(descriptor), + isUtcTimestamp, + descriptor.getPrimitiveType(), + field.getType()); + columnReaders.put(descriptor, reader); + } + WritableColumnVector writableColumnVector = + reader.readAndNewVector(readNumber, (WritableColumnVector) vector); + return Tuple2.of(reader.getLevelDelegation(), writableColumnVector); + } + + /** + * The length of the {@code isNull}-backed storage that {@code vector} (a row child) is indexed + * against by the null-collapse loop in {@link #readRow}. Every row child is an {@link + * AbstractHeapVector} (nested rows/arrays/maps and all non-decimal primitives) or a {@link + * ParquetDecimalVector} wrapping one (DECIMAL leaves; see {@code + * NestedPrimitiveColumnReader#fillColumnVector}); unwrapping the latter yields an {@code + * AbstractHeapVector} in all cases. + */ + private static int vectorLength(ColumnVector vector) { + ColumnVector storage = + vector instanceof ParquetDecimalVector + ? ((ParquetDecimalVector) vector).getVector() + : vector; + return ((AbstractHeapVector) storage).getLen(); + } + + private static void setFieldNullFlag(boolean[] nullFlags, AbstractHeapVector vector) { + for (int index = 0; index < vector.getLen() && index < nullFlags.length; index++) { + if (nullFlags[index]) { + vector.setNullAt(index); + } + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java new file mode 100644 index 0000000000000..a18520c3b5cd5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/NestedPrimitiveColumnReader.java @@ -0,0 +1,638 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector.reader; + +import org.apache.hudi.table.format.cow.utils.IntArrayList; +import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.position.LevelDelegation; + +import org.apache.flink.formats.parquet.vector.reader.ColumnReader; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.data.columnar.vector.heap.HeapBooleanVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapByteVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapDoubleVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapFloatVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapTimestampVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +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.DictionaryPage; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.parquet.column.ValuesType.DEFINITION_LEVEL; +import static org.apache.parquet.column.ValuesType.REPETITION_LEVEL; +import static org.apache.parquet.column.ValuesType.VALUES; + +/** + * Reader to read a single primitive leaf column that participates in a nested (Dremel) structure. + * + *

    Vendored from Apache Flink 2.1 (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.reader.NestedPrimitiveColumnReader}). Only the package + * and the Hudi-local {@link ParquetDecimalVector} / {@link LevelDelegation} / {@link IntArrayList} + * imports are changed; the algorithm is untouched. The companion Hudi-specific {@code + * Int64TimestampColumnReader} / {@code FixedLenBytesColumnReader} behaviours stay at the leaf- + * reader creation boundary in {@code ParquetSplitReaderUtil}, not inside this class — keeping it + * a faithful copy of upstream. + */ +public class NestedPrimitiveColumnReader implements ColumnReader { + private static final Logger LOG = LoggerFactory.getLogger(NestedPrimitiveColumnReader.class); + + private final IntArrayList repetitionLevelList = new IntArrayList(0); + private final IntArrayList definitionLevelList = new IntArrayList(0); + + private final PageReader pageReader; + private final ColumnDescriptor descriptor; + private final Type type; + private final LogicalType logicalType; + + /** The dictionary, if this column has dictionary encoding. */ + private final ParquetDataColumnReader dictionary; + + /** Maximum definition level for this column. */ + private final int maxDefLevel; + + private boolean isUtcTimestamp; + + /** Total number of values read. */ + private long valuesRead; + + /** + * value that indicates the end of the current page. That is, if valuesRead == + * endOfPageValueCount, we are at the end of the page. + */ + private long endOfPageValueCount; + + /** If true, the current page is dictionary encoded. */ + private boolean isCurrentPageDictionaryEncoded; + + private int definitionLevel; + private int repetitionLevel; + + /** Repetition/Definition/Value readers. */ + private IntIterator repetitionLevelColumn; + + private IntIterator definitionLevelColumn; + private ParquetDataColumnReader dataColumn; + + /** Total values in the current page. */ + private int pageValueCount; + + // flag to indicate if there is no data in parquet data page + private boolean eof = false; + + private boolean isFirstRow = true; + + private Object lastValue; + + public NestedPrimitiveColumnReader( + ColumnDescriptor descriptor, + PageReader pageReader, + boolean isUtcTimestamp, + Type parquetType, + LogicalType logicalType) + throws IOException { + this.descriptor = descriptor; + this.type = parquetType; + this.pageReader = pageReader; + this.maxDefLevel = descriptor.getMaxDefinitionLevel(); + this.isUtcTimestamp = isUtcTimestamp; + this.logicalType = logicalType; + + DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); + if (dictionaryPage != null) { + try { + this.dictionary = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + parquetType.asPrimitiveType(), + dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } catch (IOException e) { + throw new IOException( + String.format("Could not decode the dictionary for %s", descriptor), e); + } + } else { + this.dictionary = null; + this.isCurrentPageDictionaryEncoded = false; + } + } + + // Not invoked directly; callers use readAndNewVector instead. + @Override + public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { + throw new UnsupportedOperationException("This function should not be called."); + } + + public WritableColumnVector readAndNewVector(int readNumber, WritableColumnVector vector) + throws IOException { + if (isFirstRow) { + if (!readValue()) { + return vector; + } + isFirstRow = false; + } + + // index to set value. + int index = 0; + int valueIndex = 0; + List valueList = new ArrayList<>(); + + // repeated type need two loops to read data. + while (!eof && index < readNumber) { + do { + valueList.add(lastValue); + valueIndex++; + } while (readValue() && (repetitionLevel != 0)); + index++; + } + + return fillColumnVector(valueIndex, valueList); + } + + public LevelDelegation getLevelDelegation() { + int[] repetition = repetitionLevelList.toArray(); + int[] definition = definitionLevelList.toArray(); + repetitionLevelList.clear(); + definitionLevelList.clear(); + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + return new LevelDelegation(repetition, definition); + } + + private boolean readValue() throws IOException { + int left = readPageIfNeed(); + if (left > 0) { + // get the values of repetition and definitionLevel + readAndSaveRepetitionAndDefinitionLevels(); + // read the data if it isn't null + if (definitionLevel == maxDefLevel) { + if (isCurrentPageDictionaryEncoded) { + int dictionaryId = dataColumn.readValueDictionaryId(); + lastValue = dictionaryDecodeValue(logicalType, dictionaryId); + } else { + lastValue = readPrimitiveTypedRow(logicalType); + } + } else { + lastValue = null; + } + return true; + } else { + eof = true; + return false; + } + } + + private void readAndSaveRepetitionAndDefinitionLevels() { + // get the values of repetition and definitionLevel + repetitionLevel = repetitionLevelColumn.nextInt(); + definitionLevel = definitionLevelColumn.nextInt(); + valuesRead++; + repetitionLevelList.add(repetitionLevel); + definitionLevelList.add(definitionLevel); + } + + private int readPageIfNeed() throws IOException { + // Compute the number of values we want to read in this page. + int leftInPage = (int) (endOfPageValueCount - valuesRead); + if (leftInPage == 0) { + // no data left in current page, load data from new page + readPage(); + leftInPage = (int) (endOfPageValueCount - valuesRead); + } + return leftInPage; + } + + private Object readPrimitiveTypedRow(LogicalType category) { + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dataColumn.readBytes(); + case BOOLEAN: + return dataColumn.readBoolean(); + case TIME_WITHOUT_TIME_ZONE: + case DATE: + case INTEGER: + return dataColumn.readInteger(); + case TINYINT: + return dataColumn.readTinyInt(); + case SMALLINT: + return dataColumn.readSmallInt(); + case BIGINT: + return dataColumn.readLong(); + case FLOAT: + return dataColumn.readFloat(); + case DOUBLE: + return dataColumn.readDouble(); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dataColumn.readInteger(); + case INT64: + return dataColumn.readLong(); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return dataColumn.readBytes(); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dataColumn.readTimestamp(); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + private Object dictionaryDecodeValue(LogicalType category, Integer dictionaryValue) { + if (dictionaryValue == null) { + return null; + } + + switch (category.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return dictionary.readBytes(dictionaryValue); + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case INTEGER: + return dictionary.readInteger(dictionaryValue); + case BOOLEAN: + return dictionary.readBoolean(dictionaryValue) ? 1 : 0; + case DOUBLE: + return dictionary.readDouble(dictionaryValue); + case FLOAT: + return dictionary.readFloat(dictionaryValue); + case TINYINT: + return dictionary.readTinyInt(dictionaryValue); + case SMALLINT: + return dictionary.readSmallInt(dictionaryValue); + case BIGINT: + return dictionary.readLong(dictionaryValue); + case DECIMAL: + switch (descriptor.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + return dictionary.readInteger(dictionaryValue); + case INT64: + return dictionary.readLong(dictionaryValue); + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return dictionary.readBytes(dictionaryValue); + default: + throw new RuntimeException( + "Unsupported physical type for DECIMAL: " + descriptor.getPrimitiveType()); + } + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return dictionary.readTimestamp(dictionaryValue); + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WritableColumnVector fillColumnVector(int total, List valueList) { + switch (logicalType.getTypeRoot()) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + HeapBytesVector heapBytesVector = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (src == null) { + heapBytesVector.setNullAt(i); + } else { + heapBytesVector.appendBytes(i, src, 0, src.length); + } + } + return heapBytesVector; + case BOOLEAN: + HeapBooleanVector heapBooleanVector = new HeapBooleanVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapBooleanVector.setNullAt(i); + } else { + heapBooleanVector.vector[i] = ((List) valueList).get(i); + } + } + return heapBooleanVector; + case TINYINT: + HeapByteVector heapByteVector = new HeapByteVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapByteVector.setNullAt(i); + } else { + heapByteVector.vector[i] = (byte) ((List) valueList).get(i).intValue(); + } + } + return heapByteVector; + case SMALLINT: + HeapShortVector heapShortVector = new HeapShortVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapShortVector.setNullAt(i); + } else { + heapShortVector.vector[i] = (short) ((List) valueList).get(i).intValue(); + } + } + return heapShortVector; + case INTEGER: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + HeapIntVector heapIntVector = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapIntVector.setNullAt(i); + } else { + heapIntVector.vector[i] = ((List) valueList).get(i); + } + } + return heapIntVector; + case FLOAT: + HeapFloatVector heapFloatVector = new HeapFloatVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapFloatVector.setNullAt(i); + } else { + heapFloatVector.vector[i] = ((List) valueList).get(i); + } + } + return heapFloatVector; + case BIGINT: + HeapLongVector heapLongVector = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapLongVector.setNullAt(i); + } else { + heapLongVector.vector[i] = ((List) valueList).get(i); + } + } + return heapLongVector; + case DOUBLE: + HeapDoubleVector heapDoubleVector = new HeapDoubleVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapDoubleVector.setNullAt(i); + } else { + heapDoubleVector.vector[i] = ((List) valueList).get(i); + } + } + return heapDoubleVector; + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + HeapTimestampVector heapTimestampVector = new HeapTimestampVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + heapTimestampVector.setNullAt(i); + } else { + heapTimestampVector.setTimestamp(i, ((List) valueList).get(i)); + } + } + return heapTimestampVector; + case DECIMAL: + PrimitiveType.PrimitiveTypeName primitiveTypeName = + descriptor.getPrimitiveType().getPrimitiveTypeName(); + switch (primitiveTypeName) { + case INT32: + HeapIntVector phiv = new HeapIntVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phiv.setNullAt(i); + } else { + phiv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phiv); + case INT64: + HeapLongVector phlv = new HeapLongVector(total); + for (int i = 0; i < valueList.size(); i++) { + if (valueList.get(i) == null) { + phlv.setNullAt(i); + } else { + phlv.vector[i] = ((List) valueList).get(i); + } + } + return new ParquetDecimalVector(phlv); + default: + HeapBytesVector phbv = getHeapBytesVector(total, valueList); + return new ParquetDecimalVector(phbv); + } + default: + throw new RuntimeException("Unsupported type in the list: " + type); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HeapBytesVector getHeapBytesVector(int total, List valueList) { + HeapBytesVector phbv = new HeapBytesVector(total); + for (int i = 0; i < valueList.size(); i++) { + byte[] src = ((List) valueList).get(i); + if (valueList.get(i) == null) { + phbv.setNullAt(i); + } else { + phbv.appendBytes(i, src, 0, src.length); + } + } + return phbv; + } + + protected void readPage() { + DataPage page = pageReader.readPage(); + + if (page == null) { + return; + } + + page.accept( + new DataPage.Visitor() { + @Override + public Void visit(DataPageV1 dataPageV1) { + readPageV1(dataPageV1); + return null; + } + + @Override + public Void visit(DataPageV2 dataPageV2) { + readPageV2(dataPageV2); + return null; + } + }); + } + + private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int valueCount) + throws IOException { + this.pageValueCount = valueCount; + this.endOfPageValueCount = valuesRead + pageValueCount; + if (dataEncoding.usesDictionary()) { + this.dataColumn = null; + if (dictionary == null) { + throw new IOException( + String.format( + "Could not read page in col %s because the dictionary was missing for encoding %s.", + descriptor, dataEncoding)); + } + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getDictionaryBasedValuesReader( + descriptor, VALUES, dictionary.getDictionary()), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = true; + } else { + dataColumn = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type.asPrimitiveType(), + dataEncoding.getValuesReader(descriptor, VALUES), + isUtcTimestamp); + this.isCurrentPageDictionaryEncoded = false; + } + + try { + dataColumn.initFromPage(pageValueCount, in); + } catch (IOException e) { + throw new IOException(String.format("Could not read page in col %s.", descriptor), e); + } + } + + private void readPageV1(DataPageV1 page) { + ValuesReader rlReader = page.getRlEncoding().getValuesReader(descriptor, REPETITION_LEVEL); + ValuesReader dlReader = page.getDlEncoding().getValuesReader(descriptor, DEFINITION_LEVEL); + this.repetitionLevelColumn = new ValuesReaderIntIterator(rlReader); + this.definitionLevelColumn = new ValuesReaderIntIterator(dlReader); + try { + BytesInput bytes = page.getBytes(); + LOG.debug("Page size {} bytes and {} records.", bytes.size(), pageValueCount); + ByteBufferInputStream in = bytes.toInputStream(); + LOG.debug("Reading repetition levels at {}.", in.position()); + rlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading definition levels at {}.", in.position()); + dlReader.initFromPage(pageValueCount, in); + LOG.debug("Reading data at {}.", in.position()); + initDataReader(page.getValueEncoding(), in, page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private void readPageV2(DataPageV2 page) { + this.pageValueCount = page.getValueCount(); + this.repetitionLevelColumn = + newRLEIterator(descriptor.getMaxRepetitionLevel(), page.getRepetitionLevels()); + this.definitionLevelColumn = + newRLEIterator(descriptor.getMaxDefinitionLevel(), page.getDefinitionLevels()); + try { + LOG.debug( + "Page data size {} bytes and {} records.", page.getData().size(), pageValueCount); + initDataReader( + page.getDataEncoding(), page.getData().toInputStream(), page.getValueCount()); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read page %s in col %s.", page, descriptor), e); + } + } + + private IntIterator newRLEIterator(int maxLevel, BytesInput bytes) { + try { + if (maxLevel == 0) { + return new NullIntIterator(); + } + return new RLEIntIterator( + new RunLengthBitPackingHybridDecoder( + BytesUtils.getWidthFromMaxInt(maxLevel), + new ByteArrayInputStream(bytes.toByteArray()))); + } catch (IOException e) { + throw new ParquetDecodingException( + String.format("Could not read levels in page for col %s.", descriptor), e); + } + } + + /** Utility interface to abstract over different way to read ints with different encodings. */ + interface IntIterator { + int nextInt(); + } + + /** Reading int from {@link ValuesReader}. */ + protected static final class ValuesReaderIntIterator implements IntIterator { + ValuesReader delegate; + + public ValuesReaderIntIterator(ValuesReader delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + return delegate.readInteger(); + } + } + + /** Reading int from {@link RunLengthBitPackingHybridDecoder}. */ + protected static final class RLEIntIterator implements IntIterator { + RunLengthBitPackingHybridDecoder delegate; + + public RLEIntIterator(RunLengthBitPackingHybridDecoder delegate) { + this.delegate = delegate; + } + + @Override + public int nextInt() { + try { + return delegate.readInt(); + } catch (IOException e) { + throw new ParquetDecodingException(e); + } + } + } + + /** Reading zero always. */ + protected static final class NullIntIterator implements IntIterator { + @Override + public int nextInt() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java index 3572b117a6313..1826419db5d44 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetColumnarRowSplitReader.java @@ -18,7 +18,9 @@ package org.apache.hudi.table.format.cow.vector.reader; +import org.apache.hudi.table.format.cow.ParquetSplitReaderUtil; import org.apache.hudi.table.format.cow.vector.ParquetDecimalVector; +import org.apache.hudi.table.format.cow.vector.type.ParquetField; import org.apache.flink.formats.parquet.vector.reader.ColumnReader; import org.apache.flink.table.data.RowData; @@ -28,6 +30,7 @@ import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.util.FlinkRuntimeException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -39,6 +42,8 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.MessageColumnIO; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.Type; @@ -46,6 +51,7 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,7 +59,6 @@ import java.util.Map; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createColumnReader; import static org.apache.hudi.table.format.cow.ParquetSplitReaderUtil.createWritableColumnVector; import static org.apache.parquet.filter2.compat.FilterCompat.get; import static org.apache.parquet.filter2.compat.RowGroupFilter.filterRowGroups; @@ -77,6 +82,14 @@ public class ParquetColumnarRowSplitReader implements Closeable { private final MessageType requestedSchema; + /** + * {@link ParquetField} tree per top-level requested column, used by + * {@link ParquetSplitReaderUtil#createColumnReader(boolean, LogicalType, Type, List, + * PageReadStore, ParquetField)} to drive the Dremel-style {@link NestedColumnReader} for + * nested types. Entries are {@code null} for primitive top-level fields. Built once per split. + */ + private final List requestedFields; + /** * The total number of rows this RecordReader will eventually read. The sum of the rows of all * the row groups. @@ -158,6 +171,20 @@ public ParquetColumnarRowSplitReader( checkSchema(); + // Build the ParquetField tree once per split (the Dremel-style nested reader reuses it across + // row groups). Only columns with nested logical type get a non-null entry — primitive columns + // still use Hudi's specialized ColumnReaders. + MessageColumnIO messageColumnIO = new ColumnIOFactory().getColumnIO(requestedSchema); + List requestedRowFields = new ArrayList<>(requestedTypes.length); + List requestedFieldNames = new ArrayList<>(requestedTypes.length); + for (int i = 0; i < requestedTypes.length; i++) { + String name = requestedSchema.getFieldName(i); + requestedRowFields.add(new RowType.RowField(name, requestedTypes[i])); + requestedFieldNames.add(name); + } + this.requestedFields = ParquetSplitReaderUtil.buildFieldsList( + requestedRowFields, requestedFieldNames, messageColumnIO); + this.writableVectors = createWritableVectors(); ColumnVector[] columnVectors = patchedVector(selectedFieldNames.length, createReadableVectors(), requestedIndices); this.columnarBatch = generator.generate(columnVectors); @@ -340,12 +367,13 @@ private void readNextRowGroup() throws IOException { List columns = requestedSchema.getColumns(); columnReaders = new ColumnReader[types.size()]; for (int i = 0; i < types.size(); ++i) { - columnReaders[i] = createColumnReader( + columnReaders[i] = ParquetSplitReaderUtil.createColumnReader( utcTimestamp, requestedTypes[i], types.get(i), columns, - pages); + pages, + requestedFields.get(i)); } totalCountLoadedSoFar += pages.getRowCount(); } diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java index 861d5cb00bbe7..460bd42f9e299 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/ParquetDataColumnReaderFactory.java @@ -23,12 +23,16 @@ import org.apache.parquet.column.Dictionary; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; import org.apache.parquet.schema.PrimitiveType; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.JULIAN_EPOCH_OFFSET_DAYS; import static org.apache.flink.formats.parquet.vector.reader.TimestampColumnReader.MILLIS_IN_DAY; @@ -64,6 +68,10 @@ public DefaultParquetDataColumnReader(Dictionary dict) { this.dict = dict; } + public boolean isValid() { + return isValid; + } + @Override public void initFromPage(int i, ByteBufferInputStream in) throws IOException { valuesReader.initFromPage(i, in); @@ -170,11 +178,6 @@ public int readInteger(int id) { return dict.decodeToInt(id); } - @Override - public boolean isValid() { - return isValid; - } - @Override public long readLong(int id) { return dict.decodeToLong(id); @@ -255,21 +258,115 @@ public TimestampData readTimestamp() { } } + /** + * Reader for Parquet INT64 timestamp values (MILLIS / MICROS / NANOS), i.e. the standard + * timestamp encoding defined by Parquet's + * {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and the legacy + * {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} annotations. + * (The older INT96 encoding is marked deprecated by the Parquet format spec — see + * + * LogicalTypes.md — but is still supported here via {@link TypesFromInt96PageReader} for + * backwards compatibility with files written by older Hive / Spark / Impala versions.) + * + *

    Used by {@link NestedPrimitiveColumnReader} when a TIMESTAMP column sits inside a + * {@code Row}, {@code Array} or {@code Map}; the top-level path continues to use + * {@link Int64TimestampColumnReader} for batched-vector efficiency. + */ + public static class TypesFromInt64PageReader extends DefaultParquetDataColumnReader { + private final boolean isUtcTimestamp; + private final ChronoUnit chronoUnit; + + public TypesFromInt64PageReader( + ValuesReader realReader, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(realReader); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + public TypesFromInt64PageReader( + Dictionary dict, boolean isUtcTimestamp, ChronoUnit chronoUnit) { + super(dict); + this.isUtcTimestamp = isUtcTimestamp; + this.chronoUnit = chronoUnit; + } + + @Override + public TimestampData readTimestamp() { + return int64ToTimestamp(isUtcTimestamp, valuesReader.readLong(), chronoUnit); + } + + @Override + public TimestampData readTimestamp(int id) { + return int64ToTimestamp(isUtcTimestamp, dict.decodeToLong(id), chronoUnit); + } + } + private static ParquetDataColumnReader getDataColumnReaderByTypeHelper( boolean isDictionary, PrimitiveType parquetType, Dictionary dictionary, ValuesReader valuesReader, boolean isUtcTimestamp) { - if (parquetType.getPrimitiveTypeName() == PrimitiveType.PrimitiveTypeName.INT96) { + PrimitiveType.PrimitiveTypeName typeName = parquetType.getPrimitiveTypeName(); + if (typeName == PrimitiveType.PrimitiveTypeName.INT96) { return isDictionary ? new TypesFromInt96PageReader(dictionary, isUtcTimestamp) : new TypesFromInt96PageReader(valuesReader, isUtcTimestamp); - } else { - return isDictionary - ? new DefaultParquetDataColumnReader(dictionary) - : new DefaultParquetDataColumnReader(valuesReader); } + if (typeName == PrimitiveType.PrimitiveTypeName.INT64) { + ChronoUnit unit = resolveInt64TimestampUnit(parquetType); + if (unit != null) { + return isDictionary + ? new TypesFromInt64PageReader(dictionary, isUtcTimestamp, unit) + : new TypesFromInt64PageReader(valuesReader, isUtcTimestamp, unit); + } + } + return isDictionary + ? new DefaultParquetDataColumnReader(dictionary) + : new DefaultParquetDataColumnReader(valuesReader); + } + + /** + * Returns the {@link ChronoUnit} for a Parquet INT64 TIMESTAMP column, or {@code null} if the + * column is a plain INT64 (not a timestamp). + * + *

    Supports both the modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} and + * the legacy {@link OriginalType#TIMESTAMP_MILLIS} / {@link OriginalType#TIMESTAMP_MICROS} + * encodings. + */ + private static ChronoUnit resolveInt64TimestampUnit(PrimitiveType parquetType) { + LogicalTypeAnnotation annotation = parquetType.getLogicalTypeAnnotation(); + if (annotation instanceof LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) { + LogicalTypeAnnotation.TimeUnit unit = + ((LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation).getUnit(); + switch (unit) { + case MILLIS: + return ChronoUnit.MILLIS; + case MICROS: + return ChronoUnit.MICROS; + case NANOS: + return ChronoUnit.NANOS; + default: + return null; + } + } + OriginalType originalType = parquetType.getOriginalType(); + if (originalType == OriginalType.TIMESTAMP_MILLIS) { + return ChronoUnit.MILLIS; + } + if (originalType == OriginalType.TIMESTAMP_MICROS) { + return ChronoUnit.MICROS; + } + return null; + } + + private static TimestampData int64ToTimestamp( + boolean isUtcTimestamp, long value, ChronoUnit unit) { + Instant instant = Instant.EPOCH.plus(value, unit); + if (isUtcTimestamp) { + return TimestampData.fromInstant(instant); + } + return TimestampData.fromTimestamp(Timestamp.from(instant)); } public static ParquetDataColumnReader getDataColumnReaderByTypeOnDictionary( @@ -284,10 +381,10 @@ public static ParquetDataColumnReader getDataColumnReaderByType( } private static TimestampData int96ToTimestamp( - boolean utcTimestamp, long nanosOfDay, int julianDay) { + boolean isUtcTimestamp, long nanosOfDay, int julianDay) { long millisecond = julianDayToMillis(julianDay) + (nanosOfDay / NANOS_PER_MILLISECOND); - if (utcTimestamp) { + if (isUtcTimestamp) { int nanoOfMillisecond = (int) (nanosOfDay % NANOS_PER_MILLISECOND); return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); } else { diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java deleted file mode 100644 index 79b50487f13c1..0000000000000 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/reader/RowColumnReader.java +++ /dev/null @@ -1,63 +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.hudi.table.format.cow.vector.reader; - -import org.apache.hudi.table.format.cow.vector.HeapRowColumnVector; - -import org.apache.flink.formats.parquet.vector.reader.ColumnReader; -import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; - -import java.io.IOException; -import java.util.List; - -/** - * Row {@link ColumnReader}. - */ -public class RowColumnReader implements ColumnReader { - - private final List fieldReaders; - - public RowColumnReader(List fieldReaders) { - this.fieldReaders = fieldReaders; - } - - @Override - public void readToVector(int readNumber, WritableColumnVector vector) throws IOException { - HeapRowColumnVector rowColumnVector = (HeapRowColumnVector) vector; - WritableColumnVector[] vectors = rowColumnVector.vectors; - // row vector null array - boolean[] isNulls = new boolean[readNumber]; - for (int i = 0; i < vectors.length; i++) { - fieldReaders.get(i).readToVector(readNumber, vectors[i]); - - for (int j = 0; j < readNumber; j++) { - if (i == 0) { - isNulls[j] = vectors[i].isNullAt(j); - } else { - isNulls[j] = isNulls[j] && vectors[i].isNullAt(j); - } - if (i == vectors.length - 1 && isNulls[j]) { - // rowColumnVector[j] is null only when all fields[j] of rowColumnVector[j] is - // null - rowColumnVector.setNullAt(j); - } - } - } - } -} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.java new file mode 100644 index 0000000000000..0f5e00779a2f5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +/** + * Field that represent parquet's field type. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetField}). + */ +public abstract class ParquetField { + private final LogicalType type; + private final int repetitionLevel; + private final int definitionLevel; + private final boolean required; + + public ParquetField( + LogicalType type, int repetitionLevel, int definitionLevel, boolean required) { + this.type = type; + this.repetitionLevel = repetitionLevel; + this.definitionLevel = definitionLevel; + this.required = required; + } + + public LogicalType getType() { + return type; + } + + public int getRepetitionLevel() { + return repetitionLevel; + } + + public int getDefinitionLevel() { + return definitionLevel; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "Field{" + + "type=" + + type + + ", repetitionLevel=" + + repetitionLevel + + ", definitionLevel=" + + definitionLevel + + ", required=" + + required + + '}'; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.java new file mode 100644 index 0000000000000..f91dcca965d64 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's Group Field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetGroupField}) with a Hudi-specific extension: + * entries in the {@code children} list may be {@code null} to denote a Row child that is absent + * from the parquet file but present in the requested logical schema (schema evolution). This + * replaces Hudi's previous {@code EmptyColumnReader} branch for Row subtrees. + */ +public class ParquetGroupField extends ParquetField { + + private final List children; + + public ParquetGroupField( + LogicalType type, + int repetitionLevel, + int definitionLevel, + boolean required, + List children) { + super(type, repetitionLevel, definitionLevel, required); + // Use a plain unmodifiable list (not ImmutableList) so that null entries are allowed for + // schema-evolution missing children in ROW types. + this.children = + Collections.unmodifiableList(new ArrayList<>(requireNonNull(children, "children is null"))); + } + + /** Children of this group. Entries may be {@code null} for absent-in-file Row fields. */ + public List getChildren() { + return children; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.java new file mode 100644 index 0000000000000..f6af6f9ff479e --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/main/java/org/apache/hudi/table/format/cow/vector/type/ParquetPrimitiveField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.parquet.column.ColumnDescriptor; + +import static java.util.Objects.requireNonNull; + +/** + * Field that represent parquet's primitive field. + * + *

    Note: Vendored from Apache Flink (FLINK-35702, {@code + * org.apache.flink.formats.parquet.vector.type.ParquetPrimitiveField}). + */ +public class ParquetPrimitiveField extends ParquetField { + + private final ColumnDescriptor descriptor; + private final int id; + + public ParquetPrimitiveField( + LogicalType type, boolean required, ColumnDescriptor descriptor, int id) { + super( + type, + descriptor.getMaxRepetitionLevel(), + descriptor.getMaxDefinitionLevel(), + required); + this.descriptor = requireNonNull(descriptor, "descriptor is required"); + this.id = id; + } + + public ColumnDescriptor getDescriptor() { + return descriptor; + } + + public int getId() { + return id; + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java index 1a256ffe7bba6..aac9a4651aff3 100644 --- a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/flink/api/java/typeutils/runtime/kryo/KryoSerializerSnapshot.java @@ -34,6 +34,9 @@ public class KryoSerializerSnapshot implements TypeSerializerSnapshot { private Class type; + public KryoSerializerSnapshot() { + } + public KryoSerializerSnapshot(Class type) { this.type = type; } diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.java new file mode 100644 index 0000000000000..2727b40b13934 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/adapter/DataTypeAdapterTestUtils.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.hudi.adapter; + +import org.apache.flink.types.variant.BinaryVariant; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * Adapter utils. + */ +public class DataTypeAdapterTestUtils { + public static void assertAsBinaryVariant(Object variantObject) { + assertInstanceOf(BinaryVariant.class, variantObject, "Variant column should be a BinaryVariant"); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java new file mode 100644 index 0000000000000..aff1c32917cf9 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestHeapColumnVectorAccessors.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.writable.WritableColumnVector; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests for the Flink 2.1-compatible accessors added on {@link HeapArrayVector}, + * {@link HeapMapColumnVector} and {@link HeapRowColumnVector} when vendoring Flink 2.1's + * nested-Parquet reader (FLINK-35702). + * + *

    The accessors are wrappers over the existing public fields so legacy callers continue to + * work. These tests exist solely to pin down that wrapper contract — runtime correctness of the + * Dremel-style read path is exercised end-to-end by integration tests in + * {@code ITTestHoodieDataSource} (testParquetComplexTypes / testParquetComplexNestedRowTypes / + * testParquetArrayMapOfRowTypes / testParquetNullChildColumnsRowTypes). + */ +class TestHeapColumnVectorAccessors { + + // ----------------------------------------------------------------------------------------------- + // HeapArrayVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapArrayVectorAccessorsReflectPublicFields() { + HeapIntVector child = new HeapIntVector(4); + HeapArrayVector vector = new HeapArrayVector(2, child); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector replacementChild = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setChild(replacementChild); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(replacementChild, vector.getChild()); + assertEquals(2, vector.getSize()); + + // Backing public fields are kept in sync — preserves backward compatibility. + assertSame(offsets, vector.offsets); + assertSame(lengths, vector.lengths); + assertSame(replacementChild, vector.child); + } + + // ----------------------------------------------------------------------------------------------- + // HeapMapColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapMapColumnVectorConstructorInitializesOffsetsAndLengths() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + + HeapMapColumnVector vector = new HeapMapColumnVector(3, keys, values); + + assertEquals(3, vector.getOffsets().length); + assertEquals(3, vector.getLengths().length); + } + + @Test + void heapMapColumnVectorAccessorsReflectInternalState() { + HeapIntVector keys = new HeapIntVector(4); + HeapIntVector values = new HeapIntVector(4); + HeapMapColumnVector vector = new HeapMapColumnVector(2, keys, values); + + long[] offsets = {0L, 2L}; + long[] lengths = {2L, 2L}; + HeapLongVector newKeys = new HeapLongVector(4); + HeapLongVector newValues = new HeapLongVector(4); + + vector.setOffsets(offsets); + vector.setLengths(lengths); + vector.setKeys(newKeys); + vector.setValues(newValues); + vector.setSize(2); + + assertArrayEquals(offsets, vector.getOffsets()); + assertArrayEquals(lengths, vector.getLengths()); + assertSame(newKeys, vector.getKeys()); + assertSame(newValues, vector.getValues()); + // The Flink-2.1-style ColumnVector accessors return the same underlying child. + assertSame(newKeys, vector.getKeyColumnVector()); + assertSame(newValues, vector.getValueColumnVector()); + assertEquals(2, vector.getSize()); + } + + // ----------------------------------------------------------------------------------------------- + // HeapRowColumnVector + // ----------------------------------------------------------------------------------------------- + + @Test + void heapRowColumnVectorFieldsAccessorsReflectPublicVectors() { + HeapIntVector intField = new HeapIntVector(2); + HeapLongVector longField = new HeapLongVector(2); + HeapRowColumnVector vector = new HeapRowColumnVector(2, intField, longField); + + WritableColumnVector[] originalFields = vector.getFields(); + assertEquals(2, originalFields.length); + assertSame(intField, originalFields[0]); + assertSame(longField, originalFields[1]); + // Backing public field is kept in sync — preserves backward compatibility. + assertSame(originalFields, vector.vectors); + + HeapIntVector replacement = new HeapIntVector(2); + WritableColumnVector[] replacementFields = {replacement, longField}; + vector.setFields(replacementFields); + + assertSame(replacementFields, vector.getFields()); + assertSame(replacementFields, vector.vectors); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java new file mode 100644 index 0000000000000..04f5809b9dac4 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/TestParquetDecimalVector.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.table.format.cow.vector; + +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.columnar.vector.BytesColumnVector; +import org.apache.flink.table.data.columnar.vector.ColumnVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapBytesVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapIntVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapLongVector; +import org.apache.flink.table.data.columnar.vector.heap.HeapShortVector; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ParquetDecimalVector}. + */ +public class TestParquetDecimalVector { + + @Test + void testGetDecimalFromInt32Vector() { + // precision <= 9 => ParquetSchemaConverter.is32BitDecimal(precision) == true + HeapIntVector intVector = new HeapIntVector(1); + intVector.vector[0] = 12345; + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(new BigDecimal("123.45"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromInt64Vector() { + // 9 < precision <= 18 => ParquetSchemaConverter.is64BitDecimal(precision) == true + HeapLongVector longVector = new HeapLongVector(1); + longVector.vector[0] = 1234567890123456L; + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + DecimalData decoded = wrapped.getDecimal(0, 18, 4); + + assertEquals(new BigDecimal("123456789012.3456"), decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtLargePrecision() { + // precision > 18 => BINARY / FIXED_LEN_BYTE_ARRAY path + BigDecimal original = new BigDecimal("12345678901234567890.1234567890"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 30, 10); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalFromBytesVectorAtSmallPrecision() { + // A Parquet file can legally encode a small-precision decimal as BINARY. In that case the + // dispatch must fall through to the bytes branch rather than require an IntColumnVector. + BigDecimal original = new BigDecimal("123.45"); + byte[] unscaled = original.unscaledValue().toByteArray(); + HeapBytesVector bytesVector = new HeapBytesVector(1); + bytesVector.appendBytes(0, unscaled, 0, unscaled.length); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + + DecimalData decoded = wrapped.getDecimal(0, 5, 2); + + assertEquals(original, decoded.toBigDecimal()); + } + + @Test + void testGetDecimalThrowsOnUnsupportedVectorType() { + // A large-precision request must have a bytes-backed child; any other writable child is an + // illegal combination and must be surfaced via Preconditions.checkArgument. + ColumnVector unsupported = new HeapShortVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(unsupported); + + assertThrows(IllegalArgumentException.class, () -> wrapped.getDecimal(0, 30, 10)); + } + + @Test + void testIsNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + intVector.vector[0] = 1; + intVector.setNullAt(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + assertFalse(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testWritableIntRoundTrip() { + HeapIntVector intVector = new HeapIntVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setInt(0, 42); + + assertEquals(42, wrapped.getInt(0)); + assertEquals(42, intVector.vector[0]); + } + + @Test + void testWritableLongRoundTrip() { + HeapLongVector longVector = new HeapLongVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(longVector); + + wrapped.setLong(0, 9876543210L); + + assertEquals(9876543210L, wrapped.getLong(0)); + assertEquals(9876543210L, longVector.vector[0]); + } + + @Test + void testWritableBytesRoundTrip() { + HeapBytesVector bytesVector = new HeapBytesVector(1); + ParquetDecimalVector wrapped = new ParquetDecimalVector(bytesVector); + byte[] payload = new byte[] {0x01, 0x02, 0x03}; + + wrapped.appendBytes(0, payload, 0, payload.length); + + BytesColumnVector.Bytes out = wrapped.getBytes(0); + assertEquals(payload.length, out.len); + assertEquals(0x01, out.data[out.offset]); + assertEquals(0x02, out.data[out.offset + 1]); + assertEquals(0x03, out.data[out.offset + 2]); + } + + @Test + void testResetDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(1); + intVector.setNullAt(0); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + assertTrue(wrapped.isNullAt(0)); + + wrapped.reset(); + + assertFalse(wrapped.isNullAt(0)); + } + + @Test + void testFillWithNullsDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.fillWithNulls(); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } + + @Test + void testSetNullAtDelegatesToChild() { + HeapIntVector intVector = new HeapIntVector(2); + ParquetDecimalVector wrapped = new ParquetDecimalVector(intVector); + + wrapped.setNullAt(0); + wrapped.setNulls(1, 1); + + assertTrue(wrapped.isNullAt(0)); + assertTrue(wrapped.isNullAt(1)); + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.java new file mode 100644 index 0000000000000..ea222dad576a5 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/reader/TestParquetDataColumnReaderFactory.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.hudi.table.format.cow.vector.reader; + +import org.apache.flink.table.data.TimestampData; +import org.apache.parquet.column.Dictionary; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.OriginalType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for the {@link ParquetDataColumnReaderFactory} INT64 timestamp dispatch added when + * vendoring Flink 2.1's nested-Parquet reader (FLINK-35702). + * + *

    The factory is exercised end-to-end by integration tests through + * {@link NestedPrimitiveColumnReader}; this unit test focuses on the small, deterministic piece + * that was added by this PR — selecting the right {@code ParquetDataColumnReader} for each + * supported INT64 TIMESTAMP encoding (modern {@link LogicalTypeAnnotation.TimestampLogicalTypeAnnotation} + * MILLIS / MICROS / NANOS plus the legacy {@link OriginalType} encodings) and decoding values + * using both the values-reader and dictionary code paths. + */ +class TestParquetDataColumnReaderFactory { + + // ----------------------------------------------------------------------------------------------- + // Type dispatch + // ----------------------------------------------------------------------------------------------- + + @Test + void valuesReaderDispatchInt96TimestampUsesInt96Reader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT96).named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt96PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64WithoutAnnotationUsesDefaultReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT64).named("plainLong"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampMicrosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64TimestampNanosLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMillisOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MILLIS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt64LegacyTimestampMicrosOriginalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(OriginalType.TIMESTAMP_MICROS) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + @Test + void valuesReaderDispatchInt32DoesNotUseTimestampReader() { + PrimitiveType type = Types.required(PrimitiveType.PrimitiveTypeName.INT32).named("i"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType(type, new StubValuesReader(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.DefaultParquetDataColumnReader.class, reader); + } + + @Test + void dictionaryReaderDispatchInt64TimestampMillisLogicalUsesInt64Reader() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(), true); + assertInstanceOf(ParquetDataColumnReaderFactory.TypesFromInt64PageReader.class, reader); + } + + // ----------------------------------------------------------------------------------------------- + // INT64 → TimestampData decoding (per ChronoUnit, both UTC and local-time-zone branches) + // ----------------------------------------------------------------------------------------------- + + @Test + void int64ReaderReadsTimestampMillisFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_123L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMillis), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + assertEquals(0, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMicrosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named("ts"); + long epochMicros = 1_700_000_000_123_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochMicros), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochMicros / 1_000L, ts.getMillisecond()); + // 456 microseconds remain → 456_000 nanoseconds within the millisecond + assertEquals(456_000, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampNanosFromValuesReaderInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named("ts"); + long epochNanos = 1_700_000_000_123_456_789L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByType( + type, new StubValuesReader(epochNanos), true); + + TimestampData ts = reader.readTimestamp(); + assertNotNull(ts); + assertEquals(epochNanos / 1_000_000L, ts.getMillisecond()); + assertEquals(456_789, ts.getNanoOfMillisecond()); + } + + @Test + void int64ReaderReadsTimestampMillisFromDictionaryInUtc() { + PrimitiveType type = + Types.required(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MILLIS)) + .named("ts"); + long epochMillis = 1_700_000_000_456L; + ParquetDataColumnReader reader = + ParquetDataColumnReaderFactory.getDataColumnReaderByTypeOnDictionary( + type, new StubDictionary(epochMillis), true); + + TimestampData ts = reader.readTimestamp(0); + assertNotNull(ts); + assertEquals(epochMillis, ts.getMillisecond()); + } + + // ----------------------------------------------------------------------------------------------- + // Stubs (only the methods exercised by the dispatch + decoding tests above) + // ----------------------------------------------------------------------------------------------- + + /** Minimal {@link ValuesReader} returning a fixed long; other methods throw. */ + private static final class StubValuesReader extends ValuesReader { + private final long fixedLong; + + StubValuesReader() { + this(0L); + } + + StubValuesReader(long fixedLong) { + this.fixedLong = fixedLong; + } + + @Override + public long readLong() { + return fixedLong; + } + + @Override + public void skip() { + // unused + } + } + + /** Minimal {@link Dictionary} returning a fixed long for any id; other methods throw. */ + private static final class StubDictionary extends Dictionary { + private final long fixedLong; + + StubDictionary() { + this(0L); + } + + StubDictionary(long fixedLong) { + super(null); + this.fixedLong = fixedLong; + } + + @Override + public Binary decodeToBinary(int id) { + throw new UnsupportedOperationException(); + } + + @Override + public long decodeToLong(int id) { + return fixedLong; + } + + @Override + public int getMaxId() { + return 0; + } + } +} diff --git a/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.java new file mode 100644 index 0000000000000..2b71bae4dc152 --- /dev/null +++ b/hudi-flink-datasource/hudi-flink2.1.x/src/test/java/org/apache/hudi/table/format/cow/vector/type/TestParquetGroupField.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.hudi.table.format.cow.vector.type; + +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.VarCharType; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link ParquetGroupField}. + */ +public class TestParquetGroupField { + + @Test + void testChildrenWithAllNonNullEntriesAreRetained() { + ParquetField c0 = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetField c1 = new ParquetPrimitiveField(new VarCharType(), true, descriptor(), 1); + List children = Arrays.asList(c0, c1); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertSame(c0, group.getChildren().get(0)); + assertSame(c1, group.getChildren().get(1)); + } + + @Test + void testChildrenMayContainNullForSchemaEvolution() { + // A ROW field present in the requested Flink schema but absent from the Parquet file is + // represented by a null slot in `children`. The group must allow this (Hudi-specific + // extension over Flink's ImmutableList-backed equivalent). + ParquetField present = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List children = Arrays.asList(present, null); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, children); + + assertEquals(2, group.getChildren().size()); + assertNotNull(group.getChildren().get(0)); + assertNull(group.getChildren().get(1)); + } + + @Test + void testChildrenListIsUnmodifiable() { + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.singletonList(child)); + + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().add(null)); + assertThrows(UnsupportedOperationException.class, () -> group.getChildren().remove(0)); + } + + @Test + void testChildrenListIsDefensivelyCopied() { + // Mutations to the caller-supplied list must not be visible through the group. + ParquetField child = new ParquetPrimitiveField(new IntType(), true, descriptor(), 0); + List mutable = new ArrayList<>(); + mutable.add(child); + + ParquetGroupField group = new ParquetGroupField(rowType(), 0, 1, true, mutable); + mutable.add(null); + + assertEquals(1, group.getChildren().size()); + } + + @Test + void testNullChildrenListThrows() { + assertThrows( + NullPointerException.class, + () -> new ParquetGroupField(rowType(), 0, 1, true, null)); + } + + @Test + void testEmptyChildrenListIsAllowed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 0, 1, true, Collections.emptyList()); + + assertEquals(0, group.getChildren().size()); + } + + @Test + void testFieldMetadataIsExposed() { + ParquetGroupField group = + new ParquetGroupField(rowType(), 2, 5, false, Collections.emptyList()); + + assertEquals(2, group.getRepetitionLevel()); + assertEquals(5, group.getDefinitionLevel()); + assertFalse(group.isRequired()); + } + + private static LogicalType rowType() { + return RowType.of(new IntType()); + } + + private static ColumnDescriptor descriptor() { + PrimitiveType primitive = Types.required(PrimitiveTypeName.INT32).named("f"); + return new ColumnDescriptor(new String[] {"f"}, primitive, 0, 0); + } +} diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/common/config/DFSPropertiesConfiguration.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/common/config/DFSPropertiesConfiguration.java index 999cfdfed2afb..e048d269625f6 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/common/config/DFSPropertiesConfiguration.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/common/config/DFSPropertiesConfiguration.java @@ -118,15 +118,18 @@ public static TypedProperties loadGlobalProps() { String.format("Failed to read %s from class loader", DEFAULT_PROPERTIES_FILE), ioe); } } - // Try loading the external config file from local file system + // Try loading the external config file from local file system. Both DEFAULT_PATH and + // HUDI_CONF_DIR are optional global config locations — use the tolerant overload so a + // missing file does not propagate as an exception (preserves prior silent-ignore behavior + // for optional global-defaults paths). try { - conf.addPropsFromFile(DEFAULT_PATH); + conf.addPropsFromFile(DEFAULT_PATH, true); } catch (Exception e) { log.warn("Cannot load default config file: {}", DEFAULT_PATH, e); } Option defaultConfPath = getConfPathFromEnv(); if (defaultConfPath.isPresent() && !defaultConfPath.get().equals(DEFAULT_PATH)) { - conf.addPropsFromFile(defaultConfPath.get()); + conf.addPropsFromFile(defaultConfPath.get(), true); } return conf.getProps(); } @@ -141,11 +144,31 @@ public static void clearGlobalProps() { } /** - * Add properties from external configuration files. + * Add properties from an external configuration file. A missing file is tolerated only when the + * caller's path equals {@link #DEFAULT_PATH} (the optional global {@code hudi-defaults.conf}); any + * other missing file fails fast with {@link HoodieIOException}, so a typo in an explicit + * user-supplied path (e.g. {@code --props /path/...}) is surfaced rather than silently loading + * empty properties. + * + *

    For include-resolved paths use {@link #addPropsFromFile(StoragePath, boolean)} with + * {@code tolerateMissing=true}. * * @param filePath file path for configuration file. */ public void addPropsFromFile(StoragePath filePath) { + addPropsFromFile(filePath, filePath.equals(DEFAULT_PATH)); + } + + /** + * Add properties from an external configuration file. + * + * @param filePath file path for configuration file. + * @param tolerateMissing when {@code true}, a missing file is logged at {@code debug} and + * ignored (used for optional global-defaults paths and {@code include=} + * recursion). When {@code false}, missing files raise + * {@link HoodieIOException} so explicit user-supplied paths fail fast. + */ + void addPropsFromFile(StoragePath filePath, boolean tolerateMissing) { if (visitedFilePaths.contains(filePath.toString())) { throw new IllegalStateException("Loop detected; file " + filePath + " already referenced"); } @@ -156,9 +179,12 @@ public void addPropsFromFile(StoragePath filePath) { ); try { - if (filePath.equals(DEFAULT_PATH) && !storage.exists(filePath)) { - log.debug("Properties file {} not found. Ignoring to load props file", filePath); - return; + if (!storage.exists(filePath)) { + if (tolerateMissing) { + log.debug("Properties file {} not found. Ignoring to load props file", filePath); + return; + } + throw new HoodieIOException("Properties file does not exist: " + filePath); } } catch (IOException ioe) { throw new HoodieIOException("Cannot check if the properties file exist: " + filePath, ioe); @@ -168,7 +194,7 @@ public void addPropsFromFile(StoragePath filePath) { visitedFilePaths.add(filePath.toString()); addPropsFromStream(reader, filePath); } catch (IOException ioe) { - log.error("Error reading in properties from dfs from file " + filePath); + log.error("Error reading in properties from dfs from file {}", filePath); throw new HoodieIOException("Cannot read properties from dfs from file " + filePath, ioe); } } @@ -195,7 +221,9 @@ public void addPropsFromStream(BufferedReader reader, StoragePath cfgFilePath) t && cfgFilePath != null) { providedPath = new StoragePath(cfgFilePath.getParent(), split[1]); } - addPropsFromFile(providedPath); + // include= references may legitimately point to optional files (e.g. environment- + // specific overrides); skip silently when missing rather than failing the whole load. + addPropsFromFile(providedPath, true); } else { hoodieConfig.setValue(split[0], split[1]); } @@ -251,7 +279,7 @@ public TypedProperties getProps(boolean includeGlobalProps) { private static Option getConfPathFromEnv() { String confDir = System.getenv(CONF_FILE_DIR_ENV_NAME); if (confDir == null) { - log.debug("Environment variable " + CONF_FILE_DIR_ENV_NAME + ", not set. If desired, set it to the folder containing: " + DEFAULT_PROPERTIES_FILE); + log.debug("Environment variable {}, not set. If desired, set it to the folder containing: {}", CONF_FILE_DIR_ENV_NAME, DEFAULT_PROPERTIES_FILE); return Option.empty(); } if (StringUtils.isNullOrEmpty(URI.create(confDir).getScheme())) { diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatWriter.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatWriter.java index 23a69699a43ec..fe24bd600f1ee 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatWriter.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/common/table/log/HoodieLogFormatWriter.java @@ -19,13 +19,15 @@ package org.apache.hudi.common.table.log; -import org.apache.hudi.common.model.HoodieLogFile; -import org.apache.hudi.common.table.log.HoodieLogFormat.WriterBuilder; +import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.util.VisibleForTesting; +import org.apache.hudi.exception.ExceptionUtil; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StoragePath; +import lombok.Builder; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FSDataOutputStream; @@ -42,38 +44,36 @@ /** * HoodieLogFormatWriter can be used to append blocks to a log file Use HoodieLogFormat.WriterBuilder to construct. */ +@Getter @Slf4j -public class HoodieLogFormatWriter implements HoodieLogFormat.Writer { +public class HoodieLogFormatWriter extends HoodieLogFormat.Writer { - @Getter - private HoodieLogFile logFile; - private FSDataOutputStream output; - - private final HoodieStorage storage; - @Getter - private final long sizeThreshold; - private final Integer bufferSize; private final Short replication; - private final String rolloverLogWriteToken; - private final LogFileCreationCallback fileCreationHook; + private FSDataOutputStream outputStream; private boolean closed = false; private transient Thread shutdownThread = null; - public HoodieLogFormatWriter( - HoodieStorage storage, - HoodieLogFile logFile, + @Builder(setterPrefix = "with") + private HoodieLogFormatWriter( Integer bufferSize, - Short replication, + HoodieStorage storage, + StoragePath parentPath, + String logFileId, + String fileExtension, + String instantTime, + Integer logVersion, + String logWriteToken, + String suffix, + Long fileSize, Long sizeThreshold, - String rolloverLogWriteToken, - LogFileCreationCallback fileCreationHook) { - this.storage = storage; - this.logFile = logFile; - this.sizeThreshold = sizeThreshold; - this.bufferSize = bufferSize != null ? bufferSize : storage.getDefaultBufferSize(); + LogFileCreationCallback fileCreationCallback, + HoodieTableVersion tableVersion, + Short replication + ) throws IOException { + super(bufferSize, storage, parentPath, logFileId, fileExtension, instantTime, logVersion, logWriteToken, + suffix, fileSize, sizeThreshold, fileCreationCallback, tableVersion); + // outputStream is not initialized here, it will be lazily initialized in getOutputStream() this.replication = replication != null ? replication : storage.getDefaultReplication(logFile.getPath().getParent()); - this.rolloverLogWriteToken = rolloverLogWriteToken; - this.fileCreationHook = fileCreationHook; addShutDownHook(); } @@ -81,8 +81,8 @@ public HoodieLogFormatWriter( * Overrides the output stream, only for test purpose. */ @VisibleForTesting - public void withOutputStream(FSDataOutputStream output) { - this.output = output; + public void withOutputStream(FSDataOutputStream outputStream) { + this.outputStream = outputStream; } /** @@ -91,7 +91,7 @@ public void withOutputStream(FSDataOutputStream output) { * @throws IOException */ private FSDataOutputStream getOutputStream() throws IOException { - if (this.output == null) { + if (outputStream == null) { boolean created = false; while (!created) { try { @@ -116,7 +116,7 @@ private FSDataOutputStream getOutputStream() throws IOException { } } } - return output; + return outputStream; } @Override @@ -126,66 +126,73 @@ public AppendResult appendBlock(HoodieLogBlock block) throws IOException, Interr @Override public AppendResult appendBlocks(List blocks) throws IOException { - // Find current version - HoodieLogFormat.LogFormatVersion currentLogFormatVersion = - new HoodieLogFormatVersion(HoodieLogFormat.CURRENT_VERSION); - - FSDataOutputStream originalOutputStream = getOutputStream(); - long startPos = originalOutputStream.getPos(); - long sizeWritten = 0; - // HUDI-2655. here we wrap originalOutputStream to ensure huge blocks can be correctly written - FSDataOutputStream outputStream = new FSDataOutputStream(originalOutputStream, new FileSystem.Statistics(storage.getScheme()), startPos); - for (HoodieLogBlock block: blocks) { - long startSize = outputStream.size(); - - // 1. Write the magic header for the start of the block - outputStream.write(HoodieLogFormat.MAGIC); - - // bytes for header - byte[] headerBytes = HoodieLogBlock.getHeaderMetadataBytes(block.getLogBlockHeader()); - // content bytes - ByteArrayOutputStream content = block.getContentBytes(storage); - // bytes for footer - byte[] footerBytes = HoodieLogBlock.getFooterMetadataBytes(block.getLogBlockFooter()); - - // 2. Write the total size of the block (excluding Magic) - outputStream.writeLong(getLogBlockLength(content.size(), headerBytes.length, footerBytes.length)); - - // 3. Write the version of this log block - outputStream.writeInt(currentLogFormatVersion.getVersion()); - // 4. Write the block type - outputStream.writeInt(block.getBlockType().ordinal()); - - // 5. Write the headers for the log block - outputStream.write(headerBytes); - // 6. Write the size of the content block - outputStream.writeLong(content.size()); - // 7. Write the contents of the data block - content.writeTo(outputStream); - // 8. Write the footers for the log block - outputStream.write(footerBytes); - // 9. Write the total size of the log block (including magic) which is everything written - // until now (for reverse pointer) - // Update: this information is now used in determining if a block is corrupt by comparing to the - // block size in header. This change assumes that the block size will be the last data written - // to a block. Read will break if any data is written past this point for a block. - outputStream.writeLong(outputStream.size() - startSize); - - // Fetch the size again, so it accounts also (9). - - // HUDI-2655. Check the size written to avoid log blocks whose size overflow. - if (outputStream.size() == Integer.MAX_VALUE) { - throw new HoodieIOException("Blocks appended may overflow. Please decrease log block size or log block amount"); + try { + // Find current version + HoodieLogFormat.LogFormatVersion currentLogFormatVersion = + new HoodieLogFormatVersion(HoodieLogFormat.CURRENT_VERSION); + + FSDataOutputStream originalOutputStream = getOutputStream(); + long startPos = originalOutputStream.getPos(); + long sizeWritten = 0; + // HUDI-2655. here we wrap originalOutputStream to ensure huge blocks can be correctly written + FSDataOutputStream outputStream = new FSDataOutputStream(originalOutputStream, new FileSystem.Statistics(storage.getScheme()), startPos); + for (HoodieLogBlock block: blocks) { + long startSize = outputStream.size(); + + // 1. Write the magic header for the start of the block + outputStream.write(HoodieLogFormat.MAGIC); + + // bytes for header + byte[] headerBytes = HoodieLogBlock.getHeaderMetadataBytes(block.getLogBlockHeader()); + // content bytes + ByteArrayOutputStream content = block.getContentBytes(storage); + // bytes for footer + byte[] footerBytes = HoodieLogBlock.getFooterMetadataBytes(block.getLogBlockFooter()); + + // 2. Write the total size of the block (excluding Magic) + outputStream.writeLong(getLogBlockLength(content.size(), headerBytes.length, footerBytes.length)); + + // 3. Write the version of this log block + outputStream.writeInt(currentLogFormatVersion.getVersion()); + // 4. Write the block type + outputStream.writeInt(block.getBlockType().ordinal()); + + // 5. Write the headers for the log block + outputStream.write(headerBytes); + // 6. Write the size of the content block + outputStream.writeLong(content.size()); + // 7. Write the contents of the data block + content.writeTo(outputStream); + // 8. Write the footers for the log block + outputStream.write(footerBytes); + // 9. Write the total size of the log block (including magic) which is everything written + // until now (for reverse pointer) + // Update: this information is now used in determining if a block is corrupt by comparing to the + // block size in header. This change assumes that the block size will be the last data written + // to a block. Read will break if any data is written past this point for a block. + outputStream.writeLong(outputStream.size() - startSize); + + // Fetch the size again, so it accounts also (9). + + // HUDI-2655. Check the size written to avoid log blocks whose size overflow. + if (outputStream.size() == Integer.MAX_VALUE) { + throw new HoodieIOException("Blocks appended may overflow. Please decrease log block size or log block amount"); + } + sizeWritten += outputStream.size() - startSize; } - sizeWritten += outputStream.size() - startSize; - } - // Flush all blocks to disk - flush(); + // No flush/hsync here: append-time visibility is not part of the contract. + // Downstream readers only need commit-level visibility, which is provided + // when the writer is closed (see closeStream) or when callers explicitly + // invoke sync(). - AppendResult result = new AppendResult(logFile, startPos, sizeWritten); - // roll over if size is past the threshold - rolloverIfNeeded(); - return result; + AppendResult result = new AppendResult(logFile, startPos, sizeWritten); + // roll over if size is past the threshold + rolloverIfNeeded(); + return result; + } catch (IOException | RuntimeException e) { + closeOutputStreamOnAppendFailure(e); + throw e; + } } /** @@ -205,52 +212,106 @@ private int getLogBlockLength(int contentLength, int headerLength, int footerLen private void rolloverIfNeeded() throws IOException { // Roll over if the size is past the threshold - if (getCurrentSize() > sizeThreshold) { - log.info("CurrentSize {} has reached threshold {}. Rolling over to the next version", getCurrentSize(), sizeThreshold); + if (getCurrentSize() > getSizeThreshold()) { + log.info("CurrentSize {} has reached threshold {}. Rolling over to the next version", getCurrentSize(), getSizeThreshold()); rollOver(); } } private void rollOver() throws IOException { closeStream(); - this.logFile = logFile.rollOver(rolloverLogWriteToken); + this.logFile = getLogFile().rollOver(getLogWriteToken()); this.closed = false; } private void createNewFile() throws IOException { - fileCreationHook.preFileCreation(this.logFile); - this.output = new FSDataOutputStream( - storage.create(this.logFile.getPath(), false, bufferSize, replication, WriterBuilder.DEFAULT_SIZE_THRESHOLD), - new FileSystem.Statistics(storage.getScheme()) + getFileCreationCallback().preFileCreation(this.getLogFile()); + this.outputStream = new FSDataOutputStream( + getStorage().create( + this.getLogFile().getPath(), + false, + getBufferSize(), + getReplication(), + // HDFS block size is intentionally a fixed constant, independent of the + // log rollover threshold (getSizeThreshold()). A small rollover threshold + // must not shrink the underlying file's block size below HDFS limits. + DEFAULT_SIZE_THRESHOLD + ), + new FileSystem.Statistics(getStorage().getScheme()) ); } @Override public void close() throws IOException { - closeStream(); - // remove the shutdown hook after closing the stream to avoid memory leaks - if (null != shutdownThread) { - Runtime.getRuntime().removeShutdownHook(shutdownThread); + try { + closeStream(); + } finally { + // remove the shutdown hook after closing the stream to avoid memory leaks + if (null != shutdownThread) { + Runtime.getRuntime().removeShutdownHook(shutdownThread); + shutdownThread = null; + } } } private void closeStream() throws IOException { - if (output != null) { - flush(); - output.close(); - output = null; - closed = true; + if (outputStream == null) { + return; + } + + Throwable failure = null; + try { + // Persist all buffered data to DataNodes before closing so downstream + // readers can observe a fully-written log file at commit-level visibility. + sync(); + } catch (IOException | RuntimeException e) { + failure = e; + } + + try { + closeOutputStream(); + } catch (IOException | RuntimeException closeException) { + if (failure != null) { + failure.addSuppressed(closeException); + } else { + failure = closeException; + } + } + + if (failure != null) { + ExceptionUtil.throwAsIOExceptionOrRuntimeException(failure); + } + } + + private void closeOutputStreamOnAppendFailure(Throwable failure) { + try { + closeOutputStream(); + } catch (IOException | RuntimeException closeException) { + failure.addSuppressed(closeException); + log.warn("Failed to close output stream after append failure for log file {}", logFile, closeException); + } + } + + private void closeOutputStream() throws IOException { + if (outputStream != null) { + try { + outputStream.close(); + } finally { + outputStream = null; + closed = true; + } } } - private void flush() throws IOException { - if (output == null) { + @Override + public void sync() throws IOException { + if (outputStream == null) { return; // Presume closed } - output.flush(); - // NOTE : the following API call makes sure that the data is flushed to disk on DataNodes (akin to POSIX fsync()) - // See more details here : https://issues.apache.org/jira/browse/HDFS-744 - output.hsync(); + outputStream.flush(); + // NOTE: the following API call makes sure that the data is flushed to disk on DataNodes (akin to POSIX fsync()) + // See more details here: https://issues.apache.org/jira/browse/HDFS-744 + outputStream.hsync(); } @Override @@ -259,27 +320,25 @@ public long getCurrentSize() throws IOException { throw new IllegalStateException("Cannot get current size as the underlying stream has been closed already"); } - if (output == null) { + if (outputStream == null) { return 0; } - return output.getPos(); + return outputStream.getPos(); } /** * Close the output stream when the JVM exits. */ private void addShutDownHook() { - shutdownThread = new Thread() { - public void run() { - try { - log.info("running HoodieLogFormatWriter shutdown hook to close output stream for log file: {}", logFile); - closeStream(); - } catch (Exception e) { - log.warn("unable to close output stream for log file: {}", logFile, e); - // fail silently for any sort of exception - } + shutdownThread = new Thread(() -> { + try { + log.info("Running HoodieLogFormatWriter shutdown hook to close output stream for log file: {}", logFile); + closeStream(); + } catch (Exception e) { + log.warn("Unable to close output stream for log file: {}", logFile, e); + // fail silently for any sort of exception } - }; + }); Runtime.getRuntime().addShutdownHook(shutdownThread); } } diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HadoopFSUtils.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HadoopFSUtils.java index 2700405073078..3eb5f15148ec6 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HadoopFSUtils.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HadoopFSUtils.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.util.collection.ImmutablePair; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.InvalidHoodiePathException; import org.apache.hudi.storage.StorageConfiguration; @@ -284,7 +285,7 @@ private static FSDataInputStream getFSDataInputStreamForGCS(FSDataInputStream fs * @return true if the inputstream or the wrapped one is of type GoogleHadoopFSInputStream */ public static boolean isGCSFileSystem(FileSystem fs) { - return fs.getScheme().equals(StorageSchemes.GCS.getScheme()); + return StorageSchemes.GCS.getScheme().equals(getScheme(fs)); } /** @@ -292,7 +293,42 @@ public static boolean isGCSFileSystem(FileSystem fs) { * Wrapped by {@code BoundedFsDataInputStream}, to check whether the desired offset is out of the file size in advance. */ public static boolean isCHDFileSystem(FileSystem fs) { - return StorageSchemes.CHDFS.getScheme().equals(fs.getScheme()); + return StorageSchemes.CHDFS.getScheme().equals(getScheme(fs)); + } + + /** + * Resolves the scheme of {@code fs} without depending on {@link FileSystem#getScheme()}. + * + *

    {@code getScheme()} is optional in Hadoop: {@link FileSystem}'s own implementation throws + * {@link UnsupportedOperationException}, and proxy implementations such as Presto's + * {@code PrestoS3FileSystem} do not override it, so calling it unguarded turns an unrelated read into + * "Not implemented by the PrestoS3FileSystem FileSystem implementation" (HUDI-4602). + * {@link FileSystem#getUri()} is abstract, so every implementation supplies one to fall back on. + * + *

    The two are not interchangeable, which is why {@code getScheme()} is tried first: + * {@code InLineFileSystem} returns {@code "inlinefs"} from {@code getScheme()} while its + * {@code getUri()} is {@code URI.create("inlinefs")}, which has no colon and so carries no scheme at all. + * A URI with no scheme is therefore a resolution failure rather than a value to pass on - returning null + * would surface much later as {@code does not support scheme null} or {@code Unsupported scheme :null}, + * with the original {@code UnsupportedOperationException} discarded. + * + * @param fs instance of {@link FileSystem} in use. + * @return the scheme of {@code fs}, never null. + * @throws HoodieException if {@code getScheme()} is unimplemented and the URI carries no scheme. + */ + public static String getScheme(FileSystem fs) { + try { + return fs.getScheme(); + } catch (UnsupportedOperationException e) { + String scheme = fs.getUri().getScheme(); + if (scheme == null) { + // HoodieException rather than HoodieIOException: the latter only accepts an IOException cause, and + // discarding the UnsupportedOperationException is the thing being fixed here. + throw new HoodieException("Cannot resolve the scheme of " + fs.getClass().getName() + + ": getScheme() is unimplemented and its URI " + fs.getUri() + " carries no scheme", e); + } + return scheme; + } } private static StorageConfiguration getStorageConf(Configuration conf, boolean copy) { @@ -301,7 +337,7 @@ private static StorageConfiguration getStorageConf(Configuration public static Configuration registerFileSystem(StoragePath file, Configuration conf) { Configuration returnConf = new Configuration(conf); - String scheme = HadoopFSUtils.getFs(file.toString(), conf).getScheme(); + String scheme = getScheme(HadoopFSUtils.getFs(file.toString(), conf)); returnConf.set("fs." + HoodieWrapperFileSystem.getHoodieScheme(scheme) + ".impl", HoodieWrapperFileSystem.class.getName()); return returnConf; diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieRetryWrapperFileSystem.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieRetryWrapperFileSystem.java index c9d8fff3fbbd7..d7c1ca5f72fc8 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieRetryWrapperFileSystem.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieRetryWrapperFileSystem.java @@ -277,7 +277,7 @@ public Configuration getConf() { @Override public String getScheme() { - return fileSystem.getScheme(); + return HadoopFSUtils.getScheme(fileSystem); } @Override diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieWrapperFileSystem.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieWrapperFileSystem.java index 24674ee725a90..f8d731fd0ce21 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieWrapperFileSystem.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/hadoop/fs/HoodieWrapperFileSystem.java @@ -159,12 +159,8 @@ public HoodieWrapperFileSystem(FileSystem fileSystem, ConsistencyGuard consisten } public static Path convertToHoodiePath(StoragePath file, Configuration conf) { - try { - String scheme = HadoopFSUtils.getFs(file.toString(), conf).getScheme(); - return convertPathWithScheme(convertToHadoopPath(file), getHoodieScheme(scheme)); - } catch (HoodieIOException e) { - throw e; - } + String scheme = HadoopFSUtils.getScheme(HadoopFSUtils.getFs(file.toString(), conf)); + return convertPathWithScheme(convertToHadoopPath(file), getHoodieScheme(scheme)); } public static Path convertPathWithScheme(Path oldPath, String newScheme) { diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroHFileWriter.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroHFileWriter.java index 9031390af1bb8..864fbc9f80b04 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroHFileWriter.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroHFileWriter.java @@ -133,7 +133,7 @@ public void writeAvro(String recordKey, IndexedRecord record) throws IOException if (!this.hfileConfig.isAllowDuplicatesOnHfileWrites()) { // When allowDuplicatesOnHfileWrites is true, allow duplicates to be written to hFile. if (prevRecordKey.equals(recordKey)) { - LOG.info("Duplicate recordKey " + recordKey + " found while writing to HFile. Record payload " + record); + LOG.info("Duplicate recordKey {} found while writing to HFile. Record payload {}", recordKey, record); throw new HoodieDuplicateKeyException("Duplicate recordKey " + recordKey + " found while writing to HFile."); } } diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/parquet/io/HoodieParquetFileBinaryCopier.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/parquet/io/HoodieParquetFileBinaryCopier.java index fda8780a46c29..c2a8265573cc5 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/parquet/io/HoodieParquetFileBinaryCopier.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/parquet/io/HoodieParquetFileBinaryCopier.java @@ -315,7 +315,7 @@ private void triggerPrefetch() { } return new PrefetchResult(targetBuffer, requiredSize); } catch (IOException e) { - log.error("Failed to prefetch file: " + fileToPrefetch, e); + log.error("Failed to prefetch file: {}", fileToPrefetch, e); throw new RuntimeException(e); } }, prefetchExecutor); diff --git a/hudi-hadoop-common/src/main/java/org/apache/hudi/storage/hadoop/HoodieHadoopStorage.java b/hudi-hadoop-common/src/main/java/org/apache/hudi/storage/hadoop/HoodieHadoopStorage.java index 87a9ad1019f63..841ea2b40754c 100644 --- a/hudi-hadoop-common/src/main/java/org/apache/hudi/storage/hadoop/HoodieHadoopStorage.java +++ b/hudi-hadoop-common/src/main/java/org/apache/hudi/storage/hadoop/HoodieHadoopStorage.java @@ -31,6 +31,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathFilter; import org.apache.hudi.storage.StoragePathInfo; +import org.apache.hudi.util.Lazy; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; @@ -58,6 +59,13 @@ */ public class HoodieHadoopStorage extends HoodieStorage { private final FileSystem fs; + /** + * Resolved once. On a filesystem that does not implement {@code getScheme()} the fallback in + * {@link HadoopFSUtils#getScheme} costs a thrown-and-caught exception, and this is called once per log + * block via {@code StorageSchemes.isWriteTransactional} and three times per immutable-file write via + * {@code needCreateTempFile}. {@code fs} is final, so the answer cannot change. + */ + private final Lazy scheme = Lazy.lazily(this::resolveScheme); public HoodieHadoopStorage(StoragePath path, StorageConfiguration conf) { super(conf); @@ -111,7 +119,11 @@ public HoodieStorage newInstance(StoragePath path, StorageConfiguration stora @Override public String getScheme() { - return fs.getScheme(); + return scheme.get(); + } + + private String resolveScheme() { + return HadoopFSUtils.getScheme(fs); } @Override diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestExecutorServiceBasedEngineContext.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestExecutorServiceBasedEngineContext.java new file mode 100644 index 0000000000000..475aa4c9d2dc8 --- /dev/null +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestExecutorServiceBasedEngineContext.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.hudi.common.engine; + +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.storage.HoodieStorage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorage; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestExecutorServiceBasedEngineContext { + + private ExecutorServiceBasedEngineContext context; + + @BeforeEach + void setUp() { + HoodieStorage storage = getDefaultStorage(); + context = new ExecutorServiceBasedEngineContext(storage.getConf()); + } + + @Test + void testMapHappyPath() { + List result = context.map(Arrays.asList(1, 2, 3), x -> x * 2, 3); + assertEquals(Arrays.asList(2, 4, 6), result.stream().sorted().collect(Collectors.toList())); + } + + @Test + void testMapEmptyList() { + List result = context.map(java.util.Collections.emptyList(), x -> x * 2, 0); + assertTrue(result.isEmpty(), "map over empty list must return empty list"); + } + + @Test + void testMapPreservesInputOrder() { + List input = IntStream.range(0, 100).boxed().collect(Collectors.toList()); + List result = context.map(input, x -> x, 100); + assertEquals(input, result, "map must return results in the same order as the input list"); + } + + @Test + void testMapPropagatesRuntimeException() { + RuntimeException original = new RuntimeException("boom"); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> + context.map(java.util.Collections.singletonList(1), x -> { + throw original; + }, 1)); + // throwingMapWrapper wraps ALL exceptions in HoodieException; original is the direct cause + assertInstanceOf(HoodieException.class, thrown); + assertSame(original, thrown.getCause()); + } + + @Test + void testMapPropagatesHoodieException() { + HoodieException original = new HoodieException("hoodie-boom"); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> + context.map(java.util.Collections.singletonList(1), x -> { + throw original; + }, 1)); + assertInstanceOf(HoodieException.class, thrown); + assertSame(original, thrown.getCause()); + } + + @Test + void testMapWrapsCheckedException() { + Exception checkedCause = new Exception("checked!"); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> + context.map(java.util.Collections.singletonList(1), x -> { + throw checkedCause; + }, 1)); + assertInstanceOf(HoodieException.class, thrown); + assertSame(checkedCause, thrown.getCause()); + } + + @Test + void testWorkerThreadClassloader() { + ClassLoader[] captured = new ClassLoader[1]; + context.map(java.util.Collections.singletonList(1), x -> { + captured[0] = Thread.currentThread().getContextClassLoader(); + return x; + }, 1); + assertEquals(ExecutorServiceBasedEngineContext.class.getClassLoader(), captured[0], + "Worker threads must use ExecutorServiceBasedEngineContext classloader to avoid ClassNotFoundException on Java 11+"); + } +} diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestHoodieLocalEngineContext.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestHoodieLocalEngineContext.java index bdb858c763e4f..c4fc1e285f7c1 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestHoodieLocalEngineContext.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/engine/TestHoodieLocalEngineContext.java @@ -150,24 +150,24 @@ void testProcessKeyGroupsWithSingleValue() { ImmutablePair.of("key1", 42), ImmutablePair.of("key2", 17) ); - + HoodiePairData pairData = HoodieListPairData.lazy(singleValuePairs); - + // Create a function that just returns the values SerializableFunction, Iterator> func = iterator -> { List values = new ArrayList<>(); iterator.forEachRemaining(values::add); return values.iterator(); }; - + List shardIndices = Arrays.asList("key1", "key2"); HoodieData result = context.mapGroupsByKey(pairData, func, shardIndices, false); - + List resultList = result.collectAsList(); - + // Verify the results assertEquals(2, resultList.size()); assertTrue(resultList.contains(42)); assertTrue(resultList.contains(17)); } -} \ No newline at end of file +} diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtilsWithRetryWrapperEnable.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtilsWithRetryWrapperEnable.java index bb0b3608c7fc8..aaaf749e85d46 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtilsWithRetryWrapperEnable.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtilsWithRetryWrapperEnable.java @@ -45,7 +45,6 @@ import java.util.Arrays; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -107,9 +106,13 @@ public void testGetSchema() { FileSystem fileSystem = new HoodieRetryWrapperFileSystem(fakeFs, maxRetryIntervalMs, maxRetryNumbers, initialRetryIntervalMs, ""); - HoodieWrapperFileSystem fs = - new HoodieWrapperFileSystem(fileSystem, new NoOpConsistencyGuard()); - assertDoesNotThrow(fs::getScheme, "Method #getSchema does not implement correctly"); + // FakeRemoteFileSystem deliberately does not override getScheme(), so FileSystem's own implementation + // throws - the PrestoS3FileSystem shape (HUDI-4602). Assert on the retry wrapper itself: asserting on + // HoodieWrapperFileSystem instead would only exercise its own uri.getScheme() and never reach here, + // which is why this guard was inert from the day HUDI-5286 added it. + assertThrows(UnsupportedOperationException.class, fakeFs::getScheme); + assertEquals("file", ((HoodieRetryWrapperFileSystem) fileSystem).getScheme(), + "the retry wrapper should resolve the scheme of a filesystem that does not implement getScheme()"); } @Test @@ -254,11 +257,6 @@ public Configuration getConf() { return fs.getConf(); } - @Override - public String getScheme() { - return fs.getScheme(); - } - @Override public short getDefaultReplication(Path path) { return defaultReplication; diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java index cde861f8c5f26..81d5c71aadc50 100755 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java @@ -45,9 +45,10 @@ import org.apache.hudi.common.table.log.HoodieLogFileReader; import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.HoodieLogFormat.Reader; -import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.HoodieMergedLogRecordScanner; +import org.apache.hudi.common.table.log.HoodieUnMergedLogRecordScanner; +import org.apache.hudi.common.table.log.InstantRange; import org.apache.hudi.common.table.log.TestLogReaderUtils; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; @@ -207,10 +208,14 @@ public void testHoodieLogBlockTypeIsDataOrDeleteBlock() { @Test public void testEmptyLog() throws IOException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); assertEquals(0, writer.getCurrentSize(), "Just created this log, size should be 0"); assertTrue(writer.getLogFile().getFileName().startsWith("."), "Check all log files should start with a ."); assertEquals(1, writer.getLogFile().getLogVersion(), "Version should be 1 for new log created"); @@ -220,22 +225,26 @@ public void testEmptyLog() throws IOException { @ParameterizedTest @EnumSource(names = {"AVRO_DATA_BLOCK", "HFILE_DATA_BLOCK", "PARQUET_DATA_BLOCK"}) public void testBasicAppend(HoodieLogBlockType dataBlockType) throws IOException, InterruptedException, URISyntaxException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, getSimpleSchema().toString()); - long pos = writer.getCurrentSize(); HoodieDataBlock dataBlock = getDataBlock(dataBlockType, records, header); AppendResult result = writer.appendBlock(dataBlock); long size = writer.getCurrentSize(); assertTrue(size > 0, "We just wrote a block - size should be > 0"); + writer.sync(); assertEquals(size, storage.getPathInfo(writer.getLogFile().getPath()).getLength(), - "Write should be auto-flushed. The size reported by FileStatus and the writer should match"); + "After explicit sync, FileStatus length should match the writer's reported size"); assertEquals(size, result.size()); assertEquals(writer.getLogFile(), result.logFile()); assertEquals(0, result.offset()); @@ -244,10 +253,14 @@ public void testBasicAppend(HoodieLogBlockType dataBlockType) throws IOException @Test public void testRollover() throws IOException, InterruptedException, URISyntaxException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -264,10 +277,14 @@ public void testRollover() throws IOException, InterruptedException, URISyntaxEx // Create a writer with the size threshold as the size we just wrote - so this has to roll writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage) - .withSizeThreshold(size - 1).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .withSizeThreshold(size - 1) + .build(); records = SchemaTestUtil.generateTestRecords(0, 100); dataBlock = getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records, header); AppendResult secondAppend = writer.appendBlock(dataBlock); @@ -305,14 +322,19 @@ public void testConcurrentAppendOnFirstLogFileVersion() throws Exception { } private void testConcurrentAppend(boolean logFileExists, boolean newLogFileFormat) throws Exception { - HoodieLogFormat.WriterBuilder builder1 = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId("test-fileid1") - .withInstantTime("100").withStorage(storage); - HoodieLogFormat.WriterBuilder builder2 = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId("test-fileid1") - .withInstantTime("100").withStorage(storage); + HoodieLogFormatWriter.HoodieLogFormatWriterBuilder builder1 = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage); + + HoodieLogFormatWriter.HoodieLogFormatWriterBuilder builder2 = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage); if (newLogFileFormat && logFileExists) { // Assume there is an existing log-file with write token @@ -329,14 +351,14 @@ private void testConcurrentAppend(boolean logFileExists, boolean newLogFileForma } else { builder1 = builder1.withLogVersion(1).withLogWriteToken(HoodieLogFormat.UNKNOWN_WRITE_TOKEN); } - Writer writer = builder1.build(); + HoodieLogFormat.Writer writer = builder1.build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, getSimpleSchema().toString()); HoodieDataBlock dataBlock = getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records, header); writer.appendBlock(dataBlock); - Writer writer2 = builder2.build(); + HoodieLogFormat.Writer writer2 = builder2.build(); writer2.appendBlock(dataBlock); HoodieLogFile logFile1 = writer.getLogFile(); HoodieLogFile logFile2 = writer2.getLogFile(); @@ -349,11 +371,15 @@ private void testConcurrentAppend(boolean logFileExists, boolean newLogFileForma @ParameterizedTest @EnumSource(names = {"AVRO_DATA_BLOCK", "HFILE_DATA_BLOCK", "PARQUET_DATA_BLOCK"}) public void testMultipleAppend(HoodieLogBlockType dataBlockType) throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withLogVersion(1).withInstantTime("100") - .withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withLogVersion(1) + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -364,10 +390,14 @@ public void testMultipleAppend(HoodieLogBlockType dataBlockType) throws IOExcept writer.close(); writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withLogVersion(1).withInstantTime("100") - .withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withLogVersion(1) + .withInstantTime("100") + .withStorage(storage) + .build(); ((HoodieLogFormatWriter) writer).withOutputStream((FSDataOutputStream) storage.append(writer.getLogFile().getPath())); records = SchemaTestUtil.generateTestRecords(0, 100); @@ -376,16 +406,21 @@ public void testMultipleAppend(HoodieLogBlockType dataBlockType) throws IOExcept writer.appendBlock(dataBlock); long size2 = writer.getCurrentSize(); assertTrue(size2 > size1, "We just wrote a new block - size2 should be > size1"); + writer.sync(); assertEquals(size2, storage.getPathInfo(writer.getLogFile().getPath()).getLength(), - "Write should be auto-flushed. The size reported by FileStatus and the writer should match"); + "After explicit sync, FileStatus length should match the writer's reported size"); writer.close(); // Close and Open again and append 100 more records writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withLogVersion(1).withInstantTime("100") - .withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withLogVersion(1) + .withInstantTime("100") + .withStorage(storage) + .build(); ((HoodieLogFormatWriter) writer).withOutputStream( (FSDataOutputStream) storage.append(writer.getLogFile().getPath())); records = SchemaTestUtil.generateTestRecords(0, 100); @@ -394,12 +429,13 @@ public void testMultipleAppend(HoodieLogBlockType dataBlockType) throws IOExcept writer.appendBlock(dataBlock); long size3 = writer.getCurrentSize(); assertTrue(size3 > size2, "We just wrote a new block - size3 should be > size2"); + writer.sync(); assertEquals(size3, storage.getPathInfo(writer.getLogFile().getPath()).getLength(), - "Write should be auto-flushed. The size reported by FileStatus and the writer should match"); + "After explicit sync, FileStatus length should match the writer's reported size"); writer.close(); // Cannot get the current size after closing the log - final Writer closedWriter = writer; + final HoodieLogFormat.Writer closedWriter = writer; assertThrows(IllegalStateException.class, closedWriter::getCurrentSize, "getCurrentSize should fail after the logAppender is closed"); } @@ -421,8 +457,10 @@ public void testAppendNotSupported(@TempDir java.nio.file.Path tempDir) throws I HoodieDataBlock dataBlock = getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records, header); for (int i = 0; i < 2; i++) { - Writer writer = HoodieLogFormat.newWriterBuilder().onParentPath(testPath) - .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION).withFileId("commits") + HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(testPath) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withLogFileId("commits") .withInstantTime("") .withStorage(localStorage).build(); writer.appendBlock(dataBlock); @@ -437,11 +475,12 @@ public void testAppendNotSupported(@TempDir java.nio.file.Path tempDir) throws I @ParameterizedTest @ValueSource(ints = {6, 8}) public void testBasicWriteAndScan(int tableVersion) throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withTableVersion(HoodieTableVersion.fromVersionCode(tableVersion)) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); HoodieSchema schema = getSimpleSchema(); List records = SchemaTestUtil.generateTestRecords(0, 100); List copyOfRecords = records.stream() @@ -472,10 +511,12 @@ private List convertAvroToSerializableIndexedRecords(List records = SchemaTestUtil.generateTestRecords(0, numRecords); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -1076,10 +1120,13 @@ private HoodieLogFile addValidBlock(String fileId, String commitTime, int numRec private HoodieLogFile appendValidBlock(StoragePath path, String fileId, String commitTime, int numRecords) throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId(fileId).withInstantTime(commitTime).withStorage(storage).build(); + .withLogFileId(fileId) + .withInstantTime(commitTime) + .withStorage(storage) + .build(); ((HoodieLogFormatWriter) writer).withOutputStream( (FSDataOutputStream) storage.append(path)); List records = SchemaTestUtil.generateTestRecords(0, numRecords); @@ -1094,10 +1141,13 @@ private HoodieLogFile appendValidBlock(StoragePath path, String fileId, String c @Test public void testValidateCorruptBlockEndPosition() throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -1150,11 +1200,14 @@ public void testAvroLogRecordReaderBasic(ExternalSpillableMap.DiskMapType diskMa throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage) - .withSizeThreshold(500).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .withSizeThreshold(500L) + .build(); SchemaTestUtil testUtil = new SchemaTestUtil(); // Write 1 @@ -1194,10 +1247,13 @@ public void testAvroLogRecordReaderWithRollbackTombstone(ExternalSpillableMap.Di throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1257,10 +1313,13 @@ public void testAvroLogRecordReaderWithFailedPartialBlock(ExternalSpillableMap.D throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1295,9 +1354,12 @@ public void testAvroLogRecordReaderWithFailedPartialBlock(ExternalSpillableMap.D outputStream.close(); writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 3 header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "103"); List records3 = testUtil.generateHoodieTestRecords(0, 100); @@ -1327,10 +1389,13 @@ public void testAvroLogRecordReaderWithDeleteAndRollback(ExternalSpillableMap.Di throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1468,10 +1533,13 @@ public void testAvroLogRecordReaderWithCommitBeforeAndAfterRollback(ExternalSpil HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version String fileId = "test-fileid111"; - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId(fileId).withInstantTime("100").withStorage(storage).build(); + .withLogFileId(fileId) + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 -> 100 records are written SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1576,10 +1644,13 @@ public void testAvroLogRecordReaderWithDisorderDelete(ExternalSpillableMap.DiskM throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1709,10 +1780,13 @@ public void testAvroLogRecordReaderWithFailedRollbacks(ExternalSpillableMap.Disk // Write a Data block and Delete block with same InstantTime (written in same batch) HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1778,10 +1852,13 @@ public void testAvroLogRecordReaderWithInsertDeleteAndRollback(ExternalSpillable // Write a Data block and Delete block with same InstantTime (written in same batch) HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1830,10 +1907,13 @@ public void testAvroLogRecordReaderWithInvalidRollback(ExternalSpillableMap.Disk throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1867,10 +1947,13 @@ public void testAvroLogRecordReaderWithInsertsDeleteAndRollback(ExternalSpillabl // Write a 3 Data blocs with same InstantTime (written in same batch) HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -1921,10 +2004,13 @@ void testLogReaderWithDifferentVersionsOfDeleteBlocks(ExternalSpillableMap.DiskM throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); List deleteKeyListInV2Block = Arrays.asList( "d448e1b8-a0d4-45c0-bf2d-a9e16ff3c8ce", "df3f71cd-5b68-406c-bb70-861179444adb", @@ -2044,10 +2130,13 @@ public void testAvroLogRecordReaderWithRollbackOlderBlocks() throws IOException, URISyntaxException, InterruptedException { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -2104,10 +2193,13 @@ public void testAvroLogRecordReaderWithMixedInsertsCorruptsAndRollback(ExternalS // Write a 3 Data blocs with same InstantTime (written in same batch) HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -2149,9 +2241,13 @@ public void testAvroLogRecordReaderWithMixedInsertsCorruptsAndRollback(ExternalS outputStream.close(); writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); writer.appendBlock(dataBlock); writer.close(); @@ -2169,9 +2265,12 @@ public void testAvroLogRecordReaderWithMixedInsertsCorruptsAndRollback(ExternalS outputStream.close(); writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1 rollback block for the last commit instant header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "101"); header.put(HeaderMetadataType.TARGET_INSTANT_TIME, "100"); @@ -2199,10 +2298,13 @@ public void testAvroLogRecordReaderWithMixedInsertsCorruptsRollbackAndMergedLogB // Write a 3 Data blocks with same InstantTime (written in same batch) HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); // Set a small threshold so that every block is a new version - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); // Write 1st data blocks multiple times. SchemaTestUtil testUtil = new SchemaTestUtil(); @@ -2272,9 +2374,12 @@ public void testAvroLogRecordReaderWithMixedInsertsCorruptsRollbackAndMergedLogB outputStream.close(); writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); ((HoodieLogFormatWriter) writer).withOutputStream( (FSDataOutputStream) storage.append(writer.getLogFile().getPath())); @@ -2402,9 +2507,13 @@ private void testAvroLogRecordReaderMergingMultipleLogFiles(int numRecordsInLog1 List records2 = new ArrayList<>(records); // Write1 with numRecordsInLog1 records written to log.1 - Writer writer = HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId("test-fileid1") - .withInstantTime("100").withStorage(storage).build(); + HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -2416,9 +2525,14 @@ private void testAvroLogRecordReaderMergingMultipleLogFiles(int numRecordsInLog1 writer.close(); // write2 with numRecordsInLog2 records written to log.2 - Writer writer2 = HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId("test-fileid1") - .withInstantTime("100").withStorage(storage).withSizeThreshold(size - 1).build(); + HoodieLogFormat.Writer writer2 = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .withSizeThreshold(size - 1) + .build(); Map header2 = new HashMap<>(); header2.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); @@ -2495,10 +2609,14 @@ public void testAvroLogRecordReaderTasksSucceededInBothStageAttempts(ExternalSpi @Test public void testBasicAppendAndReadInReverse() throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); HoodieSchema schema = getSimpleSchema(); List records1 = SchemaTestUtil.generateTestRecords(0, 100); List copyOfRecords1 = records1.stream() @@ -2565,10 +2683,13 @@ public void testBasicAppendAndReadInReverse() @Test public void testAppendAndReadOnCorruptedLogInReverse() throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); HoodieSchema schema = getSimpleSchema(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); @@ -2599,9 +2720,12 @@ public void testAppendAndReadOnCorruptedLogInReverse() // Should be able to append a new block writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); ((HoodieLogFormatWriter) writer).withOutputStream( (FSDataOutputStream) storage.append(writer.getLogFile().getPath())); records = SchemaTestUtil.generateTestRecords(0, 100); @@ -2627,10 +2751,13 @@ public void testAppendAndReadOnCorruptedLogInReverse() @Test public void testBasicAppendAndTraverseInReverse() throws IOException, URISyntaxException, InterruptedException { - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); HoodieSchema schema = getSimpleSchema(); List records1 = SchemaTestUtil.generateTestRecords(0, 100); List copyOfRecords1 = records1.stream() @@ -2714,10 +2841,10 @@ public void testV0Format() throws IOException, URISyntaxException { public void testDataBlockFormatAppendAndReadWithProjectedSchema( HoodieLogBlockType dataBlockType ) throws IOException, URISyntaxException, InterruptedException { - Writer writer = HoodieLogFormat.newWriterBuilder() - .onParentPath(partitionPath) + HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1") + .withLogFileId("test-fileid1") .withInstantTime("100") .withStorage(storage) .build(); @@ -2830,6 +2957,206 @@ public void testGetRecordPositions(boolean recordWithPositions, TestLogReaderUtils.assertPositionEquals(expectedPositions, dataBlock.getRecordPositions()); } + @Test + public void testUnMergedLogRecordScannerCallbacksWithDeletes() + throws IOException, URISyntaxException, InterruptedException { + HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); + + SchemaTestUtil testUtil = new SchemaTestUtil(); + List records = testUtil.generateHoodieTestRecords(0, 100); + List copyOfRecords = records.stream() + .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, schema.toAvroSchema())) + .collect(Collectors.toList()); + Map header = new HashMap<>(); + header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); + header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString()); + writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records, header)); + + // Delete the first 20 keys via a delete block. + List allKeys = copyOfRecords.stream() + .map(r -> ((GenericRecord) r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString()) + .collect(Collectors.toList()); + List deletedKeys = new ArrayList<>(allKeys.subList(0, 20)); + List> deleteRecordList = copyOfRecords.subList(0, 20).stream() + .map(r -> Pair.of(DeleteRecord.create( + ((GenericRecord) r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString(), + ((GenericRecord) r).get(HoodieRecord.PARTITION_PATH_METADATA_FIELD).toString()), + -1L)) + .collect(Collectors.toList()); + header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); + writer.appendBlock(new HoodieDeleteBlock(deleteRecordList, header)); + writer.close(); + + FileCreateUtilsLegacy.createDeltaCommit(basePath, "100", storage); + + List insertedKeys = new ArrayList<>(); + List deletedKeysSeen = new ArrayList<>(); + HoodieUnMergedLogRecordScanner scanner = HoodieUnMergedLogRecordScanner.newBuilder() + .withStorage(storage) + .withBasePath(basePath) + .withLogFilePaths(Collections.singletonList(writer.getLogFile().getPath().toString())) + .withReaderSchema(schema) + .withLatestInstantTime("100") + .withReverseReader(false) + .withBufferSize(BUFFER_SIZE) + .withLogRecordScannerCallback(record -> insertedKeys.add(record.getRecordKey())) + .withRecordDeletionCallback(key -> deletedKeysSeen.add(key.getRecordKey())) + .build(); + scanner.scan(); + + // The un-merged scanner streams every data record and every deleted key through the callbacks + // without merging them, so all 100 inserts and all 20 deletes should be observed exactly once. + assertEquals(100, insertedKeys.size(), "Callback should see every appended data record"); + assertEquals(new HashSet<>(allKeys), new HashSet<>(insertedKeys), + "Callback keys should match every appended record key"); + assertEquals(20, deletedKeysSeen.size(), "Deletion callback should see every deleted key"); + assertEquals(new HashSet<>(deletedKeys), new HashSet<>(deletedKeysSeen), + "Deletion callback keys should match the delete block keys"); + } + + @Test + public void testUnMergedLogRecordScannerInstantRangeFiltering() + throws IOException, URISyntaxException, InterruptedException { + HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(getSimpleSchema()); + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); + + SchemaTestUtil testUtil = new SchemaTestUtil(); + // First block belongs to instant 100, second block to instant 101. + List recordsAt100 = testUtil.generateHoodieTestRecords(0, 60); + List keysAt100 = recordsAt100.stream() + .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, schema.toAvroSchema())) + .map(r -> ((GenericRecord) r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString()) + .collect(Collectors.toList()); + Map header = new HashMap<>(); + header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); + header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString()); + writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, recordsAt100, header)); + + List recordsAt101 = testUtil.generateHoodieTestRecords(0, 40); + header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "101"); + writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, recordsAt101, header)); + writer.close(); + + FileCreateUtilsLegacy.createDeltaCommit(basePath, "100", storage); + FileCreateUtilsLegacy.createDeltaCommit(basePath, "101", storage); + + // A closed range of [100, 100] should keep only the first block and drop the instant 101 block. + InstantRange instantRange = InstantRange.builder() + .startInstant("100") + .endInstant("100") + .rangeType(InstantRange.RangeType.CLOSED_CLOSED) + .build(); + + List seenKeys = new ArrayList<>(); + HoodieUnMergedLogRecordScanner scanner = HoodieUnMergedLogRecordScanner.newBuilder() + .withStorage(storage) + .withBasePath(basePath) + .withLogFilePaths(Collections.singletonList(writer.getLogFile().getPath().toString())) + .withReaderSchema(schema) + .withLatestInstantTime("101") + .withReverseReader(false) + .withBufferSize(BUFFER_SIZE) + .withInstantRange(Option.of(instantRange)) + .withLogRecordScannerCallback(record -> seenKeys.add(record.getRecordKey())) + .build(); + scanner.scan(); + + assertEquals(60, seenKeys.size(), "Only the instant 100 block should pass the instant range"); + assertEquals(new HashSet<>(keysAt100), new HashSet<>(seenKeys), + "Records outside the instant range should be filtered out"); + } + + @Test + public void testLogFileReaderReadsPastCorruptBlock() + throws IOException, URISyntaxException, InterruptedException { + HoodieSchema schema = getSimpleSchema(); + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); + List records1 = SchemaTestUtil.generateTestRecords(0, 100); + List copyOfRecords1 = records1.stream() + .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, schema.toAvroSchema())) + .collect(Collectors.toList()); + Map header = new HashMap<>(); + header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); + header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString()); + writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records1, header)); + writer.close(); + + // Append a block whose declared length does not match its content, mimicking a partial write. + FSDataOutputStream outputStream = (FSDataOutputStream) storage.append(writer.getLogFile().getPath()); + outputStream.write(HoodieLogFormat.MAGIC); + outputStream.writeLong(474); + outputStream.writeInt(HoodieLogBlockType.AVRO_DATA_BLOCK.ordinal()); + outputStream.writeInt(HoodieLogFormat.CURRENT_VERSION); + outputStream.writeLong(400); + outputStream.write(getUTF8Bytes("truncated-block-content")); + outputStream.flush(); + outputStream.close(); + + // Append a valid trailing block so the reader has a real block to recover to after the corruption. + HoodieLogFormat.Writer appendWriter = + HoodieLogFormatWriter.builder().withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); + ((HoodieLogFormatWriter) appendWriter).withOutputStream( + (FSDataOutputStream) storage.append(writer.getLogFile().getPath())); + List records2 = SchemaTestUtil.generateTestRecords(0, 10); + List copyOfRecords2 = records2.stream() + .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, schema.toAvroSchema())) + .collect(Collectors.toList()); + appendWriter.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records2, header)); + appendWriter.close(); + + HoodieLogFile logFile = new HoodieLogFile(appendWriter.getLogFile().getPath(), + storage.getPathInfo(appendWriter.getLogFile().getPath()).getLength()); + try (HoodieLogFileReader reader = + new HoodieLogFileReader(storage, logFile, SchemaTestUtil.getSimpleSchema(), BUFFER_SIZE)) { + // First a valid data block. + assertTrue(reader.hasNext(), "First data block should be available"); + HoodieLogBlock firstBlock = reader.next(); + assertEquals(HoodieLogBlockType.AVRO_DATA_BLOCK, firstBlock.getBlockType(), "First block should be a data block"); + List firstRead = getRecords((HoodieDataBlock) firstBlock); + assertEquals(convertAvroToSerializableIndexedRecords(copyOfRecords1), firstRead, + "First block contents should match the written records"); + + // The reader seeks past the bad magic/length and surfaces a corrupt block. + assertTrue(reader.hasNext(), "Corrupt block should be surfaced"); + HoodieLogBlock corruptBlock = reader.next(); + assertEquals(HoodieLogBlockType.CORRUPT_BLOCK, corruptBlock.getBlockType(), "Second block should be a corrupt block"); + + // The valid trailing block should still be readable after recovery. + assertTrue(reader.hasNext(), "Trailing data block should be available after the corrupt block"); + HoodieLogBlock lastBlock = reader.next(); + assertEquals(HoodieLogBlockType.AVRO_DATA_BLOCK, lastBlock.getBlockType(), "Third block should be a data block"); + List lastRead = getRecords((HoodieDataBlock) lastBlock); + assertEquals(convertAvroToSerializableIndexedRecords(copyOfRecords2), lastRead, + "Trailing block contents should match the written records"); + + assertFalse(reader.hasNext(), "There should be no more blocks"); + } + } + private static Stream testArguments() { // Arg1: ExternalSpillableMap Type, Arg2: isDiskMapCompressionEnabled return Stream.of( @@ -2859,10 +3186,14 @@ private static List sort(List records) { private HoodieLogFormat.Reader createCorruptedFile(String fileId) throws Exception { // block is corrupted, but check is skipped. - Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) + HoodieLogFormat.Writer writer = + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId(fileId).withInstantTime("100").withStorage(storage).build(); + .withLogFileId(fileId) + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormatAppendFailure.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormatAppendFailure.java index 7c08785ccf3a8..17cb86692ecbf 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormatAppendFailure.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormatAppendFailure.java @@ -21,8 +21,8 @@ import org.apache.hudi.common.model.HoodieArchivedLogFile; import org.apache.hudi.common.model.HoodieAvroIndexedRecord; import org.apache.hudi.common.model.HoodieRecord; -import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; @@ -120,9 +120,13 @@ public void testFailedToGetAppendStreamFromHDFSNameNode() HoodieAvroDataBlock dataBlock = new HoodieAvroDataBlock(records, header, HoodieRecord.RECORD_KEY_METADATA_FIELD); - Writer writer = HoodieLogFormat.newWriterBuilder().onParentPath(testPath) - .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION).withFileId("commits") - .withInstantTime("").withStorage(storage).build(); + Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(testPath) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withLogFileId("commits") + .withInstantTime("") + .withStorage(storage) + .build(); writer.appendBlock(dataBlock); // get the current log file version to compare later @@ -152,9 +156,13 @@ public void testFailedToGetAppendStreamFromHDFSNameNode() // Opening a new Writer right now will throw IOException. The code should handle this, rollover the logfile and // return a new writer with a bumped up logVersion - writer = HoodieLogFormat.newWriterBuilder().onParentPath(testPath) - .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION).withFileId("commits") - .withInstantTime("").withStorage(storage).build(); + writer = HoodieLogFormatWriter.builder() + .withParentPath(testPath) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withLogFileId("commits") + .withInstantTime("") + .withStorage(storage) + .build(); header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.COMMAND_BLOCK_TYPE, String.valueOf(HoodieCommandBlock.HoodieCommandBlockTypeEnum.ROLLBACK_BLOCK.ordinal())); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableConfig.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableConfig.java index eebb5a77a8be2..2784ee00a5887 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableConfig.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableConfig.java @@ -395,7 +395,7 @@ void testDefinedTableConfigs() { void testTableMergeProperties() throws IOException { // for out of the box, there are no merge properties HoodieTableConfig config = new HoodieTableConfig(storage, metaPath); - assertTrue(config.getTableMergeProperties().isEmpty()); + assertTrue(config.getTableMergeProperties(config.getPayloadClass()).isEmpty()); // delete and re-create w/ merge properties storage.deleteFile(cfgPath); @@ -414,7 +414,7 @@ void testTableMergeProperties() throws IOException { Map expectedProps = new HashMap<>(); expectedProps.put("key1","value1"); expectedProps.put("key2","value2"); - assertEquals(expectedProps, config.getTableMergeProperties()); + assertEquals(expectedProps, config.getTableMergeProperties(config.getPayloadClass())); } private static Stream testInferMergingConfigsForPreV9Table() { diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestTableSchemaResolver.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestTableSchemaResolver.java index 193bb0173dfda..f2c6d2b49b204 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestTableSchemaResolver.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestTableSchemaResolver.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.HoodieInstant; @@ -195,8 +196,13 @@ private String initTestDir(String folderName) throws IOException { private StoragePath writeLogFile(StoragePath partitionPath, Schema schema) throws IOException, URISyntaxException, InterruptedException { HoodieStorage storage = HoodieTestUtils.getStorage(partitionPath); HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath).withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId("test-fileid1").withInstantTime("100").withStorage(storage).build(); + HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid1") + .withInstantTime("100") + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(0, 100); Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100"); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/TestHoodieLogFormatWriter.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/TestHoodieLogFormatWriter.java new file mode 100644 index 0000000000000..15316012a97ab --- /dev/null +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/TestHoodieLogFormatWriter.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.hudi.common.table.log; + +import org.apache.hudi.common.model.HoodieLogFile; +import org.apache.hudi.common.table.log.block.HoodieCommandBlock; +import org.apache.hudi.common.table.log.block.HoodieLogBlock; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StoragePath; + +import org.apache.hadoop.fs.FSDataOutputStream; +import org.apache.hadoop.fs.FileSystem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestHoodieLogFormatWriter { + + private static final String WRITE_FAIL = "write-fail"; + private static final String CLOSE_FAIL = "close-fail"; + private static final String SYNC_FAIL = "sync-fail"; + + @TempDir + java.nio.file.Path tempDir; + + @Test + void testCloseOutputOnAppendWriteException() throws IOException { + HoodieStorage storage = HoodieTestUtils.getStorage(tempDir.toString()); + HoodieLogFormatWriter writer = newWriter(storage); + try { + CloseTrackingOutputStream outputStream = new CloseTrackingOutputStream(true, false); + writer.withOutputStream(newFSDataOutputStream(outputStream, storage)); + + IOException exception = assertThrows(IOException.class, () -> writer.appendBlock(commandBlock())); + + assertEquals(WRITE_FAIL, exception.getMessage()); + assertTrue(outputStream.isClosed()); + assertThrows(IllegalStateException.class, writer::getCurrentSize); + } finally { + writer.close(); + } + } + + @Test + void testPreserveAppendExceptionWhenCloseFails() throws IOException { + HoodieStorage storage = HoodieTestUtils.getStorage(tempDir.toString()); + HoodieLogFormatWriter writer = newWriter(storage); + try { + CloseTrackingOutputStream outputStream = new CloseTrackingOutputStream(true, true); + writer.withOutputStream(newFSDataOutputStream(outputStream, storage)); + + IOException exception = assertThrows(IOException.class, () -> writer.appendBlock(commandBlock())); + + assertEquals(WRITE_FAIL, exception.getMessage()); + assertTrue(outputStream.isClosed()); + assertEquals(1, exception.getSuppressed().length); + assertEquals(CLOSE_FAIL, exception.getSuppressed()[0].getMessage()); + assertThrows(IllegalStateException.class, writer::getCurrentSize); + } finally { + writer.close(); + } + } + + @Test + void testCloseOutputWhenSyncFailsOnClose() throws IOException { + HoodieStorage storage = HoodieTestUtils.getStorage(tempDir.toString()); + HoodieLogFormatWriter writer = newWriter(storage); + try { + CloseTrackingOutputStream outputStream = new CloseTrackingOutputStream(false, false); + writer.withOutputStream(new SyncFailingFSDataOutputStream(outputStream, storage)); + + IOException exception = assertThrows(IOException.class, writer::close); + + assertEquals(SYNC_FAIL, exception.getMessage()); + assertTrue(outputStream.isClosed()); + assertThrows(IllegalStateException.class, writer::getCurrentSize); + } finally { + writer.close(); + } + } + + private HoodieLogFormatWriter newWriter(HoodieStorage storage) throws IOException { + return HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(tempDir.toString())) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId("test-fileid") + .withInstantTime("100") + .withLogVersion(1) + .withStorage(storage) + .build(); + } + + private HoodieCommandBlock commandBlock() { + Map header = new HashMap<>(); + header.put(HoodieLogBlock.HeaderMetadataType.COMMAND_BLOCK_TYPE, + String.valueOf(HoodieCommandBlock.HoodieCommandBlockTypeEnum.ROLLBACK_BLOCK.ordinal())); + return new HoodieCommandBlock(header); + } + + private FSDataOutputStream newFSDataOutputStream(CloseTrackingOutputStream outputStream, HoodieStorage storage) + throws IOException { + return new FSDataOutputStream(outputStream, new FileSystem.Statistics(storage.getScheme())); + } + + private static class CloseTrackingOutputStream extends OutputStream { + + private final boolean failOnWrite; + private final boolean failOnClose; + private boolean closed; + + private CloseTrackingOutputStream(boolean failOnWrite, boolean failOnClose) { + this.failOnWrite = failOnWrite; + this.failOnClose = failOnClose; + } + + @Override + public void write(int b) throws IOException { + if (failOnWrite) { + throw new IOException(WRITE_FAIL); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (failOnWrite) { + throw new IOException(WRITE_FAIL); + } + } + + @Override + public void close() throws IOException { + closed = true; + if (failOnClose) { + throw new IOException(CLOSE_FAIL); + } + } + + private boolean isClosed() { + return closed; + } + } + + private static class SyncFailingFSDataOutputStream extends FSDataOutputStream { + + private SyncFailingFSDataOutputStream(CloseTrackingOutputStream outputStream, HoodieStorage storage) + throws IOException { + super(outputStream, new FileSystem.Statistics(storage.getScheme())); + } + + @Override + public void hsync() throws IOException { + throw new IOException(SYNC_FAIL); + } + } +} diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogWriterBuilder.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogFormatWriterBuilder.java similarity index 87% rename from hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogWriterBuilder.java rename to hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogFormatWriterBuilder.java index e362a033cfe4a..c7ed090fb85fe 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogWriterBuilder.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/log/block/TestHoodieLogFormatWriterBuilder.java @@ -23,6 +23,7 @@ import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; @@ -38,21 +39,21 @@ import static org.mockito.Mockito.mock; /** - * Test class for {@link HoodieLogFormat#newWriterBuilder()}. + * Test class for {@link HoodieLogFormatWriter#builder()}. */ -public class TestHoodieLogWriterBuilder { +public class TestHoodieLogFormatWriterBuilder { - HoodieLogFormat.WriterBuilder builder; + HoodieLogFormatWriter.HoodieLogFormatWriterBuilder builder; HoodieLogFormat.Writer writer; HoodieStorage storage; @BeforeEach public void setup() { storage = mock(HoodieStorage.class); - builder = HoodieLogFormat.newWriterBuilder() - .withFileId("test-fileid1") + builder = HoodieLogFormatWriter.builder() + .withLogFileId("test-fileid1") .withInstantTime("100") - .onParentPath(new StoragePath("/tmp")) + .withParentPath(new StoragePath("/tmp")) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) .withStorage(storage); } diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV1.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV1.java index 3142338119a92..d576087bb3f13 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV1.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedTimelineV1.java @@ -40,6 +40,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.versioning.clean.CleanPlanV2MigrationHandler; @@ -835,16 +836,23 @@ private HoodieArchivedMetaEntry createArchivedMetaWrapper(HoodieInstant hoodieIn } private Writer buildWriter(StoragePath archiveFilePath) throws IOException { - return HoodieLogFormat.newWriterBuilder().onParentPath(archiveFilePath.getParent()) - .withFileId(archiveFilePath.getName()).withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) - .withStorage(metaClient.getStorage()).withInstantTime("").build(); + return HoodieLogFormatWriter.builder() + .withParentPath(archiveFilePath.getParent()) + .withLogFileId(archiveFilePath.getName()) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + .withStorage(metaClient.getStorage()) + .withInstantTime("") + .build(); } private Writer buildWriter(StoragePath archiveFilePath, int logVersion) throws IOException { - return HoodieLogFormat.newWriterBuilder().onParentPath(archiveFilePath.getParent()) - .withFileId(archiveFilePath.getName()).withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) + return HoodieLogFormatWriter.builder().withParentPath(archiveFilePath.getParent()) + .withLogFileId(archiveFilePath.getName()) + .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) .withLogVersion(logVersion) - .withStorage(metaClient.getStorage()).withInstantTime("").build(); + .withStorage(metaClient.getStorage()) + .withInstantTime("") + .build(); } private void writeArchiveLog(Writer writer, List records) throws Exception { diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestHoodieTableFileSystemView.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestHoodieTableFileSystemView.java index ff9e303f7772a..f89033fecae61 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestHoodieTableFileSystemView.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestHoodieTableFileSystemView.java @@ -1198,7 +1198,7 @@ protected void testViewForFileSlicesWithAsyncCompaction(boolean skipCreatingData roView.getAllBaseFiles(partitionPath); fileSliceList = rtView.getLatestFileSlices(partitionPath).collect(Collectors.toList()); - log.info("FILESLICE LIST=" + fileSliceList); + log.info("FILESLICE LIST={}", fileSliceList); dataFiles = fileSliceList.stream().map(FileSlice::getBaseFile).filter(Option::isPresent).map(Option::get) .collect(Collectors.toList()); assertEquals(1, dataFiles.size(), "Expect only one data-files in latest view as there is only one file-group"); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestIncrementalFSViewSync.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestIncrementalFSViewSync.java index d01669b507f32..0e6285f274e07 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestIncrementalFSViewSync.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/view/TestIncrementalFSViewSync.java @@ -527,7 +527,7 @@ private void testCleans(SyncableFileSystemView view, List newCleanerInst final int netFilesAddedPerInstant = numFilesAddedPerInstant - numFilesReplacedPerInstant; assertEquals(newCleanerInstants.size(), cleanedInstants.size()); long exp = PARTITIONS.stream().mapToLong(p1 -> view.getAllFileSlices(p1).count()).findAny().getAsLong(); - log.info("Initial File Slices :" + exp); + log.info("Initial File Slices :{}", exp); for (int idx = 0; idx < newCleanerInstants.size(); idx++) { String instant = cleanedInstants.get(idx); try { @@ -544,8 +544,8 @@ private void testCleans(SyncableFileSystemView view, List newCleanerInst assertEquals(State.COMPLETED, view.getLastInstant().get().getState()); assertEquals(HoodieTimeline.CLEAN_ACTION, view.getLastInstant().get().getAction()); PARTITIONS.forEach(p -> { - log.info("PARTITION : " + p); - log.info("\tFileSlices :" + view.getAllFileSlices(p).collect(Collectors.toList())); + log.info("PARTITION : {}", p); + log.info("\tFileSlices :{}", view.getAllFileSlices(p).collect(Collectors.toList())); }); final int instantIdx = newCleanerInstants.size() - idx; @@ -593,7 +593,7 @@ private void testRestore(SyncableFileSystemView view, List newRestoreIns isDeltaCommit ? initialFileSlices : initialFileSlices - ((idx + 1) * (FILE_IDS_PER_PARTITION.size() - totalReplacedFileSlicesPerPartition)); view.sync(); assertTrue(view.getLastInstant().isPresent()); - log.info("Last Instant is :" + view.getLastInstant().get()); + log.info("Last Instant is :{}", view.getLastInstant().get()); if (isRestore) { assertEquals(newRestoreInstants.get(idx), view.getLastInstant().get().requestedTime()); assertEquals(HoodieTimeline.RESTORE_ACTION, view.getLastInstant().get().getAction()); @@ -629,10 +629,15 @@ private void performClean(String instant, List files, String cleanInstan throws IOException { Map> partitionToFiles = deleteFiles(files); List cleanStats = partitionToFiles.entrySet().stream().map(e -> - new HoodieCleanStat(HoodieCleaningPolicy.KEEP_LATEST_COMMITS, e.getKey(), e.getValue(), e.getValue(), - new ArrayList<>(), - instant.length() < 3 ? String.valueOf(Integer.parseInt(instant) + 1) : HoodieInstantTimeGenerator.instantTimePlusMillis(instant, 1), - "")).collect(Collectors.toList()); + HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_COMMITS) + .withPartitionPath(e.getKey()) + .withDeletePathPatterns(e.getValue()) + .withSuccessDeleteFiles(e.getValue()) + .withFailedDeleteFiles(Collections.emptyList()) + .withEarliestCommitToRetain(instant.length() < 3 ? String.valueOf(Integer.parseInt(instant) + 1) : HoodieInstantTimeGenerator.instantTimePlusMillis(instant, 1)) + .withLastCompletedCommitTimestamp("") + .build()).collect(Collectors.toList()); HoodieInstant cleanInflightInstant = INSTANT_GENERATOR.createNewInstant(State.INFLIGHT, HoodieTimeline.CLEAN_ACTION, cleanInstant); metaClient.getActiveTimeline().createNewInstant(cleanInflightInstant); @@ -876,7 +881,7 @@ private Map> testMultipleWriteSteps(SyncableFileSystemView int multiple = begin; for (int idx = 0; idx < instants.size(); idx++) { String instant = instants.get(idx); - log.info("Adding instant=" + instant); + log.info("Adding instant={}", instant); HoodieInstant lastInstant = lastInstants.get(idx); // Add a non-empty ingestion to COW table List filePaths = addInstant(metaClient, instant, deltaCommit); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java index a3602b3ff9e81..ed6a6be4105f5 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java @@ -358,12 +358,14 @@ protected static List writeLogFiles(StoragePath partitionPath, String commitTime, String logBlockInstantTime) throws IOException, InterruptedException { - HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(partitionPath) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withSizeThreshold(1024).withFileId(fileId) - .withInstantTime(commitTime) - .withStorage(storage).build(); + HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(partitionPath) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withSizeThreshold(1024L) + .withLogFileId(fileId) + .withInstantTime(commitTime) + .withStorage(storage) + .build(); if (storage.exists(writer.getLogFile().getPath())) { // enable append for reader test. ((HoodieLogFormatWriter) writer).withOutputStream( diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java index d0ec62e0abb25..46b5248e6f337 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java @@ -451,14 +451,12 @@ public HoodieTestTable addClean( public HoodieTestTable addClean(String instantTime) throws IOException { HoodieCleanerPlan cleanerPlan = new HoodieCleanerPlan(new HoodieActionInstant(EMPTY_STRING, EMPTY_STRING, EMPTY_STRING), EMPTY_STRING, EMPTY_STRING, new HashMap<>(), CleanPlanV2MigrationHandler.VERSION, new HashMap<>(), new ArrayList<>(), Collections.EMPTY_MAP); - HoodieCleanStat cleanStats = new HoodieCleanStat( - HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS, - HoodieTestUtils.DEFAULT_PARTITION_PATHS[RANDOM.nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)], - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList(), - instantTime, - ""); + HoodieCleanStat cleanStats = HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS) + .withPartitionPath(HoodieTestUtils.DEFAULT_PARTITION_PATHS[RANDOM.nextInt(HoodieTestUtils.DEFAULT_PARTITION_PATHS.length)]) + .withEarliestCommitToRetain(instantTime) + .withLastCompletedCommitTimestamp("") + .build(); HoodieCleanMetadata cleanMetadata = convertCleanMetadata(instantTime, Option.of(0L), Collections.singletonList(cleanStats), Collections.EMPTY_MAP); return HoodieTestTable.of(metaClient).addClean(instantTime, cleanerPlan, cleanMetadata); } @@ -468,8 +466,14 @@ public Pair getHoodieCleanMetadata(Strin EMPTY_STRING, EMPTY_STRING, new HashMap<>(), CleanPlanV2MigrationHandler.VERSION, new HashMap<>(), new ArrayList<>(), Collections.EMPTY_MAP); List cleanStats = new ArrayList<>(); for (Map.Entry> entry : testTableState.getPartitionToFileIdMapForCleaner(commitTime).entrySet()) { - cleanStats.add(new HoodieCleanStat(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS, - entry.getKey(), entry.getValue(), entry.getValue(), Collections.emptyList(), commitTime, "")); + cleanStats.add(HoodieCleanStat.builder() + .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_FILE_VERSIONS) + .withPartitionPath(entry.getKey()) + .withDeletePathPatterns(entry.getValue()) + .withSuccessDeleteFiles(entry.getValue()) + .withEarliestCommitToRetain(commitTime) + .withLastCompletedCommitTimestamp("") + .build()); } return Pair.of(cleanerPlan, convertCleanMetadata(commitTime, Option.of(0L), cleanStats, Collections.EMPTY_MAP)); } diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/minicluster/HdfsTestService.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/minicluster/HdfsTestService.java index 2a763b77c6769..1a1bec072d9ae 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/minicluster/HdfsTestService.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/minicluster/HdfsTestService.java @@ -63,7 +63,7 @@ public MiniDFSCluster start(boolean format) throws IOException { // If clean, then remove the work dir so we can start fresh. if (format) { - log.info("Cleaning HDFS cluster data at: " + dfsBaseDirPath + " and starting fresh."); + log.info("Cleaning HDFS cluster data at: {} and starting fresh.", dfsBaseDirPath); Files.deleteIfExists(dfsBaseDirPath); } @@ -114,7 +114,7 @@ public void stop() { private static Configuration configureDFSCluster(Configuration config, String dfsBaseDir, String bindIP, int namenodeRpcPort, int datanodePort, int datanodeIpcPort, int datanodeHttpPort) { - log.info("HDFS force binding to ip: " + bindIP); + log.info("HDFS force binding to ip: {}", bindIP); config.set(DFSConfigKeys.FS_DEFAULT_NAME_KEY, "hdfs://" + bindIP + ":" + namenodeRpcPort); config.set(DFSConfigKeys.DFS_DATANODE_ADDRESS_KEY, bindIP + ":" + datanodePort); config.set(DFSConfigKeys.DFS_DATANODE_IPC_ADDRESS_KEY, bindIP + ":" + datanodeIpcPort); diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileGroupReaderTestHarness.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileGroupReaderTestHarness.java index b7c63d3dd81cf..9259d75adb24f 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileGroupReaderTestHarness.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileGroupReaderTestHarness.java @@ -137,11 +137,14 @@ protected ClosableIterator getFileGroupIterator(int numFiles, boo properties.setProperty(HoodieMemoryConfig.SPILLABLE_MAP_BASE_PATH.key(), basePath + "/" + HoodieTableMetaClient.TEMPFOLDER_NAME); properties.setProperty(HoodieCommonConfig.SPILLABLE_DISK_MAP_TYPE.key(), ExternalSpillableMap.DiskMapType.ROCKS_DB.name()); properties.setProperty(HoodieCommonConfig.DISK_MAP_BITCASK_COMPRESSION_ENABLED.key(), "false"); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + FileSlice fileSlice = fileSliceOpt.orElseThrow(() -> new IllegalArgumentException("FileSlice is not present")); + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime("1000") // Not used internally. - .withFileSlice(fileSliceOpt.orElseThrow(() -> new IllegalArgumentException("FileSlice is not present"))) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(HOODIE_SCHEMA) .withRequestedSchema(HOODIE_SCHEMA) .withProps(properties) diff --git a/hudi-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java similarity index 98% rename from hudi-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java rename to hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java index 71e3fc6706818..d0f5bde6f41ed 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java @@ -36,6 +36,7 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCDCDataBlock; import org.apache.hudi.common.table.log.block.HoodieDataBlock; @@ -288,10 +289,10 @@ public static HoodieLogFile createLogFile( Map keyToPositionMap ) throws InterruptedException, IOException { try (HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder() - .onParentPath(new StoragePath(logFilePath).getParent()) + HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(logFilePath).getParent()) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId(fileId) + .withLogFileId(fileId) .withInstantTime(logInstantTime) .withLogVersion(version) .withStorage(storage).build()) { diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestClusteringUtils.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestClusteringUtils.java index 6d580e3b5c564..25490d3971ddc 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestClusteringUtils.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestClusteringUtils.java @@ -124,10 +124,53 @@ public void testClusteringPlanMultipleInstants() throws Exception { //now that it is complete, the first instant should be picked HoodieInstant complete = metaClient.getActiveTimeline().transitionClusterInflightToComplete(false, inflight, new HoodieReplaceCommitMetadata()); assertEquals(HoodieInstant.State.COMPLETED, complete.getState()); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, complete.getAction()); lastPendingClustering = metaClient.reloadActiveTimeline().getLastPendingClusterInstant(); assertEquals("1", lastPendingClustering.get().requestedTime()); } + /** + * A clustering commit completes as a {@code replacecommit} whichever action its inflight instant + * carried, {@code clustering} on table version 8+ or {@code replacecommit} before that. + */ + @Test + public void testClusteringAndReplaceInflightBothCompleteAsReplaceCommit() throws Exception { + List fileIds = new ArrayList<>(); + fileIds.add(UUID.randomUUID().toString()); + List completed = new ArrayList<>(); + + HoodieInstant clusterRequested = createRequestedClusterInstant("partition1", "1", fileIds); + HoodieInstant clusterInflight = metaClient.getActiveTimeline() + .transitionClusterRequestedToInflight(clusterRequested, Option.empty()); + assertEquals(HoodieTimeline.CLUSTERING_ACTION, clusterInflight.getAction()); + ClusteringUtils.transitionClusteringOrReplaceInflightToComplete( + false, clusterInflight, new HoodieReplaceCommitMetadata(), metaClient.getActiveTimeline(), + completed::add); + assertEquals(1, completed.size()); + assertEquals(HoodieInstant.State.COMPLETED, completed.get(0).getState()); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, completed.get(0).getAction(), + "a clustering inflight instant must complete as replacecommit"); + + completed.clear(); + HoodieInstant replaceRequested = createRequestedReplaceInstantNotClustering("2"); + HoodieInstant replaceInflight = metaClient.getActiveTimeline() + .transitionReplaceRequestedToInflight(replaceRequested, Option.empty()); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, replaceInflight.getAction()); + ClusteringUtils.transitionClusteringOrReplaceInflightToComplete( + false, replaceInflight, new HoodieReplaceCommitMetadata(), metaClient.getActiveTimeline(), + completed::add); + assertEquals(1, completed.size()); + assertEquals(HoodieInstant.State.COMPLETED, completed.get(0).getState()); + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, completed.get(0).getAction(), + "a replacecommit inflight instant must complete as replacecommit"); + + List completedInstants = + metaClient.reloadActiveTimeline().filterCompletedInstants().getInstants(); + assertEquals(2, completedInstants.size()); + completedInstants.forEach(instant -> + assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, instant.getAction())); + } + // replacecommit.inflight doesn't have clustering plan. // Verify that getClusteringPlan fetches content from corresponding requested file. @Disabled("Will fail due to avro issue AVRO-3789. This is fixed in avro 1.11.3") diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestDFSPropertiesConfiguration.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestDFSPropertiesConfiguration.java index 9e5e45a00778d..fd2b550259858 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestDFSPropertiesConfiguration.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestDFSPropertiesConfiguration.java @@ -271,4 +271,87 @@ public void testClassInitializationNeverThrows() { TypedProperties props = cfg.getProps(); assertEquals(5, props.size()); } + + @Test + public void testIncludeNonExistentFile() throws IOException { + // Create a properties file that includes a non-existent file + Path filePath = new Path(dfsBasePath + "/t5.props"); + writePropertiesFile(filePath, new String[] { + "existing.prop=value1", + "include=" + dfsBasePath + "/non-existent-file.props", + "another.prop=value2" + }); + + // Should not throw an exception, but log a warning and continue + DFSPropertiesConfiguration cfg = new DFSPropertiesConfiguration(dfs.getConf(), + new StoragePath(filePath.toUri())); + TypedProperties props = cfg.getProps(); + + // Properties before and after the non-existent include should still be loaded + assertEquals(2, props.size()); + assertEquals("value1", props.getString("existing.prop")); + assertEquals("value2", props.getString("another.prop")); + } + + @Test + public void testIncludeNonExistentRelativeFile() throws IOException { + // Create a properties file that includes a non-existent relative file + Path filePath = new Path(dfsBasePath + "/t6.props"); + writePropertiesFile(filePath, new String[] { + "prop1=val1", + "include=non-existent-relative.props", + "prop2=val2" + }); + + // Should not throw an exception for non-existent relative includes + DFSPropertiesConfiguration cfg = new DFSPropertiesConfiguration(dfs.getConf(), + new StoragePath(filePath.toUri())); + TypedProperties props = cfg.getProps(); + + // Properties before and after the non-existent include should still be loaded + assertEquals(2, props.size()); + assertEquals("val1", props.getString("prop1")); + assertEquals("val2", props.getString("prop2")); + } + + @Test + public void testMixedExistentAndNonExistentIncludes() throws IOException { + // Create a properties file with both existent and non-existent includes + Path filePath = new Path(dfsBasePath + "/t7.props"); + writePropertiesFile(filePath, new String[] { + "base.prop=base_value", + "include=" + dfsBasePath + "/non-existent-1.props", + "include=" + dfsBasePath + "/t1.props", // This exists + "include=" + dfsBasePath + "/non-existent-2.props", + "override.prop=override_value" + }); + + // Should load successfully, ignoring non-existent files + DFSPropertiesConfiguration cfg = new DFSPropertiesConfiguration(dfs.getConf(), + new StoragePath(filePath.toUri())); + TypedProperties props = cfg.getProps(); + + // Should have properties from t1.props and the main file + assertEquals("base_value", props.getString("base.prop")); + assertEquals("override_value", props.getString("override.prop")); + assertEquals(123, props.getInteger("int.prop")); // From t1.props + assertEquals("str", props.getString("string.prop")); // From t1.props + assertTrue(props.getBoolean("boolean.prop")); // From t1.props + } + + @Test + public void testExplicitMissingPropertiesFileThrows() { + // Explicit user-supplied paths (e.g. --props /typo.props) should fail fast rather than + // silently load empty properties. Only include= recursion and optional global-defaults + // paths are tolerated. + StoragePath missingPath = new StoragePath(dfsBasePath + "/this-file-does-not-exist.props"); + assertThrows(HoodieIOException.class, + () -> new DFSPropertiesConfiguration(dfs.getConf(), missingPath), + "Constructor should throw when the explicit properties file is missing"); + + DFSPropertiesConfiguration cfg = new DFSPropertiesConfiguration(); + assertThrows(HoodieIOException.class, + () -> cfg.addPropsFromFile(missingPath), + "Public addPropsFromFile should throw when the explicit properties file is missing"); + } } diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/hadoop/fs/TestHadoopFSUtils.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/hadoop/fs/TestHadoopFSUtils.java index 7768ff4feae7f..da4e9a7500f42 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/hadoop/fs/TestHadoopFSUtils.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/hadoop/fs/TestHadoopFSUtils.java @@ -19,25 +19,188 @@ package org.apache.hudi.hadoop.fs; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import org.apache.hudi.storage.hadoop.HoodieHadoopStorage; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FSDataInputStream; import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.FilterFileSystem; +import org.apache.hadoop.fs.LocalFileSystem; import org.apache.hadoop.fs.Path; +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.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; + import static org.apache.hudi.hadoop.fs.HadoopFSUtils.convertToHadoopFileStatus; import static org.apache.hudi.hadoop.fs.HadoopFSUtils.convertToHadoopPath; import static org.apache.hudi.hadoop.fs.HadoopFSUtils.convertToStoragePath; import static org.apache.hudi.hadoop.fs.HadoopFSUtils.convertToStoragePathInfo; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests {@link HadoopFSUtils} */ public class TestHadoopFSUtils { + /** + * HUDI-4602: {@link FileSystem#getScheme()} is optional in Hadoop -- the base implementation throws + * {@link UnsupportedOperationException} -- and proxy implementations such as Presto's + * {@code PrestoS3FileSystem} do not override it. Opening a log file went straight through + * {@code isGCSFileSystem}, so a MOR {@code _rt} query on Presto failed with + * "Not implemented by the PrestoS3FileSystem FileSystem implementation" rather than reading anything. + * + *

    {@link FilterFileSystem} has the same shape: it leaves {@code getScheme()} to the throwing base + * implementation while overriding {@code getUri()}. + */ + @Test + public void testGetFSDataInputStreamWhenGetSchemeIsUnimplemented(@TempDir java.nio.file.Path tempDir) throws IOException { + java.nio.file.Path file = tempDir.resolve("log.file"); + byte[] contents = new byte[] {1, 2, 3, 4}; + Files.write(file, contents); + // newInstanceLocal rather than getLocal, so closing this does not evict a cached FileSystem that + // other tests in the same JVM share. + try (FileSystem fs = newFsWithoutGetScheme(FileSystem.newInstanceLocal(new Configuration()))) { + try (FSDataInputStream stream = + HadoopFSUtils.getFSDataInputStream(fs, new StoragePath(file.toUri()), 1024, true)) { + byte[] read = new byte[contents.length]; + stream.readFully(read); + assertArrayEquals(contents, read, "The read path should not depend on the optional getScheme()"); + } + } + } + + @Test + public void testGetSchemeFallsBackToTheUriWhenUnimplemented() throws IOException { + try (FileSystem localFs = FileSystem.newInstanceLocal(new Configuration())) { + assertEquals("file", HadoopFSUtils.getScheme(localFs), + "LocalFileSystem overrides getScheme(), so the helper should return what it reports " + + "rather than falling back to getUri()"); + + // FilterFileSystem#close closes the delegate, so the wrapper is not given its own block: it owns + // nothing, and closing it here would close localFs a second time. + FileSystem noScheme = newFsWithoutGetScheme(localFs); + assertEquals("file", HadoopFSUtils.getScheme(noScheme), + "FilterFileSystem does not override getScheme(), so the helper should fall back to " + + "getUri().getScheme()"); + } + } + + /** + * A URI with no scheme cannot stand in for an unimplemented {@code getScheme()}. {@code InLineFileSystem} + * is the case in this module: {@code getScheme()} returns "inlinefs" while {@code getUri()} is + * {@code URI.create("inlinefs")}, which has no colon and so no scheme. Returning null there would surface + * far away as "does not support scheme null" with the original failure discarded, so it must fail here. + */ + @Test + public void testGetSchemeFailsLoudlyWhenNeitherSourceHasOne() throws IOException { + try (FileSystem localFs = FileSystem.newInstanceLocal(new Configuration())) { + FileSystem schemeless = new NoSchemeFileSystem(localFs, URI.create("inlinefs")); + + HoodieException thrown = + assertThrows(HoodieException.class, () -> HadoopFSUtils.getScheme(schemeless)); + assertTrue(thrown.getMessage().contains("carries no scheme"), + () -> "the failure should say the URI carries no scheme, but was: " + thrown.getMessage()); + assertInstanceOf(UnsupportedOperationException.class, thrown.getCause(), + "the original getScheme() failure must be chained rather than discarded"); + } + } + + /** + * The three call sites this rerouted that no test in the repo reached: {@code registerFileSystem}, + * {@code HoodieWrapperFileSystem#convertToHoodiePath} - which is on the write path, via + * {@code HoodieBaseParquetWriter} and friends - and {@code HoodieHadoopStorage#getScheme}. All three threw + * {@link UnsupportedOperationException} on a filesystem without {@code getScheme()} before this change. + */ + @Test + public void testCallSitesWorkOnAFileSystemWithoutGetScheme(@TempDir java.nio.file.Path tempDir) { + Configuration conf = new Configuration(); + conf.setClass("fs.file.impl", NoSchemeLocalFileSystem.class, FileSystem.class); + StoragePath path = new StoragePath(tempDir.toUri()); + + assertDoesNotThrow(() -> HadoopFSUtils.registerFileSystem(path, conf), + "registerFileSystem resolves the scheme to build the fs..impl key"); + assertDoesNotThrow(() -> HoodieWrapperFileSystem.convertToHoodiePath(path, conf), + "convertToHoodiePath is on the write path and resolves the scheme to rewrite it"); + assertEquals("file", new HoodieHadoopStorage(path, HadoopFSUtils.getStorageConf(conf)).getScheme(), + "HoodieHadoopStorage#getScheme is what HoodieStorage callers reach"); + } + + /** + * {@code isGCSFileSystem} and {@code isCHDFileSystem} become reachable for a filesystem without + * {@code getScheme()} for the first time with this change, and they select different stream wrappers. + * Neither predicate had a test before. + */ + @ParameterizedTest + @CsvSource({ + "gs://bucket, org.apache.hudi.hadoop.fs.SchemeAwareFSDataInputStream", + "ofs://cluster, org.apache.hudi.hadoop.fs.BoundedFsDataInputStream" + }) + public void testSchemeSpecificStreamIsSelectedWithoutGetScheme(String uri, String expectedStream, + @TempDir java.nio.file.Path tempDir) throws IOException { + java.nio.file.Path file = tempDir.resolve("log.file"); + Files.write(file, new byte[] {1, 2, 3, 4}); + try (FileSystem localFs = FileSystem.newInstanceLocal(new Configuration())) { + // Reports a gs:// or ofs:// URI while leaving getScheme() to the throwing base implementation. + FileSystem fs = new NoSchemeFileSystem(localFs, URI.create(uri)); + assertThrows(UnsupportedOperationException.class, fs::getScheme); + + try (FSDataInputStream stream = + HadoopFSUtils.getFSDataInputStream(fs, new StoragePath(file.toUri()), 1024, true)) { + assertEquals(expectedStream, stream.getClass().getName(), + "the scheme-specific wrapper should be selected from the fallback-resolved scheme"); + } + } + } + + /** A FileSystem with the reported shape: {@code getUri()} works, {@code getScheme()} throws. */ + private static FileSystem newFsWithoutGetScheme(FileSystem delegate) { + FileSystem fs = new FilterFileSystem(delegate); + // The premise of every assertion below: this is the call the read path used to make unguarded. + assertThrows(UnsupportedOperationException.class, fs::getScheme); + return fs; + } + + /** Same shape, but reporting a URI of our choosing so scheme-specific branches can be reached. */ + private static class NoSchemeFileSystem extends FilterFileSystem { + private final URI uri; + + NoSchemeFileSystem(FileSystem delegate, URI uri) { + super(delegate); + this.uri = uri; + } + + @Override + public URI getUri() { + return uri; + } + } + + /** + * A {@link LocalFileSystem} that does not implement {@code getScheme()}, so it can be registered as + * {@code fs.file.impl} and reached through the normal {@code FileSystem.get} path. + */ + public static class NoSchemeLocalFileSystem extends LocalFileSystem { + @Override + public String getScheme() { + throw new UnsupportedOperationException( + "Not implemented by the NoSchemeLocalFileSystem FileSystem implementation"); + } + } + @ParameterizedTest @ValueSource(strings = { "/a/b/c", diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestBaseTableMetadata.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestBaseTableMetadata.java new file mode 100644 index 0000000000000..94a3ffe8dd9a2 --- /dev/null +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestBaseTableMetadata.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.hudi.metadata; + +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.data.HoodieListData; +import org.apache.hudi.common.data.HoodieListPairData; +import org.apache.hudi.common.data.HoodiePairData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.expression.Expression; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordGlobalLocation; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieMetadataException; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StorageConfiguration; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestBaseTableMetadata { + + @TempDir + Path tempDir; + + private HoodieTableMetaClient metaClient; + private String basePath; + + @BeforeEach + void setUp() throws Exception { + basePath = tempDir.toString(); + metaClient = HoodieTestUtils.init(basePath); + } + + @Test + void testReadFailuresAreWrappedWithMetadataContext() { + TestingTableMetadata metadata = newMetadata(); + metadata.failSingleReads = true; + assertThrows(HoodieMetadataException.class, metadata::getAllPartitionPaths); + + metadata.failSingleReads = false; + metadata.failBulkReads = true; + assertThrows(HoodieMetadataException.class, + () -> metadata.getAllFilesInPartitions(Collections.singletonList(basePath))); + } + + @Test + void testDisabledIndexesAndEmptyBloomFilterLookup() { + TestingTableMetadata metadata = newMetadata(); + assertFalse(metadata.getBloomFilter("", "file.parquet", MetadataPartitionType.BLOOM_FILTERS.getPartitionPath()).isPresent()); + assertTrue(metadata.getBloomFilters( + Collections.singletonList(Pair.of("", "file.parquet")), + MetadataPartitionType.BLOOM_FILTERS.getPartitionPath()).isEmpty()); + assertTrue(metadata.getColumnStats( + Collections.singletonList(Pair.of("", "file.parquet")), + Collections.singletonList("column")).isEmpty()); + + metaClient.getTableConfig().setMetadataPartitionState( + metaClient, MetadataPartitionType.BLOOM_FILTERS.getPartitionPath(), true); + metadata = newMetadata(); + assertFalse(metadata.getBloomFilter( + "", "file.parquet", MetadataPartitionType.BLOOM_FILTERS.getPartitionPath()).isPresent()); + assertTrue(metadata.getBloomFilters( + Collections.emptyList(), MetadataPartitionType.BLOOM_FILTERS.getPartitionPath()).isEmpty()); + } + + @Test + void testPayloadFailuresAndMissingColumnStats() { + HoodieMetadataPayload payload = new HoodieMetadataPayload( + "bad", MetadataPartitionType.FILES.getRecordType(), Collections.emptyMap()) { + @Override + public List getFileList( + HoodieStorage storage, StoragePath partitionPath) { + throw new HoodieException("corrupt payload"); + } + }; + + TestingTableMetadata firstMetadata = newMetadata(); + firstMetadata.singleRecord = Option.of(payload); + assertThrows(HoodieException.class, + () -> firstMetadata.getAllFilesInPartition(new StoragePath(basePath))); + + HoodieMetadataPayload inconsistentPayload = + HoodieMetadataPayload.createPartitionListRecord( + Collections.singletonList("ghost"), true).getData(); + firstMetadata.singleRecord = Option.of(inconsistentPayload); + assertThrows(HoodieMetadataException.class, firstMetadata::getAllPartitionPaths); + + metaClient.getTableConfig().setMetadataPartitionState( + metaClient, MetadataPartitionType.COLUMN_STATS.getPartitionPath(), true); + TestingTableMetadata metadata = newMetadata(); + HoodieMetadataPayload missingColumnStats = + HoodieMetadataPayload.createPartitionFilesRecord( + "", Collections.singletonMap("file.parquet", 1L), + Collections.emptyList()).getData(); + metadata.pairRecords = HoodieListPairData.eager( + Collections.singletonList(Pair.of("missing-key", missingColumnStats))); + assertTrue(metadata.getColumnStats( + Collections.singletonList(Pair.of("", "file.parquet")), + Collections.singletonList("column")).isEmpty()); + } + + @Test + void testProtectedReadContextAccessors() { + TestingTableMetadata metadata = newMetadata(); + assertNotNull(metadata.storageConfiguration()); + assertEquals("00000000000000", metadata.latestDataInstant()); + } + + @Test + void testHoodieBackedMetadataStaysDisabledWithoutMetadataTable() { + HoodieBackedTableMetadata metadata = new HoodieBackedTableMetadata( + null, + metaClient.getStorage(), + HoodieMetadataConfig.newBuilder().enable(false).build(), + basePath); + + assertFalse(metadata.isMetadataTableInitialized()); + assertFalse(metadata.getSyncedInstantTime().isPresent()); + assertFalse(metadata.getLatestCompactionTime().isPresent()); + metadata.close(); + } + + private TestingTableMetadata newMetadata() { + return new TestingTableMetadata( + null, metaClient.getStorage(), + HoodieMetadataConfig.newBuilder() + .enable(true) + .ignoreSpuriousDeletes(false) + .build(), + basePath); + } + + private static class TestingTableMetadata extends BaseTableMetadata { + private boolean failSingleReads; + private boolean failBulkReads; + private Option singleRecord = Option.empty(); + private HoodiePairData pairRecords = + HoodieListPairData.eager(Collections.emptyList()); + + TestingTableMetadata(HoodieEngineContext engineContext, + HoodieStorage storage, + HoodieMetadataConfig metadataConfig, + String dataBasePath) { + super(engineContext, storage, metadataConfig, dataBasePath); + isMetadataTableInitialized = true; + } + + @Override + protected Option readFilesIndexRecords(String key, String partitionName) { + if (failSingleReads) { + throw new HoodieException("single read failed"); + } + return singleRecord; + } + + @Override + public List getPartitionPathWithPathPrefixUsingFilterExpression( + List relativePathPrefixes, + Types.RecordType partitionFields, + Expression expression) { + return Collections.emptyList(); + } + + @Override + public List getPartitionPathWithPathPrefixes(List relativePathPrefixes) { + return Collections.emptyList(); + } + + @Override + public HoodiePairData readIndexRecordsWithKeys( + HoodieData rawKeys, String partitionName) { + if (failBulkReads) { + throw new HoodieException("bulk read failed"); + } + return pairRecords; + } + + @Override + protected HoodiePairData readIndexRecordsWithKeys( + HoodieData rawKeys, + String partitionName, + Option dataTablePartition) { + return readIndexRecordsWithKeys(rawKeys, partitionName); + } + + @Override + public HoodiePairData readSecondaryIndexDataTableRecordKeysWithKeys( + HoodieData keys, String partitionName) { + return HoodieListPairData.eager(Collections.emptyList()); + } + + @Override + public HoodiePairData readSecondaryIndexLocationsWithKeys( + HoodieData secondaryKeys, String partitionName) { + return HoodieListPairData.eager(Collections.emptyList()); + } + + @Override + public HoodiePairData readRecordIndexLocationsWithKeys( + HoodieData recordKeys) { + return HoodieListPairData.eager(Collections.emptyList()); + } + + @Override + public HoodiePairData readRecordIndexLocationsWithKeys( + HoodieData recordKeys, Option dataTablePartition) { + return HoodieListPairData.eager(Collections.emptyList()); + } + + @Override + public HoodieData> getRecordsByKeyPrefixes( + HoodieData rawKeys, + String partitionName, + boolean shouldLoadInMemory) { + return HoodieListData.eager(Collections.emptyList()); + } + + @Override + public Map, List> listPartitions( + List> partitionPathList) { + return Collections.emptyMap(); + } + + @Override + public Option getSyncedInstantTime() { + return Option.empty(); + } + + @Override + public Option getLatestCompactionTime() { + return Option.empty(); + } + + @Override + public void reset() { + } + + @Override + public void close() { + } + + @Override + public int getNumFileGroupsForPartition(MetadataPartitionType partition) { + return 0; + } + + @Override + public Map> getBucketizedFileGroupsForPartitionedRLI( + MetadataPartitionType partition) { + return Collections.emptyMap(); + } + + StorageConfiguration storageConfiguration() { + return getStorageConf(); + } + + String latestDataInstant() { + return getLatestDataInstantTime(); + } + } +} diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestFileSystemBackedTableMetadata.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestFileSystemBackedTableMetadata.java index 48a2851c0924d..30a26b65c9c9b 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestFileSystemBackedTableMetadata.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestFileSystemBackedTableMetadata.java @@ -18,10 +18,13 @@ package org.apache.hudi.metadata; +import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.engine.HoodieLocalEngineContext; import org.apache.hudi.common.testutils.HoodieCommonTestHarness; import org.apache.hudi.common.testutils.HoodieTestTable; +import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieMetadataException; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; @@ -256,4 +259,37 @@ public void testMultiLevelEmptyPartitionTable() throws Exception { } } + @Test + public void testMetadataIndexOperationsAreUnsupported() { + HoodieLocalEngineContext localEngineContext = + new HoodieLocalEngineContext(metaClient.getStorageConf()); + FileSystemBackedTableMetadata metadata = new FileSystemBackedTableMetadata( + localEngineContext, metaClient.getTableConfig(), metaClient.getStorage(), basePath); + + Assertions.assertThrows(UnsupportedOperationException.class, metadata::getSyncedInstantTime); + Assertions.assertThrows(UnsupportedOperationException.class, metadata::getLatestCompactionTime); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getBloomFilter("", "file.parquet", MetadataPartitionType.BLOOM_FILTERS.getPartitionPath())); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getBloomFilters(Collections.emptyList(), MetadataPartitionType.BLOOM_FILTERS.getPartitionPath())); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getColumnStats(Collections.emptyList(), "column")); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getColumnStats(Collections.emptyList(), Collections.singletonList("column"))); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getRecordsByKeyPrefixes( + HoodieListData.eager(Collections.emptyList()), MetadataPartitionType.FILES.getPartitionPath(), false)); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.readRecordIndexLocationsWithKeys(HoodieListData.eager(Collections.emptyList()))); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.readRecordIndexLocationsWithKeys(HoodieListData.eager(Collections.emptyList()), Option.empty())); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.readSecondaryIndexLocationsWithKeys( + HoodieListData.eager(Collections.emptyList()), MetadataPartitionType.SECONDARY_INDEX.getPartitionPath())); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getNumFileGroupsForPartition(MetadataPartitionType.FILES)); + Assertions.assertThrows(HoodieMetadataException.class, + () -> metadata.getBucketizedFileGroupsForPartitionedRLI(MetadataPartitionType.RECORD_INDEX)); + } + } diff --git a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java index f928b46b7c9c2..204467f9d21fc 100644 --- a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java +++ b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java @@ -18,17 +18,24 @@ package org.apache.hudi.metadata; +import org.apache.hudi.avro.model.HoodieMetadataRecord; import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.testutils.HoodieCommonTestHarness; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieMetadataException; import org.apache.hudi.stats.HoodieColumnRangeMetadata; import org.apache.hudi.stats.ValueMetadata; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -325,6 +332,62 @@ public void testSecondaryIndexPayloadMerging() { assertEquals(newSecondaryIndexRecord.getData(), combinedSecondaryIndexRecord.get().getData()); } + @Test + public void testPayloadAccessorsAndObjectMethods() { + HoodieMetadataPayload emptyPayload = new HoodieMetadataPayload(Option.empty()); + assertFalse(emptyPayload.getBloomFilterMetadata().isPresent()); + assertFalse(emptyPayload.getColumnStatMetadata().isPresent()); + assertFalse(emptyPayload.equals("not-a-payload")); + emptyPayload.hashCode(); + + HoodieMetadataPayload secondaryIndexPayload = HoodieMetadataPayload.createSecondaryIndexRecord( + "record-key", "secondary-key", MetadataPartitionType.SECONDARY_INDEX.getPartitionPath() + "test", true).getData(); + assertTrue(secondaryIndexPayload.isSecondaryIndexDeleted()); + } + + @Test + public void testInvalidRecordIndexInputs() { + assertThrows(HoodieMetadataException.class, + () -> HoodieMetadataPayload.parseRecordIndexInstantTime("not-an-instant")); + assertThrows(HoodieMetadataException.class, + () -> HoodieMetadataPayload.createRecordIndexUpdate( + "record-key", PARTITION_NAME, "not-a-uuid", "20240101000000000", 0)); + } + + @Test + public void testProjectedInsertValueIncludesBloomFilter() throws IOException { + HoodieMetadataPayload bloomFilterPayload = HoodieMetadataPayload.createBloomFilterMetadataRecord( + PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet", "20240101000000000", "SIMPLE", + ByteBuffer.wrap("bloom-data".getBytes()), false).getData(); + Schema projectedSchema = HoodieSchemaUtils.addMetadataFields( + HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())).toAvroSchema(); + + IndexedRecord projectedRecord = bloomFilterPayload.getInsertValue(projectedSchema).get(); + + assertEquals(bloomFilterPayload.getBloomFilterMetadata().get(), + ((GenericRecord) projectedRecord).get("BloomFilterMetadata")); + } + + @Test + public void testPayloadToStringForIndexedRecordTypes() { + HoodieMetadataPayload filesPayload = HoodieMetadataPayload.createPartitionFilesRecord( + PARTITION_NAME, Collections.singletonMap("file.parquet", 10L), Collections.singletonList("old.parquet")).getData(); + assertTrue(filesPayload.toString().contains("creations=[file.parquet]")); + assertTrue(filesPayload.toString().contains("deletions=[old.parquet]")); + + HoodieMetadataPayload bloomFilterPayload = HoodieMetadataPayload.createBloomFilterMetadataRecord( + PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet", "20240101000000000", "SIMPLE", + ByteBuffer.wrap("bloom-data".getBytes()), false).getData(); + assertTrue(bloomFilterPayload.toString().contains("BloomFilter")); + + HoodieColumnRangeMetadata columnRange = HoodieColumnRangeMetadata.create( + "file.parquet", "column", 1, 2, 0, 2, 10, 10, ValueMetadata.V1EmptyMetadata.get()); + HoodieMetadataPayload columnStatsPayload = + (HoodieMetadataPayload) HoodieMetadataPayload.createColumnStatsRecords( + PARTITION_NAME, Collections.singletonList(columnRange), false).findFirst().get().getData(); + assertTrue(columnStatsPayload.toString().contains("ColStats")); + } + @Test public void testConstructSecondaryIndexKey() { // Simple case diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/BootstrapColumnStichingRecordReader.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/BootstrapColumnStichingRecordReader.java index 7f9aa77648f94..5c24766d3868f 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/BootstrapColumnStichingRecordReader.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/BootstrapColumnStichingRecordReader.java @@ -60,7 +60,7 @@ public BootstrapColumnStichingRecordReader(RecordReadernewBuilder() + this.recordIterator = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(latestCommitTime) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) .withDataSchema(tableSchema) .withRequestedSchema(requestedSchema) .withProps(props) diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java index f3911faeaf8f6..ee650978238c0 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java @@ -208,7 +208,7 @@ private RecordReader createBootstrappingRecordReade List> colNamesWithTypesForExternal = colNameWithTypes.stream() .filter(p -> !HoodieRecord.HOODIE_META_COLUMNS.contains(p.getKey())).collect(Collectors.toList()); - LOG.info("colNameWithTypes =" + colNameWithTypes + ", Num Entries =" + colNameWithTypes.size()); + LOG.info("colNameWithTypes ={}, Num Entries ={}", colNameWithTypes, colNameWithTypes.size()); if (hoodieColsProjected.isEmpty()) { return getRecordReaderInternal(eSplit.getBootstrapFileSplit(), job, reporter); @@ -225,7 +225,7 @@ private RecordReader createBootstrappingRecordReade jobConfCopy.unset(TableScanDesc.FILTER_EXPR_CONF_STR); jobConfCopy.unset(ConvertAstToSearchArg.SARG_PUSHDOWN); - LOG.info("Generating column stitching reader for " + eSplit.getPath() + " and " + rightSplit.getPath()); + LOG.info("Generating column stitching reader for {} and {}", eSplit.getPath(), rightSplit.getPath()); return new BootstrapColumnStichingRecordReader(getRecordReaderInternal(eSplit, jobConfCopy, reporter), HoodieRecord.HOODIE_META_COLUMNS.size(), getRecordReaderInternal(rightSplit, jobConfCopy, reporter), diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/InputPathHandler.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/InputPathHandler.java index 88e96d29e1be2..09f6f4bbabdb4 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/InputPathHandler.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/InputPathHandler.java @@ -114,7 +114,7 @@ private void parseInputPaths(Path[] inputPaths, List incrementalTables) tagAsIncrementalOrSnapshot(inputPath, metaClient, incrementalTables); } catch (TableNotFoundException | InvalidTableException e) { // This is a non Hoodie inputPath - LOG.info("Handling a non-hoodie path " + inputPath); + LOG.info("Handling a non-hoodie path {}", inputPath); nonHoodieInputPaths.add(inputPath); } } diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/hive/HoodieCombineHiveInputFormat.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/hive/HoodieCombineHiveInputFormat.java index 9634b7f6b097c..c726bda2e69a3 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/hive/HoodieCombineHiveInputFormat.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/hive/HoodieCombineHiveInputFormat.java @@ -174,7 +174,7 @@ private InputSplit[] getCombineSplits(JobConf job, int numSplits, Map inputFormatClass = part.getInputFileFormatClass(); String inputFormatClassName = inputFormatClass.getName(); InputFormat inputFormat = getInputFormatFromCache(inputFormatClass, job); - LOG.info("Input Format => " + inputFormatClass.getName()); + LOG.info("Input Format => {}", inputFormatClass.getName()); // **MOD** Set the hoodie filter in the combine if (inputFormatClass.getName().equals(getParquetInputFormatClassName())) { combine.setHoodieFilter(true); @@ -186,7 +186,7 @@ private InputSplit[] getCombineSplits(JobConf job, int numSplits, Map partitions = new ArrayList<>(part.getPartSpec().keySet()); if (!partitions.isEmpty()) { String partitionStr = String.join("/", partitions); - LOG.info("Setting Partitions in jobConf - Partition Keys for Path : " + path + " is :" + partitionStr); + LOG.info("Setting Partitions in jobConf - Partition Keys for Path : {} is :{}", path, partitionStr); job.set(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, partitionStr); } else { job.set(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, ""); @@ -224,11 +224,11 @@ private InputSplit[] getCombineSplits(JobConf job, int numSplits, Map getNonCombinablePathIndices(JobConf job, Path[] paths, int numThreads) throws ExecutionException, InterruptedException { - LOG.info("Total number of paths: " + paths.length + ", launching " + numThreads - + " threads to check non-combinable ones."); + LOG.info("Total number of paths: {}, launching {} threads to check non-combinable ones.", paths.length, numThreads); int numPathPerThread = (int) Math.ceil((double) paths.length / numThreads); ExecutorService executor = Executors.newFixedThreadPool(numThreads); @@ -559,7 +558,7 @@ private List sampleSplits(List splits) { retLists.add(split); long splitgLength = split.getLength(); if (size + splitgLength >= targetSize) { - LOG.info("Sample alias " + entry.getValue() + " using " + (i + 1) + "splits"); + LOG.info("Sample alias {} using {}splits", entry.getValue(), (i + 1)); if (size + splitgLength > targetSize) { ((InputSplitShim) split).shrinkSplit(targetSize - size); } @@ -963,8 +962,7 @@ public CombineFileSplit[] getSplits(JobConf job, int numSplits) throws IOExcepti if (job.getLong(org.apache.hadoop.mapreduce.lib.input.FileInputFormat.SPLIT_MAXSIZE, 0L) == 0L) { super.setMaxSplitSize(minSize); } - LOG.info("mapreduce.input.fileinputformat.split.minsize=" + minSize - + ", mapreduce.input.fileinputformat.split.maxsize=" + maxSize); + LOG.info("mapreduce.input.fileinputformat.split.minsize={}, mapreduce.input.fileinputformat.split.maxsize={}", minSize, maxSize); if (isRealTime) { job.set("hudi.hive.realtime", "true"); diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/AbstractRealtimeRecordReader.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/AbstractRealtimeRecordReader.java index 913e670095273..ce58db552e23d 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/AbstractRealtimeRecordReader.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/AbstractRealtimeRecordReader.java @@ -94,9 +94,9 @@ public abstract class AbstractRealtimeRecordReader { public AbstractRealtimeRecordReader(RealtimeSplit split, JobConf job) { this.split = split; this.jobConf = job; - LOG.info("cfg ==> " + job.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR)); - LOG.info("columnIds ==> " + job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); - LOG.info("partitioningColumns ==> " + job.get(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, "")); + LOG.info("cfg ==> {}", job.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR)); + LOG.info("columnIds ==> {}", job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); + LOG.info("partitioningColumns ==> {}", job.get(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, "")); this.supportPayload = Boolean.parseBoolean(job.get("hoodie.support.payload", "true")); try { metaClient = HoodieTableMetaClient.builder() @@ -106,7 +106,7 @@ public AbstractRealtimeRecordReader(RealtimeSplit split, JobConf job) { this.payloadProps.setProperty(HoodiePayloadProps.PAYLOAD_ORDERING_FIELD_PROP_KEY, metaClient.getTableConfig().getOrderingFieldsStr().orElse(null)); } this.usesCustomPayload = usesCustomPayload(metaClient); - LOG.info("usesCustomPayload ==> " + this.usesCustomPayload); + LOG.info("usesCustomPayload ==> {}", this.usesCustomPayload); // get timestamp columns supportTimestamp = HoodieColumnProjectionUtils.supportTimestamp(jobConf); @@ -190,7 +190,7 @@ private void init() throws Exception { public HoodieSchema constructHiveOrderedSchema(HoodieSchema writerSchema, Map schemaFieldsMap, String hiveColumnString) { String[] hiveColumns = hiveColumnString.isEmpty() ? new String[0] : hiveColumnString.split(","); - LOG.info("Hive Columns : " + hiveColumnString); + LOG.info("Hive Columns : {}", hiveColumnString); List hiveSchemaFields = new ArrayList<>(); for (String columnName : hiveColumns) { diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieHFileRealtimeInputFormat.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieHFileRealtimeInputFormat.java index c7655abbbf3d0..ef12dee0ad068 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieHFileRealtimeInputFormat.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieHFileRealtimeInputFormat.java @@ -62,9 +62,7 @@ public RecordReader getRecordReader(final InputSpli // actual heavy lifting of reading the parquet files happen. if (jobConf.get(HoodieInputFormatUtils.HOODIE_READ_COLUMNS_PROP) == null) { synchronized (jobConf) { - LOG.info( - "Before adding Hoodie columns, Projections :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR) - + ", Ids :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); + LOG.info("Before adding Hoodie columns, Projections :{}, Ids :{}", jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR), jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); if (jobConf.get(HoodieInputFormatUtils.HOODIE_READ_COLUMNS_PROP) == null) { // Hive (across all versions) fails for queries like select count(`_hoodie_commit_time`) from table; // In this case, the projection fields gets removed. Looking at HiveInputFormat implementation, in some cases @@ -82,8 +80,7 @@ public RecordReader getRecordReader(final InputSpli } HoodieRealtimeInputFormatUtils.cleanProjectionColumnIds(jobConf); - LOG.info("Creating record reader with readCols :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR) - + ", Ids :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); + LOG.info("Creating record reader with readCols :{}, Ids :{}", jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR), jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); // sanity check ValidationUtils.checkArgument(split instanceof HoodieRealtimeFileSplit, "HoodieRealtimeRecordReader can only work on HoodieRealtimeFileSplit and not with " + split); diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieParquetRealtimeInputFormat.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieParquetRealtimeInputFormat.java index a911bbab788b6..48e78f1476445 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieParquetRealtimeInputFormat.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieParquetRealtimeInputFormat.java @@ -80,8 +80,7 @@ public RecordReader getRecordReader(final InputSpli HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setConf(getStorageConf(jobConf)).setBasePath(realtimeSplit.getBasePath()).build(); HoodieTableConfig tableConfig = metaClient.getTableConfig(); addProjectionToJobConf(realtimeSplit, jobConf, tableConfig); - LOG.info("Creating record reader with readCols :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR) - + ", Ids :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); + LOG.info("Creating record reader with readCols :{}, Ids :{}", jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR), jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); // for log only split, set the parquet reader as empty. if (isLogFile(realtimeSplit.getPath())) { @@ -100,9 +99,7 @@ void addProjectionToJobConf(final RealtimeSplit realtimeSplit, final JobConf job // actual heavy lifting of reading the parquet files happen. if (HoodieRealtimeInputFormatUtils.canAddProjectionToJobConf(realtimeSplit, jobConf)) { synchronized (jobConf) { - LOG.info( - "Before adding Hoodie columns, Projections :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR) - + ", Ids :" + jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); + LOG.info("Before adding Hoodie columns, Projections :{}, Ids :{}", jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR), jobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR)); if (HoodieRealtimeInputFormatUtils.canAddProjectionToJobConf(realtimeSplit, jobConf)) { // Hive (across all versions) fails for queries like select count(`_hoodie_commit_time`) from table; // In this case, the projection fields gets removed. Looking at HiveInputFormat implementation, in some cases diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieRealtimeRecordReader.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieRealtimeRecordReader.java index 79d8e6ad64a65..eea8200d7fe02 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieRealtimeRecordReader.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/HoodieRealtimeRecordReader.java @@ -66,7 +66,7 @@ private static RecordReader constructRecordReader(R LOG.info("Enabling un-merged reading of realtime records"); return new RealtimeUnmergedRecordReader(split, jobConf, realReader); } - LOG.info("Enabling merged reading of realtime records for split " + split); + LOG.info("Enabling merged reading of realtime records for split {}", split); return new RealtimeCompactedRecordReader(split, jobConf, realReader); } catch (Exception e) { LOG.error("Got exception when constructing record reader", e); diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/RealtimeCompactedRecordReader.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/RealtimeCompactedRecordReader.java index 8d0d1765eb5e7..de676624c6c38 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/RealtimeCompactedRecordReader.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/realtime/RealtimeCompactedRecordReader.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.model.HoodieAvroRecordMerger; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.schema.HoodieAvroSchemaCache; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.log.HoodieMergedLogRecordScanner; import org.apache.hudi.common.table.read.BufferedRecord; @@ -186,8 +187,8 @@ private void setUpWritable(Option rec, ArrayWritable ar arrayWritable.set(originalValue); } catch (RuntimeException re) { LOG.error("Got exception when doing array copy", re); - LOG.error("Base record :" + HoodieRealtimeRecordReaderUtils.arrayWritableToString(arrayWritable)); - LOG.error("Log record :" + HoodieRealtimeRecordReaderUtils.arrayWritableToString(aWritable)); + LOG.error("Base record :{}", HoodieRealtimeRecordReaderUtils.arrayWritableToString(arrayWritable)); + LOG.error("Log record :{}", HoodieRealtimeRecordReaderUtils.arrayWritableToString(aWritable)); String errMsg = "Base-record :" + HoodieRealtimeRecordReaderUtils.arrayWritableToString(arrayWritable) + " ,Log-record :" + HoodieRealtimeRecordReaderUtils.arrayWritableToString(aWritable) + " ,Error :" + re.getMessage(); throw new RuntimeException(errMsg, re); @@ -204,8 +205,8 @@ private Option mergeRecord(HoodieRecord newRecord, A // once presto on hudi have its own mor reader, we can remove the rewrite logical. GenericRecord genericRecord = HiveAvroSerializer.rewriteRecordIgnoreResultCheck(oldRecord, getLogScannerReaderSchema()); RecordContext recordContext = AvroRecordContext.getFieldAccessorInstance(); - BufferedRecord record = BufferedRecords.fromEngineRecord(genericRecord, HoodieSchema.fromAvroSchema(genericRecord.getSchema()), recordContext, orderingFields, newRecord.getRecordKey(), false); - BufferedRecord newBufferedRecord = BufferedRecords.fromHoodieRecord(newRecord, HoodieSchema.fromAvroSchema(getLogScannerReaderSchema().toAvroSchema()), + BufferedRecord record = BufferedRecords.fromEngineRecord(genericRecord, HoodieAvroSchemaCache.intern(genericRecord.getSchema()), recordContext, orderingFields, newRecord.getRecordKey(), false); + BufferedRecord newBufferedRecord = BufferedRecords.fromHoodieRecord(newRecord, HoodieAvroSchemaCache.intern(getLogScannerReaderSchema().toAvroSchema()), recordContext, payloadProps, orderingFields, deleteContext); BufferedRecord mergeResult = merger.merge(record, newBufferedRecord, recordContext, payloadProps); if (mergeResult.isDelete()) { diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieHiveUtils.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieHiveUtils.java index 981ab9ce54b3c..a781f3c53d092 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieHiveUtils.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieHiveUtils.java @@ -94,7 +94,7 @@ public static Option getMaxCommit(JobConf job, String tableName) { public static boolean stopAtCompaction(JobContext job, String tableName) { String compactionPropName = String.format(HOODIE_STOP_AT_COMPACTION_PATTERN, tableName); boolean stopAtCompaction = job.getConfiguration().getBoolean(compactionPropName, true); - LOG.info("Read stop at compaction - " + stopAtCompaction); + LOG.info("Read stop at compaction - {}", stopAtCompaction); return stopAtCompaction; } @@ -104,13 +104,13 @@ public static Integer readMaxCommits(JobContext job, String tableName) { if (maxCommits == MAX_COMMIT_ALL) { maxCommits = Integer.MAX_VALUE; } - LOG.info("Read max commits - " + maxCommits); + LOG.info("Read max commits - {}", maxCommits); return maxCommits; } public static String readStartCommitTime(JobContext job, String tableName) { String startCommitTimestampName = String.format(HOODIE_START_COMMIT_PATTERN, tableName); - LOG.info("Read start commit time - " + job.getConfiguration().get(startCommitTimestampName)); + LOG.info("Read start commit time - {}", job.getConfiguration().get(startCommitTimestampName)); return job.getConfiguration().get(startCommitTimestampName); } diff --git a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java index 0e1539fab9bfb..f40eca1308729 100644 --- a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java +++ b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java @@ -137,9 +137,7 @@ public static void cleanProjectionColumnIds(Configuration conf) { String columnIds = conf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR); if (!columnIds.isEmpty() && columnIds.charAt(0) == ',') { conf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, columnIds.substring(1)); - if (LOG.isDebugEnabled()) { - LOG.debug("The projection Ids: {" + columnIds + "} start with ','. First comma is removed"); - } + LOG.debug("The projection Ids: {{}} start with ','. First comma is removed", columnIds); } } } diff --git a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java index 3616b9909b773..0383f9f7e668c 100644 --- a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java +++ b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java @@ -19,6 +19,7 @@ package org.apache.hudi.hadoop.realtime; import org.apache.hudi.avro.model.HoodieCompactionPlan; +import org.apache.hudi.avro.model.HoodieRollbackPlan; import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieMemoryConfig; import org.apache.hudi.common.config.HoodieReaderConfig; @@ -213,8 +214,8 @@ private void testReaderInternal(ExternalSpillableMap.DiskMapType diskMapType, List> logVersionsWithAction = new ArrayList<>(); logVersionsWithAction.add(Pair.of(HoodieTimeline.DELTA_COMMIT_ACTION, 1)); logVersionsWithAction.add(Pair.of(HoodieTimeline.DELTA_COMMIT_ACTION, 2)); - // TODO: HUDI-154 Once Hive 2.x PR (PR-674) is merged, enable this change - // logVersionsWithAction.add(Pair.of(HoodieTimeline.ROLLBACK_ACTION, 3)); + logVersionsWithAction.add(Pair.of(HoodieTimeline.ROLLBACK_ACTION, 3)); + FileSlice fileSlice = new FileSlice(partitioned ? HadoopFSUtils.getRelativePartitionPath(new Path(basePath.toString()), new Path(partitionDir.getAbsolutePath())) : "default", baseInstant, "fileid0"); @@ -231,7 +232,7 @@ private void testReaderInternal(ExternalSpillableMap.DiskMapType diskMapType, HoodieLogFormat.Writer writer; if (action.equals(HoodieTimeline.ROLLBACK_ACTION)) { - writer = InputFormatTestUtil.writeRollback(partitionDir, storage, "fileid0", baseInstant, + writer = InputFormatTestUtil.writeRollback(partitionDir, storage, "fileid0", instantTime, instantTime, String.valueOf(baseInstantTs + logVersion - 1), logVersion); } else { @@ -243,7 +244,11 @@ private void testReaderInternal(ExternalSpillableMap.DiskMapType diskMapType, long size = writer.getCurrentSize(); writer.close(); assertTrue(size > 0, "block - size should be > 0"); - FileCreateUtilsLegacy.createDeltaCommit(COMMIT_METADATA_SER_DE, basePath.toString(), instantTime, commitMetadata); + if (action.equals(HoodieTimeline.ROLLBACK_ACTION)) { + FileCreateUtilsLegacy.createRequestedRollbackFile(basePath.toString(), instantTime, new HoodieRollbackPlan()); + } else { + FileCreateUtilsLegacy.createDeltaCommit(COMMIT_METADATA_SER_DE, basePath.toString(), instantTime, commitMetadata); + } // create a split with baseFile (parquet file written earlier) and new log file(s) fileSlice.addLogFile(writer.getLogFile()); diff --git a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java index 274b2e21ac2b3..06f8113b5ee6e 100644 --- a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java +++ b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java @@ -30,6 +30,7 @@ import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; import org.apache.hudi.common.table.log.block.HoodieDataBlock; @@ -371,10 +372,14 @@ public static HoodieLogFormat.Writer writeRollback(File partitionDir, HoodieStor int logVersion) throws InterruptedException, IOException { HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(new StoragePath(partitionDir.getPath())) - .withFileId(fileId) - .withInstantTime(baseCommit).withStorage(storage).withLogVersion(logVersion) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).build(); + HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(partitionDir.getPath())) + .withLogFileId(fileId) + .withInstantTime(baseCommit) + .withStorage(storage) + .withLogVersion(logVersion) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .build(); // generate metadata Map header = new HashMap<>(); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, newCommit); @@ -406,8 +411,10 @@ public static HoodieLogFormat.Writer writeDataBlockToLogFile(File partitionDir, HoodieLogBlock.HoodieLogBlockType logBlockType) throws InterruptedException, IOException { HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(new StoragePath(partitionDir.getPath())) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(fileId) + HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(partitionDir.getPath())) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(fileId) .withLogVersion(logVersion) .withInstantTime(newCommit) .withStorage(storage) @@ -444,8 +451,10 @@ public static HoodieLogFormat.Writer writeRollbackBlockToLogFile(File partitionD String oldCommit, int logVersion) throws InterruptedException, IOException { HoodieLogFormat.Writer writer = - HoodieLogFormat.newWriterBuilder().onParentPath(new StoragePath(partitionDir.getPath())) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(fileId) + HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(partitionDir.getPath())) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(fileId) .withInstantTime(baseCommit) .withLogVersion(logVersion).withStorage(storage).build(); diff --git a/hudi-integ-test/pom.xml b/hudi-integ-test/pom.xml index 4910031b5a477..f2f2633847ba5 100644 --- a/hudi-integ-test/pom.xml +++ b/hudi-integ-test/pom.xml @@ -42,12 +42,13 @@ jersey-container-servlet-core + com.github.docker-java docker-java - 3.1.2 + 3.3.6 test @@ -391,6 +392,19 @@ trino-jdbc + + org.testcontainers + testcontainers + test + + + + org.testcontainers + junit-jupiter + + ${testcontainers.version} + test + @@ -518,6 +532,38 @@ + + integration-tests + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + ${skipITs} + + **/IT*.java + + + **/integ2/** + + + ${dynamodb-local.endpoint} + ${surefire-log4j.file} + + false + + + + + m1-mac diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordPartialPayloadGenerator.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordPartialPayloadGenerator.java index 999f83de66c08..b42ad4396c1be 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordPartialPayloadGenerator.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordPartialPayloadGenerator.java @@ -63,7 +63,7 @@ public boolean validate(GenericRecord record) { return validate((Object) record); } - // Atleast 1 entry should be null + // At least 1 entry should be null private boolean validate(Object object) { if (object == null) { return true; diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/helpers/DFSTestSuitePathSelector.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/helpers/DFSTestSuitePathSelector.java index 2b8d95cc7df37..c4f85c6d98dcf 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/helpers/DFSTestSuitePathSelector.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/helpers/DFSTestSuitePathSelector.java @@ -20,7 +20,6 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.ImmutablePair; import org.apache.hudi.common.util.collection.Pair; @@ -41,6 +40,7 @@ import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; /** @@ -99,14 +99,13 @@ public Pair, Checkpoint> getNextFilePathsAndMaxModificationTime( // no data to readAvro if (eligibleFiles.size() == 0) { return new ImmutablePair<>(Option.empty(), - lastCheckpoint.orElseGet(() -> new StreamerCheckpointV2(String.valueOf(Long.MIN_VALUE)))); + lastCheckpoint.orElseGet(() -> createCheckpoint(String.valueOf(Long.MIN_VALUE)))); } // readAvro the files out. String pathStr = eligibleFiles.stream().map(f -> f.getPath().toString()) .collect(Collectors.joining(",")); - return new ImmutablePair<>(Option.ofNullable(pathStr), - new StreamerCheckpointV2(String.valueOf(nextBatchId))); + return new ImmutablePair<>(Option.ofNullable(pathStr), createCheckpoint(String.valueOf(nextBatchId))); } catch (IOException ioe) { throw new HoodieIOException( "Unable to readAvro from source from checkpoint: " + lastCheckpoint, ioe); diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestBase.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestBase.java index 60493f98931d9..eba2270a430df 100644 --- a/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestBase.java +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestBase.java @@ -62,7 +62,6 @@ public abstract class ITTestBase { protected static final String ADHOC_2_CONTAINER = "/adhoc-2"; protected static final String HIVESERVER = "/hiveserver"; protected static final String PRESTO_COORDINATOR = "/presto-coordinator-1"; - protected static final String TRINO_COORDINATOR = "/trino-coordinator-1"; protected static final String HOODIE_WS_ROOT = "/var/hoodie/ws"; protected static final String HOODIE_JAVA_APP = HOODIE_WS_ROOT + "/hudi-spark-datasource/hudi-spark/run_hoodie_app.sh"; protected static final String HOODIE_GENERATE_APP = HOODIE_WS_ROOT + "/hudi-spark-datasource/hudi-spark/run_hoodie_generate_app.sh"; @@ -77,7 +76,6 @@ public abstract class ITTestBase { HOODIE_WS_ROOT + "/docker/hoodie/hadoop/hive_base/target/hoodie-utilities.jar"; protected static final String HIVE_SERVER_JDBC_URL = "jdbc:hive2://hiveserver:10000"; protected static final String PRESTO_COORDINATOR_URL = "presto-coordinator-1:8090"; - protected static final String TRINO_COORDINATOR_URL = "trino-coordinator-1:8091"; protected static final String HADOOP_CONF_DIR = "/etc/hadoop"; // Skip these lines when capturing output from hive @@ -126,12 +124,6 @@ static String getPrestoConsoleCommand(String commandFile) { .append(" -f " + commandFile).toString(); } - static String getTrinoConsoleCommand(String commandFile) { - return new StringBuilder().append("trino --server " + TRINO_COORDINATOR_URL) - .append(" --catalog hive --schema default") - .append(" -f " + commandFile).toString(); - } - @BeforeEach public void init() { String dockerHost = (OVERRIDDEN_DOCKER_HOST != null) ? OVERRIDDEN_DOCKER_HOST : DEFAULT_DOCKER_HOST; @@ -320,20 +312,6 @@ void executePrestoCopyCommand(String fromFile, String remotePath) { .exec(); } - Pair executeTrinoCommandFile(String commandFile) throws Exception { - String trinoCmd = getTrinoConsoleCommand(commandFile); - TestExecStartResultCallback callback = executeCommandStringInDocker(ADHOC_1_CONTAINER, trinoCmd, true); - return Pair.of(callback.getStdout().toString().trim(), callback.getStderr().toString().trim()); - } - - void executeTrinoCopyCommand(String fromFile, String remotePath) { - Container adhocContainer = runningContainers.get(ADHOC_1_CONTAINER); - dockerClient.copyArchiveToContainerCmd(adhocContainer.getId()) - .withHostResource(fromFile) - .withRemotePath(remotePath) - .exec(); - } - private void saveUpLogs() { try { // save up the Hive log files for introspection diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestHoodieDemo.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestHoodieDemo.java index d9d2c20dc2bb4..d9cc3e526f7f3 100644 --- a/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestHoodieDemo.java +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ/ITTestHoodieDemo.java @@ -38,28 +38,18 @@ */ public class ITTestHoodieDemo extends ITTestBase { - private static final String TRINO_TABLE_CHECK_FILENAME = "trino-table-check.commands"; - private static final String TRINO_BATCH1_FILENAME = "trino-batch1.commands"; - private static final String TRINO_BATCH2_FILENAME = "trino-batch2-after-compaction.commands"; - private static final String HDFS_DATA_DIR = "/usr/hive/data/input"; private static final String HDFS_BATCH_PATH1 = HDFS_DATA_DIR + "/batch_1.json"; private static final String HDFS_BATCH_PATH2 = HDFS_DATA_DIR + "/batch_2.json"; private static final String HDFS_PRESTO_INPUT_TABLE_CHECK_PATH = HDFS_DATA_DIR + "/presto-table-check.commands"; private static final String HDFS_PRESTO_INPUT_BATCH1_PATH = HDFS_DATA_DIR + "/presto-batch1.commands"; private static final String HDFS_PRESTO_INPUT_BATCH2_PATH = HDFS_DATA_DIR + "/presto-batch2-after-compaction.commands"; - private static final String HDFS_TRINO_INPUT_TABLE_CHECK_PATH = HDFS_DATA_DIR + "/" + TRINO_TABLE_CHECK_FILENAME; - private static final String HDFS_TRINO_INPUT_BATCH1_PATH = HDFS_DATA_DIR + "/" + TRINO_BATCH1_FILENAME; - private static final String HDFS_TRINO_INPUT_BATCH2_PATH = HDFS_DATA_DIR + "/" + TRINO_BATCH2_FILENAME; private static final String INPUT_BATCH_PATH1 = HOODIE_WS_ROOT + "/docker/demo/data/batch_1.json"; private static final String PRESTO_INPUT_TABLE_CHECK_RELATIVE_PATH = "/docker/demo/presto-table-check.commands"; private static final String PRESTO_INPUT_BATCH1_RELATIVE_PATH = "/docker/demo/presto-batch1.commands"; private static final String INPUT_BATCH_PATH2 = HOODIE_WS_ROOT + "/docker/demo/data/batch_2.json"; private static final String PRESTO_INPUT_BATCH2_RELATIVE_PATH = "/docker/demo/presto-batch2-after-compaction.commands"; - private static final String TRINO_INPUT_TABLE_CHECK_RELATIVE_PATH = "/docker/demo/" + TRINO_TABLE_CHECK_FILENAME; - private static final String TRINO_INPUT_BATCH1_RELATIVE_PATH = "/docker/demo/" + TRINO_BATCH1_FILENAME; - private static final String TRINO_INPUT_BATCH2_RELATIVE_PATH = "/docker/demo/" + TRINO_BATCH2_FILENAME; private static final String COW_BASE_PATH = "/user/hive/warehouse/stock_ticks_cow"; private static final String MOR_BASE_PATH = "/user/hive/warehouse/stock_ticks_mor"; @@ -120,16 +110,15 @@ public void testParquetDemo() throws Exception { // batch 1 ingestFirstBatchAndHiveSync(); testHiveAfterFirstBatch(); - // TODO(HUDI-8269, HUDI-8270): fix integration tests with Presto and Trino + // TODO(HUDI-8269): fix integration tests with Presto. The legacy Trino demo + // path was retired in favor of the integ2 testcontainers Trino E2E suite. // testPrestoAfterFirstBatch(); - // testTrinoAfterFirstBatch(); testSparkSQLAfterFirstBatch(); // batch 2 ingestSecondBatchAndHiveSync(); testHiveAfterSecondBatch(); // testPrestoAfterSecondBatch(); - // testTrinoAfterSecondBatch(); testSparkSQLAfterSecondBatch(); // TODO: HUDI-8572 // testIncrementalHiveQueryBeforeCompaction(); @@ -141,7 +130,6 @@ public void testParquetDemo() throws Exception { testIncrementalSparkSQLQuery(); testHiveAfterSecondBatchAfterCompaction(); // testPrestoAfterSecondBatchAfterCompaction(); - // testTrinoAfterSecondBatchAfterCompaction(); // TODO: HUDI-8572 // testIncrementalHiveQueryAfterCompaction(); } @@ -159,14 +147,12 @@ public void testHFileDemo() throws Exception { ingestFirstBatchAndHiveSync(); testHiveAfterFirstBatch(); //testPrestoAfterFirstBatch(); - //testTrinoAfterFirstBatch(); //testSparkSQLAfterFirstBatch(); // batch 2 ingestSecondBatchAndHiveSync(); testHiveAfterSecondBatch(); //testPrestoAfterSecondBatch(); - //testTrinoAfterSecondBatch(); //testSparkSQLAfterSecondBatch(); testIncrementalHiveQueryBeforeCompaction(); //testIncrementalSparkSQLQuery(); @@ -175,7 +161,6 @@ public void testHFileDemo() throws Exception { scheduleAndRunCompaction(); testHiveAfterSecondBatchAfterCompaction(); //testPrestoAfterSecondBatchAfterCompaction(); - //testTrinoAfterSecondBatchAfterCompaction(); //testIncrementalHiveQueryAfterCompaction(); } @@ -196,10 +181,6 @@ private void setupDemo() throws Exception { executePrestoCopyCommand(System.getProperty("user.dir") + "/.." + PRESTO_INPUT_TABLE_CHECK_RELATIVE_PATH, HDFS_DATA_DIR); executePrestoCopyCommand(System.getProperty("user.dir") + "/.." + PRESTO_INPUT_BATCH1_RELATIVE_PATH, HDFS_DATA_DIR); executePrestoCopyCommand(System.getProperty("user.dir") + "/.." + PRESTO_INPUT_BATCH2_RELATIVE_PATH, HDFS_DATA_DIR); - - executeTrinoCopyCommand(System.getProperty("user.dir") + "/.." + TRINO_INPUT_TABLE_CHECK_RELATIVE_PATH, HDFS_DATA_DIR); - executeTrinoCopyCommand(System.getProperty("user.dir") + "/.." + TRINO_INPUT_BATCH1_RELATIVE_PATH, HDFS_DATA_DIR); - executeTrinoCopyCommand(System.getProperty("user.dir") + "/.." + TRINO_INPUT_BATCH2_RELATIVE_PATH, HDFS_DATA_DIR); } private void ingestFirstBatchAndHiveSync() throws Exception { @@ -359,20 +340,6 @@ private void testPrestoAfterFirstBatch() throws Exception { "\"GOOG\",\"2018-08-31 10:29:00\",\"3391\",\"1230.1899\",\"1230.085\"", 2); } - private void testTrinoAfterFirstBatch() throws Exception { - Pair stdOutErrPair = executeTrinoCommandFile(HDFS_TRINO_INPUT_TABLE_CHECK_PATH); - assertStdOutContains(stdOutErrPair, "stock_ticks_cow", 2); - assertStdOutContains(stdOutErrPair, "stock_ticks_mor", 6); - - stdOutErrPair = executeTrinoCommandFile(HDFS_TRINO_INPUT_BATCH1_PATH); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:29:00\"", 4); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 09:59:00\",\"6330\",\"1230.5\",\"1230.02\"", 2); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:29:00\",\"3391\",\"1230.1899\",\"1230.085\"", 2); - } - private void testHiveAfterSecondBatch() throws Exception { Pair stdOutErrPair = executeHiveCommandFile(HIVE_BATCH1_COMMANDS); assertStdOutContains(stdOutErrPair, "| symbol | _c1 |\n+---------+----------------------+\n" @@ -406,20 +373,6 @@ private void testPrestoAfterSecondBatch() throws Exception { "\"GOOG\",\"2018-08-31 10:59:00\",\"9021\",\"1227.1993\",\"1227.215\""); } - private void testTrinoAfterSecondBatch() throws Exception { - Pair stdOutErrPair = executeTrinoCommandFile(HDFS_TRINO_INPUT_BATCH1_PATH); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:29:00\"", 2); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:59:00\"", 2); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 09:59:00\",\"6330\",\"1230.5\",\"1230.02\"", 2); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:29:00\",\"3391\",\"1230.1899\",\"1230.085\""); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:59:00\",\"9021\",\"1227.1993\",\"1227.215\""); - } - private void testHiveAfterSecondBatchAfterCompaction() throws Exception { Pair stdOutErrPair = executeHiveCommandFile(HIVE_BATCH2_COMMANDS); assertStdOutContains(stdOutErrPair, "| symbol | _c1 |\n+---------+----------------------+\n" @@ -442,16 +395,6 @@ private void testPrestoAfterSecondBatchAfterCompaction() throws Exception { "\"GOOG\",\"2018-08-31 10:59:00\",\"9021\",\"1227.1993\",\"1227.215\""); } - private void testTrinoAfterSecondBatchAfterCompaction() throws Exception { - Pair stdOutErrPair = executeTrinoCommandFile(HDFS_TRINO_INPUT_BATCH2_PATH); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:59:00\"", 2); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 09:59:00\",\"6330\",\"1230.5\",\"1230.02\""); - assertStdOutContains(stdOutErrPair, - "\"GOOG\",\"2018-08-31 10:59:00\",\"9021\",\"1227.1993\",\"1227.215\""); - } - private void testSparkSQLAfterSecondBatch() throws Exception { Pair stdOutErrPair = executeSparkSQLCommand(SPARKSQL_BATCH2_COMMANDS, true); assertStdOutContains(stdOutErrPair, diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ/testsuite/TestFileDeltaInputWriter.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ/testsuite/TestFileDeltaInputWriter.java index a33ee7c5fb4fd..ad6ae17673835 100644 --- a/hudi-integ-test/src/test/java/org/apache/hudi/integ/testsuite/TestFileDeltaInputWriter.java +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ/testsuite/TestFileDeltaInputWriter.java @@ -98,7 +98,7 @@ public void testAvroFileSinkWriter() throws IOException { DeltaWriteStats deltaWriteStats = fileSinkWriter.getDeltaWriteStats(); FileSystem fs = HadoopFSUtils.getFs(basePath, jsc.hadoopConfiguration()); FileStatus[] fileStatuses = fs.listStatus(new Path(deltaWriteStats.getFilePath())); - // Atleast 1 file was written + // At least 1 file was written assertEquals(1, fileStatuses.length); // File length should be greater than 0 assertTrue(fileStatuses[0].getLen() > 0); diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.java new file mode 100644 index 0000000000000..0283f24e6b676 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.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.hudi.integ2.testcontainers; + +import org.testcontainers.containers.ContainerState; + +/** + * Interface for providing access to containers by service name. + * This abstraction allows different implementations (e.g., ComposeContainer, individual containers). + */ +public interface ContainerProvider { + + /** + * Get a container by its service name. + * + * @param serviceName the full compose service instance name as Testcontainers resolves it (e.g. "adhoc-1-1") + * @return the container state + * @throws IllegalStateException if container not found + */ + ContainerState getContainer(String serviceName); +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.java new file mode 100644 index 0000000000000..256278d1915e6 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.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.hudi.integ2.testcontainers; + +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.integ2.testcontainers.service.HiveService; +import org.apache.hudi.integ2.testcontainers.service.SparkService; +import org.apache.hudi.integ2.testcontainers.service.TrinoService; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Containers; +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Network; +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.SystemProps; +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Timeouts; + +/** + * Base test class for integration tests using Testcontainers with Docker Compose. + * Uses a "Service" pattern, where each external service (Hive, Spark, etc.) is + * represented by a dedicated service object. This provides better separation of + * concerns and a cleaner API for tests. + */ +@Slf4j +@Testcontainers +public abstract class ITTestBaseTestcontainers implements ContainerProvider { + + protected static ComposeContainer environment; + + // Service objects for interacting with different components + protected HiveService hive; + protected SparkService sparkAdhoc1; + protected TrinoService trino; + + @BeforeAll + public static void setupDockerCompose() { + String composeFilePath = createProcessedComposeFilePath(getDockerComposeFilePath()); + String hudiWorkspace = getHudiWorkspace(); + + log.info("Starting Docker Compose environment"); + log.info("Compose file: {}", composeFilePath); + log.info("HUDI_WS: {}", hudiWorkspace); + + environment = new ComposeContainer(new File(composeFilePath)) + .withEnv("HUDI_WS", hudiWorkspace) + .withExposedService(Containers.SPARK_MASTER, Network.SPARK_MASTER_WEB_UI_PORT, + Wait.forListeningPort().forPorts(Network.SPARK_MASTER_WEB_UI_PORT) + .withStartupTimeout(Timeouts.CONTAINER_STARTUP)) + .withStartupTimeout(Timeouts.CONTAINER_STARTUP); + + // Activate optional compose profiles (e.g. "trino") when requested. Without this the + // profile-gated services stay down, which is the default hive-sync-only topology. + String composeProfiles = System.getProperty(SystemProps.COMPOSE_PROFILES_PROP, ""); + if (!composeProfiles.isEmpty()) { + environment.withEnv("COMPOSE_PROFILES", composeProfiles); + if (composeProfiles.contains(SystemProps.TRINO_PROFILE)) { + // Stream the coordinator's log into the test output. When Trino dies during + // startup (plugin load or config errors) the container is torn down with the + // stack, so this stream is the only place the root cause survives. + environment.withLogConsumer(Containers.TRINO_COORDINATOR, + new Slf4jLogConsumer(log).withPrefix(Containers.TRINO_COORDINATOR)); + } + } + // Point the compose stack at a host-built Trino plugin dir when supplied. The compose + // file falls back to an empty overlay when TRINO_PLUGIN_DIR is unset. + String trinoPluginDir = System.getProperty(SystemProps.TRINO_PLUGIN_DIR_PROP); + if (trinoPluginDir != null) { + environment.withEnv("TRINO_PLUGIN_DIR", trinoPluginDir); + } + environment.start(); + + log.info("Docker Compose environment started successfully"); + log.info("All containers verified and running"); + } + + /** + * Tear down the compose stack between test classes. The docker-compose files publish + * host ports directly (zookeeper 2181, spark 7077, ...), so leaving one + * stack up would make the next class's `@BeforeAll` collide on those host ports. + * Testcontainers' Ryuk reaper only fires at JVM shutdown, which is too late when + * failsafe reuses a JVM across classes. + */ + @AfterAll + public static void tearDownDockerCompose() { + if (environment != null) { + log.info("Stopping Docker Compose environment"); + try { + environment.stop(); + } finally { + environment = null; + } + } + } + + /** + * Initialize service objects. Should be called in @BeforeEach or constructor of test class. + */ + protected void initializeServices() { + this.hive = new HiveService(this); + this.sparkAdhoc1 = new SparkService(this, Containers.ADHOC_1); + // Only wire the Trino service when its profile is active; otherwise the + // trinocoordinator container does not exist and getContainer would throw. + if (isTrinoProfileActive()) { + this.trino = new TrinoService(this); + } + } + + /** + * Returns {@code true} when the {@link SystemProps#COMPOSE_PROFILES_PROP} system + * property activates the {@code trino} compose profile, i.e. the Trino coordinator + * container is part of the running stack. + */ + protected static boolean isTrinoProfileActive() { + return System.getProperty(SystemProps.COMPOSE_PROFILES_PROP, "") + .contains(SystemProps.TRINO_PROFILE); + } + + /** + * Skips the test unless the {@code trino} compose profile is active. Use in the + * {@code @BeforeAll} of Trino ITs so they abort cleanly on a hive-sync-only stack + * where the coordinator container is absent. + */ + protected static void assumeTrinoProfile() { + Assumptions.assumeTrue(isTrinoProfileActive(), + "Test requires the 'trino' compose profile; run with -D" + + SystemProps.COMPOSE_PROFILES_PROP + "=" + SystemProps.TRINO_PROFILE); + } + + /** + * Skips the test unless the active docker-compose prefix points to a Spark 4.x stack. + * Use for tests that rely on Spark 4.0+ only features (e.g. VARIANT type). + */ + protected static void assumeSpark4Compose() { + Assumptions.assumeTrue(isSpark4Compose(), + "Test requires a Spark 4.x compose stack; active prefix is '" + + System.getProperty(SystemProps.COMPOSE_PREFIX, SystemProps.DEFAULT_COMPOSE_PREFIX) + "'"); + } + + /** + * Non-assumption variant of {@link #assumeSpark4Compose()}: returns {@code true} when the + * active compose prefix points at a Spark 4.x stack, without aborting the caller. + */ + protected static boolean isSpark4Compose() { + String composePrefix = System.getProperty(SystemProps.COMPOSE_PREFIX, SystemProps.DEFAULT_COMPOSE_PREFIX); + return composePrefix.contains(SystemProps.SPARK_4_PREFIX_TOKEN); + } + + /** + * Waits for HDFS namenode to be ready by retrying the safemode wait command. + * The namenode may take some time to start after Docker Compose reports containers as running. + */ + protected void waitForHdfs() throws Exception { + for (int i = 1; i <= Timeouts.HDFS_MAX_RETRIES; i++) { + try { + sparkAdhoc1.executeShellCommand("hdfs dfsadmin -safemode wait").expectToSucceed(); + log.info("HDFS namenode is ready"); + return; + } catch (Throwable e) { + if (i == Timeouts.HDFS_MAX_RETRIES) { + throw new RuntimeException( + "HDFS namenode did not become ready after " + Timeouts.HDFS_MAX_RETRIES + " retries", e); + } + log.info("Waiting for HDFS namenode to be ready (attempt {}/{})", i, Timeouts.HDFS_MAX_RETRIES); + Thread.sleep(Timeouts.HDFS_RETRY_INTERVAL.toMillis()); + } + } + } + + /** + * Get a container by service name from the Docker Compose environment. + * Docker Compose appends _1 suffix to service names. + * Implements ContainerProvider interface. + */ + @Override + public ContainerState getContainer(String serviceName) { + try { + return environment.getContainerByServiceName(serviceName) + .orElseThrow(() -> new IllegalStateException("Container not found: " + serviceName)); + } catch (IllegalStateException e) { + log.error("Failed to get container: {}", serviceName, e); + throw e; + } + } + + private static String getHudiWorkspace() { + String projectDir = System.getProperty("user.dir"); + return new File(projectDir, "..").getAbsolutePath(); + } + + private static String getDockerComposeFilePath() { + String projectDir = System.getProperty("user.dir"); + String os = System.getProperty("os.name").toLowerCase(); + String arch = System.getProperty("os.arch").toLowerCase(); + String composePrefix = System.getProperty(SystemProps.COMPOSE_PREFIX, SystemProps.DEFAULT_COMPOSE_PREFIX); + + // Determine which compose file to use based on OS and architecture + boolean isMacArm64 = os.contains("mac") && arch.contains("aarch64"); + String archSuffix = isMacArm64 ? "_arm64" : "_amd64"; + File dockerComposeFile = new File(projectDir, + TestcontainersConfig.Paths.COMPOSE_DIR + composePrefix + archSuffix + ".yml"); + if (!dockerComposeFile.isFile() || !dockerComposeFile.exists()) { + throw new HoodieException(String.format("%s does not exist", dockerComposeFile.getAbsolutePath())); + } + return dockerComposeFile.getAbsolutePath(); + } + + private static String getHadoopEnvFilePath() { + return new File(System.getProperty("user.dir"), "../docker/compose/hadoop.env").getAbsolutePath(); + } + + /** + * Reads the original docker-compose file, removes all 'container_name' directives, and returns a temporary file containing the modified content. Including Testcontainers in the docker-compose file + * will cause ContainerLaunchExceptions to be thrown. + *

    + * Any env_file will normalize/canonicalize to the temporary directory used. This function will make a copy of hadoop.env into the temporary directory to ensure that no error is thrown. + */ + private static String createProcessedComposeFilePath(String composeFile) { + try { + // Read all bytes from the file and convert to a String + byte[] bytes = Files.readAllBytes(Paths.get(composeFile)); + String originalContent = new String(bytes, StandardCharsets.UTF_8); + + // Use a regular expression to find and remove all lines containing 'container_name' + String modifiedContent = originalContent.replaceAll("(?m)^\\s*container_name:.*$", ""); + + // Create a temporary file to hold our modified configuration + Path tempDir = Files.createTempDirectory("hudi-test-compose-"); + + // Delete the temp dir at JVM exit. File.delete() cannot remove a non-empty directory, + // and deleteOnExit runs LIFO, so the files below are registered too: they are deleted + // first, leaving the dir empty for its own delete. + tempDir.toFile().deleteOnExit(); + + // Write the modified content to the temporary file as a byte array. + File tempDockerComposeFile = new File(tempDir.toFile(), "docker-compose.yml"); + Files.write(tempDockerComposeFile.toPath(), modifiedContent.getBytes(StandardCharsets.UTF_8)); + tempDockerComposeFile.deleteOnExit(); + + // tempDir is used as the working directory, docker-compose will look for hadoop.env in the SAME temp directory + // Copy hadoop.env into the SAME temp directory + Path destHadoopEnvPath = tempDir.resolve("hadoop.env"); + Path originalHadoopEnvPath = Paths.get(getHadoopEnvFilePath()).toAbsolutePath().normalize(); + Files.copy(originalHadoopEnvPath, destHadoopEnvPath, StandardCopyOption.REPLACE_EXISTING); + destHadoopEnvPath.toFile().deleteOnExit(); + + // Return the temporary file for Testcontainers to use + return tempDockerComposeFile.toPath().toAbsolutePath().toString(); + } catch (IOException e) { + throw new RuntimeException("Failed to process the docker-compose file", e); + } + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java new file mode 100644 index 0000000000000..3b3cbc5e5e0a0 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java @@ -0,0 +1,266 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.integ2.testcontainers; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Paths; + +/** + * End-to-end Hive sync coverage for Hudi's custom logical types (VECTOR, BLOB) and the + * Spark 4.0 VARIANT type, running against a real Hive metastore via the Testcontainers + * harness. + * + * Each type is exercised through both paths: + * - SQL CREATE TABLE (`*-sql.commands`) - table name `_test` + * - DataFrame writer API (`*-df.commands`) - table name `_test_df` + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ITTestCustomTypeHiveSync extends ITTestBaseTestcontainers { + + // BLOB + private static final String BLOB_SQL_TEST_BASE_PATH = "/user/hive/warehouse/blob_test"; + private static final String BLOB_DF_TEST_BASE_PATH = "/user/hive/warehouse/blob_test_df"; + private static final String SPARKSQL_BLOB_TYPE_SQL_COMMANDS = Paths.DEMO_DIR + "/sparksql-blob-type-sql.commands"; + private static final String SPARKSQL_BLOB_TYPE_DF_COMMANDS = Paths.DEMO_DIR + "/sparksql-blob-type-df.commands"; + + // VARIANT + private static final String VARIANT_SQL_TEST_BASE_PATH = "/user/hive/warehouse/variant_test"; + private static final String VARIANT_DF_TEST_BASE_PATH = "/user/hive/warehouse/variant_test_df"; + private static final String SPARKSQL_VARIANT_TYPE_SQL_COMMANDS = Paths.DEMO_DIR + "/sparksql-variant-type-sql.commands"; + private static final String SPARKSQL_VARIANT_TYPE_DF_COMMANDS = Paths.DEMO_DIR + "/sparksql-variant-type-df.commands"; + + // VECTOR + private static final String VECTOR_SQL_TEST_BASE_PATH = "/user/hive/warehouse/vector_test"; + private static final String VECTOR_DF_TEST_BASE_PATH = "/user/hive/warehouse/vector_test_df"; + private static final String SPARKSQL_VECTOR_TYPE_SQL_COMMANDS = Paths.DEMO_DIR + "/sparksql-vector-type-sql.commands"; + private static final String SPARKSQL_VECTOR_TYPE_DF_COMMANDS = Paths.DEMO_DIR + "/sparksql-vector-type-df.commands"; + + // All test paths cleaned in a single `hdfs dfs -rm -R -f` call; -f silently skips + // non-existent paths so tests that don't create every table don't fail teardown. + private static final String CLEANUP_PATHS_JOINED = String.join(" ", + BLOB_SQL_TEST_BASE_PATH, BLOB_DF_TEST_BASE_PATH, + VARIANT_SQL_TEST_BASE_PATH, VARIANT_DF_TEST_BASE_PATH, + VECTOR_SQL_TEST_BASE_PATH, VECTOR_DF_TEST_BASE_PATH); + + /** + * Run idempotent demo setup once per test class instead of once per @Test. The script + * (mkdir -p, cp, copyFromLocal -f, chmod +x) is safe to run a single time and saves + * one shell exec round-trip per test. Requires @TestInstance(PER_CLASS) so this method + * can be a non-static instance method that uses sparkAdhoc1. + */ + @BeforeAll + public void setupOnce() throws Exception { + initializeServices(); + waitForHdfs(); + sparkAdhoc1.executeShellCommand("/bin/bash " + Paths.DEMO_SETUP).expectToSucceed(); + } + + @AfterEach + public void clean() throws Exception { + sparkAdhoc1.executeShellCommand("hdfs dfs -rm -R -f " + CLEANUP_PATHS_JOINED) + .expectToSucceed(); + } + + // ---------- BLOB ---------- + + @Test + public void testBlobTypeWithHiveSyncSQL() throws Exception { + sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("BLOB_SQL_INSERT_SUCCESS") + .assertStdOutContainsLine("BLOB_SQL_UPDATE_SUCCESS") + .assertStdOutContainsLine("BLOB_SQL_MERGE_SUCCESS") + .assertStdOutContainsLine("BLOB_SQL_DELETE_SUCCESS") + .assertStdOutContainsLine("BLOB_SQL_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.blob_test") + .expectToSucceed() + .assertStdOutContains("blob_data"); + + // MERGE added dt=2024-01-02; DELETE removed the row but kept the partition metadata. + hive.execute("SHOW PARTITIONS default.blob_test") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + // Post-DELETE final row count is 2 (id=1 updated, id=2 merged; id=3 deleted). + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.blob_test") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + + // Project a nested struct field through the Hive serde to verify the BLOB + // struct layout round-trips correctly (count() and DESCRIBE do not). + hive.execute( + "SELECT concat('BLOB_TYPE=', blob_data.type) FROM default.blob_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("BLOB_TYPE=OUT_OF_LINE"); + } + + @Test + public void testBlobTypeWithHiveSyncDataFrameAPI() throws Exception { + sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_DF_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("BLOB_DF_INSERT_SUCCESS") + .assertStdOutContainsLine("BLOB_DF_UPSERT_SUCCESS") + .assertStdOutContainsLine("BLOB_DF_DELETE_SUCCESS") + .assertStdOutContainsLine("BLOB_DF_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.blob_test_df") + .expectToSucceed() + .assertStdOutContains("blob_data"); + + hive.execute("SHOW PARTITIONS default.blob_test_df") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.blob_test_df") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + + // DF path seed used the INLINE struct branch and id=1 is never mutated, + // so projecting blob_data.type through the Hive serde should return INLINE. + hive.execute( + "SELECT concat('BLOB_TYPE=', blob_data.type) FROM default.blob_test_df WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("BLOB_TYPE=INLINE"); + } + + // ---------- VARIANT (Spark 4.x only) ---------- + + @Test + public void testVariantTypeWithHiveSyncSQL() throws Exception { + assumeSpark4Compose(); + sparkAdhoc1.executeSQLFile(SPARKSQL_VARIANT_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VARIANT_SQL_INSERT_SUCCESS") + .assertStdOutContainsLine("VARIANT_SQL_UPDATE_SUCCESS") + .assertStdOutContainsLine("VARIANT_SQL_MERGE_SUCCESS") + .assertStdOutContainsLine("VARIANT_SQL_DELETE_SUCCESS") + .assertStdOutContainsLine("VARIANT_SQL_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.variant_test") + .expectToSucceed() + .assertStdOutContains("variant_data"); + + hive.execute("SHOW PARTITIONS default.variant_test") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + // count(*) does not deserialize the variant column, so it is safe even if + // the Hive serde can't project the variant payload. + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.variant_test") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + } + + @Test + public void testVariantTypeWithHiveSyncDataFrameAPI() throws Exception { + assumeSpark4Compose(); + sparkAdhoc1.executeSQLFile(SPARKSQL_VARIANT_TYPE_DF_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VARIANT_DF_INSERT_SUCCESS") + .assertStdOutContainsLine("VARIANT_DF_UPSERT_SUCCESS") + .assertStdOutContainsLine("VARIANT_DF_DELETE_SUCCESS") + .assertStdOutContainsLine("VARIANT_DF_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.variant_test_df") + .expectToSucceed() + .assertStdOutContains("variant_data"); + + hive.execute("SHOW PARTITIONS default.variant_test_df") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.variant_test_df") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + } + + // ---------- VECTOR ---------- + + @Test + public void testVectorTypeWithHiveSyncSQL() throws Exception { + sparkAdhoc1.executeSQLFile(SPARKSQL_VECTOR_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VECTOR_SQL_INSERT_SUCCESS") + .assertStdOutContainsLine("VECTOR_SQL_UPDATE_SUCCESS") + .assertStdOutContainsLine("VECTOR_SQL_MERGE_SUCCESS") + .assertStdOutContainsLine("VECTOR_SQL_DELETE_SUCCESS") + .assertStdOutContainsLine("VECTOR_SQL_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.vector_test") + .expectToSucceed() + .assertStdOutContains("embedding") + .assertStdOutContains("binary"); + + hive.execute("SHOW PARTITIONS default.vector_test") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.vector_test") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + + // VECTOR(3) is stored on disk as fixed_len_byte_array(12) and mapped to + // Hive BINARY (per RFC-99). length() on the binary column confirms the + // bytes round-trip through the Hive serde at the correct width: 3 floats + // x 4 bytes each = 12. Any flip to array would return 3 instead. + hive.execute( + "SELECT concat('VEC_LEN=', length(embedding)) FROM default.vector_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("VEC_LEN=12"); + } + + @Test + public void testVectorTypeWithHiveSyncDataFrameAPI() throws Exception { + sparkAdhoc1.executeSQLFile(SPARKSQL_VECTOR_TYPE_DF_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VECTOR_DF_INSERT_SUCCESS") + .assertStdOutContainsLine("VECTOR_DF_UPSERT_SUCCESS") + .assertStdOutContainsLine("VECTOR_DF_DELETE_SUCCESS") + .assertStdOutContainsLine("VECTOR_DF_TEST_SUCCESS"); + + hive.execute("DESCRIBE default.vector_test_df") + .expectToSucceed() + .assertStdOutContains("embedding") + .assertStdOutContains("binary"); + + hive.execute("SHOW PARTITIONS default.vector_test_df") + .expectToSucceed() + .assertStdOutContains("dt=2024-01-01") + .assertStdOutContains("dt=2024-01-02"); + + hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM default.vector_test_df") + .expectToSucceed() + .assertStdOutContains("HIVE_COUNT=2"); + + hive.execute( + "SELECT concat('VEC_LEN=', length(embedding)) FROM default.vector_test_df WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("VEC_LEN=12"); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.java new file mode 100644 index 0000000000000..300fc661968bd --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.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.hudi.integ2.testcontainers; + +import java.time.Duration; +import java.util.List; + +/** + * Central configuration for the integ2 Testcontainers harness. Every constant that + * describes "how this harness expects the compose environment to look" belongs here: + * container service names, container-side paths, network endpoints, timeouts, and + * the keys / defaults of system properties that tune the harness. + * + *

    Per-test fixture data (table paths, per-test {@code .commands} scripts) does + * not belong here, those stay in the relevant test class, though they should build + * their common prefix from {@link Paths#DEMO_DIR} / {@link Paths#WS_ROOT} rather + * than inlining the path literal. + */ +public final class TestcontainersConfig { + + private TestcontainersConfig() { + } + + /** Docker Compose service names. Must match the compose YAML verbatim. */ + public static final class Containers { + public static final String HIVESERVER = "hiveserver"; + public static final String SPARK_MASTER = "sparkmaster"; + // Testcontainers appends the replica index, so the adhoc services resolve as "-1". + public static final String ADHOC_1 = "adhoc-1-1"; + public static final String TRINO_COORDINATOR = "trinocoordinator"; + + private Containers() { + } + } + + /** Paths inside the containers (absolute) and the host-side compose directory. */ + public static final class Paths { + public static final String WS_ROOT = "/var/hoodie/ws"; + public static final String DEMO_DIR = WS_ROOT + "/docker/demo"; + public static final String DEMO_SETUP = DEMO_DIR + "/setup_demo_container.sh"; + public static final String HIVE_TARGET = WS_ROOT + "/docker/hoodie/hadoop/hive_base/target"; + public static final String SPARK_BUNDLE = HIVE_TARGET + "/hoodie-spark-bundle.jar"; + public static final String HADOOP_CONF_DIR = "/etc/hadoop"; + /** Host-side, relative to the hudi-integ-test module working directory. */ + public static final String COMPOSE_DIR = "../docker/compose/"; + + private Paths() { + } + } + + /** Network endpoints the harness exposes to tests. */ + public static final class Network { + public static final int SPARK_MASTER_WEB_UI_PORT = 8080; + /** Container-internal Trino HTTP port. Tests exec the CLI inside the coordinator. */ + public static final int TRINO_PORT = 8080; + + private Network() { + } + } + + /** Waits and timeouts used by the harness. */ + public static final class Timeouts { + public static final Duration CONTAINER_STARTUP = Duration.ofMinutes(5); + public static final int HDFS_MAX_RETRIES = 12; + public static final Duration HDFS_RETRY_INTERVAL = Duration.ofSeconds(10); + /** + * Trino's slow startup path is plugin discovery + metastore handshake. The CLI's + * first query against an unready coordinator returns a misleading error, so callers + * should retry up to this many times. + */ + public static final int TRINO_READY_MAX_RETRIES = 18; + public static final Duration TRINO_READY_RETRY_INTERVAL = Duration.ofSeconds(10); + + private Timeouts() { + } + } + + /** System-property keys and their defaults (read via {@link System#getProperty}). */ + public static final class SystemProps { + public static final String COMPOSE_PREFIX = "spark.docker.compose.prefix"; + public static final String DEFAULT_COMPOSE_PREFIX = "docker-compose_hadoop340_hive2310_spark402"; + /** Substring present in compose prefixes that run Spark 4.x (e.g. "...spark402"). */ + public static final String SPARK_4_PREFIX_TOKEN = "spark4"; + + /** + * Comma-separated Docker Compose profiles to activate (passed through to the + * compose stack as {@code COMPOSE_PROFILES}). The Trino services live behind the + * {@link #TRINO_PROFILE} profile, so they only start when it is present. + */ + public static final String COMPOSE_PROFILES_PROP = "compose.profiles"; + /** Host path to the built Trino Hudi plugin, mounted into the coordinator container. */ + public static final String TRINO_PLUGIN_DIR_PROP = "trino.plugin.dir"; + /** Compose profile name that gates the Trino coordinator/worker services. */ + public static final String TRINO_PROFILE = "trino"; + + /** + * Flip to {@code true} (e.g. {@code -Dhudi.integ.hive.verbose=true}) to route + * Hive logs to the console so exception stack traces show up in test output. + */ + public static final String HIVE_VERBOSE = "hudi.integ.hive.verbose"; + + /** + * Hiveconf entries applied only when {@link #HIVE_VERBOSE} is enabled. + * See {@code HiveService#execute} for the per-flag rationale. + */ + public static final List VERBOSE_HIVECONFS = + List.of("hive.root.logger=INFO,console", + "hive.exec.mode.local.auto=false", + "hive.log.explain.output=true", + "hive.server2.logging.operation.verbose=true"); + + private SystemProps() { + } + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.java new file mode 100644 index 0000000000000..f2f01a006c1ab --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.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.hudi.integ2.testcontainers.command; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerState; + +/** + * A utility class for executing commands within a given Testcontainer. + * This class is stateless regarding the command being executed but is tied to a specific container. + */ +@Slf4j +public final class CommandExecutor { + + private final ContainerState container; + + public CommandExecutor(ContainerState container) { + this.container = container; + } + + /** + * Execute a command in the executor's container. + */ + public CommandResult executeCommand(String... command) throws Exception { + String containerIdentifier = getContainerIdentifier(container); + String commandStr = String.join(" ", command); + + log.info("==> [{}] Executing: {}", containerIdentifier, commandStr); + + long startTime = System.currentTimeMillis(); + Container.ExecResult result = container.execInContainer(command); + long duration = System.currentTimeMillis() - startTime; + + int exitCode = result.getExitCode(); + log.info("<== [{}] Exit code: {} ({}ms)", containerIdentifier, exitCode, duration); + + if (exitCode != 0) { + log.error("STDOUT:\n{}", result.getStdout()); + log.error("STDERR:\n{}", result.getStderr()); + } else if (log.isDebugEnabled()) { + log.debug("STDOUT:\n{}", result.getStdout()); + if (!result.getStderr().isEmpty()) { + log.debug("STDERR:\n{}", result.getStderr()); + } + } + + return new CommandResult(result); + } + + /** + * Execute a shell command string. + */ + public CommandResult executeCommandString(String cmd) throws Exception { + String[] cmdArray = {"/bin/bash", "-c", cmd}; + return executeCommand(cmdArray); + } + + /** + * Get a readable identifier for the container. + */ + private String getContainerIdentifier(ContainerState container) { + String containerName = container.getContainerInfo().getName(); + if (containerName != null && !containerName.isEmpty()) { + // Container names start with '/', so remove it + String cleanName = containerName.startsWith("/") ? containerName.substring(1) : containerName; + return cleanName + ":" + container.getContainerId().substring(0, 8); + } + return container.getContainerId().substring(0, 12); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.java new file mode 100644 index 0000000000000..8e65e9aebcbaf --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.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.hudi.integ2.testcontainers.command; + +import lombok.AllArgsConstructor; +import org.testcontainers.containers.Container; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A dedicated class to hold the result of a command execution and provide + * fluent assertion methods for cleaner tests. + */ +@AllArgsConstructor +public class CommandResult { + + // Spark 4.0 (Scala 2.13) spark-shell runs under a dumb terminal when stdin + // is piped from a file, which flushes the next `scala> ` prompt onto the + // same line as preceding async println output (e.g. `scala> MARKER`). + // Stripping one-or-more leading `scala>\s+` prefixes normalizes those lines + // back to the bare sentinel while leaving Spark 3.5 output (sentinel already + // on its own line) unchanged. + private static final Pattern REPL_PROMPT_PREFIX = Pattern.compile("^(scala>\\s+)+"); + + private final String stdout; + private final String stderr; + private final int exitCode; + + public CommandResult(Container.ExecResult execResult) { + this.stdout = execResult.getStdout(); + this.stderr = execResult.getStderr(); + this.exitCode = execResult.getExitCode(); + } + + /** + * Asserts that the command's exit code is 0 (success). + * + * @return The same {@link CommandResult} instance for chaining assertions. + * @throws AssertionError if the exit code is not 0. + */ + public CommandResult expectToSucceed() { + assertEquals(0, exitCode, + String.format("Command failed with exit code %d. Stderr: %s", exitCode, stderr)); + return this; + } + + /** + * Asserts that the standard output contains a specific substring exactly once. + * + * @param expectedSubstring The substring to search for. + * @return The same {@link CommandResult} instance for chaining assertions. + */ + public CommandResult assertStdOutContains(String expectedSubstring) { + return assertStdOutContains(expectedSubstring, 1); + } + + /** + * Asserts that the standard output contains a specific substring an exact number of times. + * + * @param expectedSubstring The substring to search for. + * @param times The exact number of times the substring is expected to appear. + * @return The same {@link CommandResult} instance for chaining assertions. + */ + public CommandResult assertStdOutContains(String expectedSubstring, int times) { + // Normalize whitespace for more robust matching + String stdOutSingleSpaced = stdout.replaceAll("[\\s]+", " ").trim(); + String expectedOutput = expectedSubstring.replaceAll("[\\s]+", " ").trim(); + + int lastIndex = 0; + int count = 0; + while (lastIndex != -1) { + lastIndex = stdOutSingleSpaced.indexOf(expectedOutput, lastIndex); + if (lastIndex != -1) { + count++; + lastIndex += expectedOutput.length(); + } + } + assertEquals(times, count, + String.format("Expected to find substring '%s' %d times, but found %d. Full stdout: %s", + expectedOutput, times, count, stdout)); + return this; + } + + /** + * Asserts that the standard output contains a line exactly equal to the expected value + * (after trimming and after stripping any leading {@code scala> } REPL prompt prefix), + * appearing exactly once. Use this for REPL sentinel markers emitted via + * {@code println(...)}. Handles both Scala 2.12's spark-shell echoing the input line + * back (Spark 3.5) and Scala 2.13's dumb-terminal mode prefixing sentinel output with + * {@code scala> } on the same line (Spark 4.0). + * + * @param expectedLine The exact line content to match (after trim and prompt strip). + * @return The same {@link CommandResult} instance for chaining assertions. + */ + public CommandResult assertStdOutContainsLine(String expectedLine) { + return assertStdOutContainsLine(expectedLine, 1); + } + + /** + * Asserts that the standard output contains a line exactly equal to the expected value + * (after trimming and after stripping any leading {@code scala> } REPL prompt prefix), + * appearing an exact number of times. + * + * @param expectedLine The exact line content to match (after trim and prompt strip). + * @param times The exact number of matching lines expected. + * @return The same {@link CommandResult} instance for chaining assertions. + */ + public CommandResult assertStdOutContainsLine(String expectedLine, int times) { + String expected = expectedLine.trim(); + long count = stdout.lines() + .map(line -> REPL_PROMPT_PREFIX.matcher(line).replaceFirst("").trim()) + .filter(expected::equals) + .count(); + assertEquals(times, count, + String.format("Expected line '%s' %d times, but found %d. Full stdout: %s", + expected, times, count, stdout)); + return this; + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.java new file mode 100644 index 0000000000000..5c5151809a7a2 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.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.hudi.integ2.testcontainers.service; + +import org.apache.hudi.integ2.testcontainers.ContainerProvider; +import org.apache.hudi.integ2.testcontainers.TestcontainersConfig; +import org.apache.hudi.integ2.testcontainers.command.CommandExecutor; +import org.apache.hudi.integ2.testcontainers.command.CommandResult; + +import java.util.ArrayList; +import java.util.List; + +/** + * A service wrapper for the Hive container. + * This class is responsible for all interactions with the Hive service, + * including executing commands and managing files. + */ +public class HiveService { + + private final CommandExecutor executor; + private final boolean verbose; + + public HiveService(ContainerProvider provider) { + this(provider, Boolean.getBoolean(TestcontainersConfig.SystemProps.HIVE_VERBOSE)); + } + + /** + * Visible-for-tests overload so callers can toggle verbose mode without + * setting the system property at JVM start time. + */ + public HiveService(ContainerProvider provider, boolean verbose) { + this.executor = new CommandExecutor(provider.getContainer(TestcontainersConfig.Containers.HIVESERVER)); + this.verbose = verbose; + } + + /** + * Execute a Hive command and return the result. + */ + public CommandResult execute(String hiveCommand) throws Exception { + List hiveCmd = new ArrayList<>(); + hiveCmd.add("hive"); + hiveCmd.add("--hiveconf"); + hiveCmd.add("hive.input.format=org.apache.hadoop.hive.ql.io.HiveInputFormat"); + hiveCmd.add("--hiveconf"); + hiveCmd.add("hive.stats.autogather=false"); + if (verbose) { + for (String kv : TestcontainersConfig.SystemProps.VERBOSE_HIVECONFS) { + hiveCmd.add("--hiveconf"); + hiveCmd.add(kv); + } + } + hiveCmd.add("-e"); + hiveCmd.add(hiveCommand); + return executor.executeCommand(hiveCmd.toArray(new String[0])); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.java new file mode 100644 index 0000000000000..1c105402d6445 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.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.hudi.integ2.testcontainers.service; + +import org.apache.hudi.integ2.testcontainers.ContainerProvider; +import org.apache.hudi.integ2.testcontainers.TestcontainersConfig; +import org.apache.hudi.integ2.testcontainers.command.CommandExecutor; +import org.apache.hudi.integ2.testcontainers.command.CommandResult; + +/** + * A service wrapper for the Spark container. + * This class is responsible for all interactions with the Spark service, + * including executing spark-shell commands. + */ +public class SparkService { + + private final CommandExecutor executor; + + public SparkService(ContainerProvider provider, String containerName) { + this.executor = new CommandExecutor(provider.getContainer(containerName)); + } + + /** + * Execute a Spark SQL command file. + */ + public CommandResult executeSQLFile(String commandFile) throws Exception { + String sparkShellCmd = new StringBuilder() + .append("spark-shell --jars ").append(TestcontainersConfig.Paths.SPARK_BUNDLE) + .append(" --master local[2] --driver-class-path ").append(TestcontainersConfig.Paths.HADOOP_CONF_DIR) + .append(" --conf spark.serializer=org.apache.spark.serializer.KryoSerializer") + .append(" --conf spark.sql.catalog.spark_catalog=org.apache.spark.sql.hudi.catalog.HoodieCatalog") + .append(" --conf spark.sql.extensions=org.apache.spark.sql.hudi.HoodieSparkSessionExtension") + .append(" --deploy-mode client --driver-memory 1G --executor-memory 1G --num-executors 1") + // Pipe via stdin instead of `-i`. With `-i`, Spark 4's REPL boots in + // dumb-terminal mode (`WARN jline: Unable to create a system terminal, + // creating a dumb terminal`) and silently runs the script — neither + // input echo nor exception traces reach stdout, so success markers and + // failures alike are invisible to assertStdOutContains. + .append(" < ").append(commandFile) + .toString(); + + return executor.executeCommandString(sparkShellCmd); + } + + /** + * Executes an arbitrary shell command inside the service's container. + * This is useful for hdfs commands, hudi-cli, etc. + * + * @param command The shell command to execute. + * @return The result of the command execution. + */ + public CommandResult executeShellCommand(String command) throws Exception { + return executor.executeCommandString(command); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/TrinoService.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/TrinoService.java new file mode 100644 index 0000000000000..4eb3398c7e1f2 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/TrinoService.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.hudi.integ2.testcontainers.service; + +import org.apache.hudi.integ2.testcontainers.ContainerProvider; +import org.apache.hudi.integ2.testcontainers.TestcontainersConfig; +import org.apache.hudi.integ2.testcontainers.command.CommandExecutor; +import org.apache.hudi.integ2.testcontainers.command.CommandResult; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.ContainerState; + +/** + * Service wrapper for the Trino coordinator. Mirrors {@link HiveService} in shape, but + * execs the bundled {@code trino} CLI inside the coordinator container itself rather + * than from an adhoc Spark container. The coordinator runs a hudi-built image based on + * {@code trinodb/trino:481}, which bundles a modern JDK; the adhoc Spark images do not, + * so {@code execInContainer("trino", ...)} on the coordinator is the reliable way to + * run Trino 481's CLI. + * + *

    The default catalog is {@code hudi} (the native trino-hudi connector registered + * by {@code HudiConnectorFactory#getName}) and the default schema is {@code default}. + * + *

    Output format is {@code CSV_UNQUOTED} so substring assertions stay simple and + * match the existing {@link HiveService} ergonomics. + */ +@Slf4j +public class TrinoService { + + private static final String CLI = "trino"; + private static final String SERVER = "localhost:" + TestcontainersConfig.Network.TRINO_PORT; + private static final String DEFAULT_CATALOG = "hudi"; + private static final String DEFAULT_SCHEMA = "default"; + + private final CommandExecutor executor; + private final ContainerState container; + + public TrinoService(ContainerProvider provider) { + this.container = provider.getContainer(TestcontainersConfig.Containers.TRINO_COORDINATOR); + this.executor = new CommandExecutor(container); + } + + /** + * Execute a single Trino SQL statement against the default {@code hudi.default} + * catalog/schema. Returns a {@link CommandResult} so the fluent assertions used by + * other services apply unchanged. + */ + public CommandResult execute(String sql) throws Exception { + return execute(DEFAULT_CATALOG, DEFAULT_SCHEMA, sql); + } + + /** + * Execute a single Trino SQL statement against an explicit catalog/schema. Useful + * for {@code SHOW CATALOGS} or cross-catalog probes where the schema is irrelevant. + */ + public CommandResult execute(String catalog, String schema, String sql) throws Exception { + String[] cmd = { + CLI, + "--server", SERVER, + "--catalog", catalog, + "--schema", schema, + "--output-format", "CSV_UNQUOTED", + // On failure the CLI prints the full server-side stack to stderr, which + // CommandResult embeds in the assertion message. No effect on success output. + "--debug", + "--execute", sql + }; + return executor.executeCommand(cmd); + } + + /** + * Block until the coordinator is ready to serve queries. Trino reports a healthy + * HTTP {@code /v1/info} well before plugin discovery finishes, so the cheapest + * reliable readiness probe is to actually issue a query. + */ + public void waitUntilReady() throws Exception { + int max = TestcontainersConfig.Timeouts.TRINO_READY_MAX_RETRIES; + long sleepMs = TestcontainersConfig.Timeouts.TRINO_READY_RETRY_INTERVAL.toMillis(); + for (int i = 1; i <= max; i++) { + try { + execute("system", "runtime", "SELECT 1").expectToSucceed(); + log.info("Trino coordinator is ready (attempt {}/{})", i, max); + return; + } catch (Throwable t) { + if (!container.isRunning()) { + // No point retrying against a dead container; the boot log streamed by the + // ITTestBaseTestcontainers log consumer holds the root cause. + throw new RuntimeException( + "trinocoordinator container is not running -- Trino likely crashed at startup;" + + " see the trinocoordinator-prefixed log lines above", t); + } + if (i == max) { + throw new RuntimeException( + "Trino coordinator did not become ready after " + max + " retries", t); + } + log.info("Waiting for Trino coordinator to be ready (attempt {}/{}): {}", i, max, t.getMessage()); + Thread.sleep(sleepMs); + } + } + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoCustomType.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoCustomType.java new file mode 100644 index 0000000000000..677ee43cd21f5 --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoCustomType.java @@ -0,0 +1,369 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.integ2.testcontainers.trino; + +import org.apache.hudi.integ2.testcontainers.ITTestBaseTestcontainers; +import org.apache.hudi.integ2.testcontainers.ITTestCustomTypeHiveSync; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Paths; + +/** + * Trino read coverage for Hudi's custom logical types (BLOB struct, VECTOR + * fixed_len_byte_array, VARIANT), complementing {@link ITTestCustomTypeHiveSync} + * which asserts the same fixtures round-trip through the Hive serde. A flip in + * either direction (e.g. VECTOR decoded as array<float> instead of + * binary, BLOB struct field projection broken, VARIANT row count off) shows up + * here. + * + *

    This test reuses the same {@code sparksql-*-sql.commands} fixtures that + * {@code ITTestCustomTypeHiveSync} drives. The two run in either order without + * cross-contamination because {@code tearDownDockerCompose} drops the whole + * stack between classes, so each starts against a fresh HDFS and metastore and + * re-seeds its own fixtures. + * + *

    The {@code trinocoordinator} service exists only in the spark402 compose + * pair, so this class always runs on a Spark 4.x stack and the VARIANT coverage + * is unconditional. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ITTestTrinoCustomType extends ITTestBaseTestcontainers { + + private static final String BLOB_TEST_PATH = "/user/hive/warehouse/blob_test"; + private static final String BLOB_TEST_DF_PATH = "/user/hive/warehouse/blob_test_df"; + private static final String VECTOR_TEST_PATH = "/user/hive/warehouse/vector_test"; + private static final String VARIANT_TEST_PATH = "/user/hive/warehouse/variant_test"; + private static final String SPARKSQL_BLOB_TYPE_SQL_COMMANDS = + Paths.DEMO_DIR + "/sparksql-blob-type-sql.commands"; + private static final String SPARKSQL_BLOB_TYPE_DF_COMMANDS = + Paths.DEMO_DIR + "/sparksql-blob-type-df.commands"; + private static final String SPARKSQL_VECTOR_TYPE_SQL_COMMANDS = + Paths.DEMO_DIR + "/sparksql-vector-type-sql.commands"; + private static final String SPARKSQL_VARIANT_TYPE_SQL_COMMANDS = + Paths.DEMO_DIR + "/sparksql-variant-type-sql.commands"; + + @BeforeAll + public void setupOnce() throws Exception { + assumeTrinoProfile(); + initializeServices(); + waitForHdfs(); + sparkAdhoc1.executeShellCommand("/bin/bash " + Paths.DEMO_SETUP).expectToSucceed(); + sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("BLOB_SQL_TEST_SUCCESS"); + // The DF fixture writes blob_test_df with the INLINE branch of the BLOB struct + // (data field non-null, reference null). The SQL fixture exercises only the + // OUT_OF_LINE branch, so seeding both gives Trino read coverage of both shapes. + sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_DF_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("BLOB_DF_TEST_SUCCESS"); + sparkAdhoc1.executeSQLFile(SPARKSQL_VECTOR_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VECTOR_SQL_TEST_SUCCESS"); + sparkAdhoc1.executeSQLFile(SPARKSQL_VARIANT_TYPE_SQL_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("VARIANT_SQL_TEST_SUCCESS"); + trino.waitUntilReady(); + } + + @AfterAll + public void clean() throws Exception { + // JUnit runs @AfterAll even when the @BeforeAll assumption aborted setupOnce() + // before initializeServices(); nothing was seeded then, so nothing to clean. + if (sparkAdhoc1 == null) { + return; + } + // -f silently skips non-existent paths so cleanup is safe even when seeding + // failed partway and some of these tables were never created. + sparkAdhoc1.executeShellCommand("hdfs dfs -rm -R -f " + + BLOB_TEST_PATH + " " + BLOB_TEST_DF_PATH + " " + + VECTOR_TEST_PATH + " " + VARIANT_TEST_PATH).expectToSucceed(); + } + + // ---------- BLOB OUT_OF_LINE (blob_test) ---------- + + @Test + public void testTrinoCountBlob() throws Exception { + // Post-DELETE state of sparksql-blob-type-sql.commands is 2 rows (id=1 updated, + // id=2 merged, id=3 inserted then deleted) - parity with the Hive count assertion + // in ITTestCustomTypeHiveSync#testBlobTypeWithHiveSyncSQL. + trino.execute("SELECT count(*) FROM blob_test") + .expectToSucceed() + .assertStdOutContains("2"); + } + + @Test + public void testTrinoProjectsBlobUpdatedRow() throws Exception { + // Full per-row shape for id=1 (post-UPDATE state): type discriminator + + // every reference subfield + the OUT_OF_LINE invariant that data IS NULL. + // One query, one substring assertion - catches column-order shifts, + // nested-struct field renames, and per-field decoding bugs. + trino.execute("SELECT blob_data.type, blob_data.data IS NULL, " + + "blob_data.reference.external_path, blob_data.reference.offset, " + + "blob_data.reference.length, blob_data.reference.managed " + + "FROM blob_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("OUT_OF_LINE,true,blobs/updated-1,10,100,true"); + } + + @Test + public void testTrinoProjectsBlobMergedRow() throws Exception { + // id=2 was MATCHED by the MERGE clause and rewritten to 'blobs/merged-2'. + // Same full-shape assertion as id=1 - confirms both UPDATE and MERGE write + // paths land at an identical on-disk OUT_OF_LINE shape. + trino.execute("SELECT blob_data.type, blob_data.data IS NULL, " + + "blob_data.reference.external_path, blob_data.reference.offset, " + + "blob_data.reference.length, blob_data.reference.managed " + + "FROM blob_test WHERE id = 2") + .expectToSucceed() + .assertStdOutContains("OUT_OF_LINE,true,blobs/merged-2,20,200,true"); + } + + @Test + public void testTrinoBlobDeletedRowAbsent() throws Exception { + // id=3 was MERGE-inserted into dt=2024-01-02 then DELETEd. A DELETE that + // leaves the row visible (e.g. tombstone not honored on read) shows up as + // count = 1 here. Pairs with testTrinoCountBlob = 2 (total post-delete) + // to catch the case where DELETE silently no-ops. + trino.execute("SELECT count(*) FROM blob_test WHERE id = 3") + .expectToSucceed() + .assertStdOutContains("0"); + } + + @Test + public void testTrinoBlobEmptiedPartitionInvisible() throws Exception { + // MERGE created dt=2024-01-02 (id=3), then DELETE emptied it. Grouping by the + // partition column must return exactly the data-bearing partition with its + // full row count. Not a partition-pruning assertion: GROUP BY only emits + // groups for partitions that still hold rows, and dt=2024-01-02 stays + // registered in the metastore either way (see ITTestCustomTypeHiveSync). + // What it does catch is a mis-decoded partition column, or a deleted row + // resurfacing under its old partition. + trino.execute("SELECT dt, count(*) FROM blob_test GROUP BY dt ORDER BY dt") + .expectToSucceed() + .assertStdOutContains("2024-01-01,2") + .assertStdOutContains("2024-01-02", 0); + } + + // ---------- BLOB INLINE (blob_test_df) ---------- + + @Test + public void testTrinoCountBlobInline() throws Exception { + // Post-DELETE state of sparksql-blob-type-df.commands is 2 rows (id=1 kept, + // id=2 updated to "updated payload", id=3 upserted then deleted). Parity + // with testTrinoCountBlob for the INLINE-shape table. + trino.execute("SELECT count(*) FROM blob_test_df") + .expectToSucceed() + .assertStdOutContains("2"); + } + + @Test + public void testTrinoProjectsBlobInlineRow() throws Exception { + // INLINE shape for id=1 (never mutated): type=INLINE, data is the UTF-8 + // seed "hello world", and the INLINE invariant that reference IS NULL. + // from_utf8(data) is the right decoder - raw cast(varbinary as varchar) + // is rejected by Trino. + trino.execute("SELECT blob_data.type, from_utf8(blob_data.data), " + + "blob_data.reference IS NULL FROM blob_test_df WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("INLINE,hello world,true"); + } + + @Test + public void testTrinoProjectsBlobInlineUpdatedRow() throws Exception { + // id=2 was UPSERT-rewritten to "updated payload" in the DF fixture. Same + // full-shape assertion as id=1 - confirms the UPSERT write path for the + // INLINE branch round-trips end-to-end through Trino. + trino.execute("SELECT blob_data.type, from_utf8(blob_data.data), " + + "blob_data.reference IS NULL FROM blob_test_df WHERE id = 2") + .expectToSucceed() + .assertStdOutContains("INLINE,updated payload,true"); + } + + @Test + public void testTrinoBlobInlineDeletedRowAbsent() throws Exception { + // id=3 was upserted into dt=2024-01-02 then DELETEd in the DF fixture. + // Parity with testTrinoBlobDeletedRowAbsent for the INLINE-shape table. + trino.execute("SELECT count(*) FROM blob_test_df WHERE id = 3") + .expectToSucceed() + .assertStdOutContains("0"); + } + + // ---------- VECTOR (vector_test) ---------- + + @Test + public void testTrinoCountVector() throws Exception { + // Post-DELETE state of sparksql-vector-type-sql.commands is 2 rows. + // Parity with testTrinoCountBlob / testTrinoCountVariant. + trino.execute("SELECT count(*) FROM vector_test") + .expectToSucceed() + .assertStdOutContains("2"); + } + + @Test + public void testTrinoVectorDeletedRowAbsent() throws Exception { + // id=3 was MERGE-inserted then DELETEd in the vector fixture. Parity with + // the BLOB/VARIANT delete-absence checks. + trino.execute("SELECT count(*) FROM vector_test WHERE id = 3") + .expectToSucceed() + .assertStdOutContains("0"); + } + + @Test + public void testTrinoVectorRoundTripsAsBinary() throws Exception { + // Per RFC-99, VECTOR(3) is stored on disk as fixed_len_byte_array(12) and Hive + // sync maps it to BINARY. The native plugin should expose the column as VARBINARY + // of the same 12 bytes (3 floats * 4 bytes). length() returning 12 confirms the + // round-trip; a return of 3 would mean the plugin decoded it as array, + // a real regression worth a separate ticket. Pairs with the Hive assertion at + // ITTestCustomTypeHiveSync:228-235. + trino.execute("SELECT length(embedding) FROM vector_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("12"); + } + + @Test + public void testTrinoVectorBytesDecodeToExpectedFloats() throws Exception { + // The 12 bytes of VECTOR(3) are 3 IEEE-754 floats in little-endian (Parquet's + // default). reverse(substr(..., n, 4)) flips each 4-byte chunk to big-endian + // so from_ieee754_32 returns the actual value. round(., 1) sidesteps float- + // precision noise in Trino's CSV rendering (0.9f decodes to ~0.90000004). + // For id=1's post-UPDATE state the fixture writes (0.9f, 0.8f, 0.7f). + trino.execute("SELECT round(from_ieee754_32(reverse(substr(embedding, 1, 4))), 1), " + + "round(from_ieee754_32(reverse(substr(embedding, 5, 4))), 1), " + + "round(from_ieee754_32(reverse(substr(embedding, 9, 4))), 1) " + + "FROM vector_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("0.9,0.8,0.7"); + } + + @Test + public void testTrinoVectorMergedRowDecodes() throws Exception { + // id=2 was MATCHED by the MERGE clause and rewritten to (0.41f, 0.51f, 0.61f). + // round(., 2) keeps it readable; complements id=1's UPDATE path to confirm + // both write paths land at the same on-disk layout. + trino.execute("SELECT round(from_ieee754_32(reverse(substr(embedding, 1, 4))), 2), " + + "round(from_ieee754_32(reverse(substr(embedding, 5, 4))), 2), " + + "round(from_ieee754_32(reverse(substr(embedding, 9, 4))), 2) " + + "FROM vector_test WHERE id = 2") + .expectToSucceed() + .assertStdOutContains("0.41,0.51,0.61"); + } + + @Test + public void testTrinoVectorAllRowsAreFixedLength12() throws Exception { + // Invariant: every row's embedding is exactly 12 bytes. count(DISTINCT length(...)) + // = 1 AND min/max = 12 catches the case where some rows decode at a different + // width (e.g. an older row written before a layout fix). Stronger than the + // single-row length() check in testTrinoVectorRoundTripsAsBinary. + trino.execute("SELECT count(DISTINCT length(embedding)), min(length(embedding)), " + + "max(length(embedding)) FROM vector_test") + .expectToSucceed() + .assertStdOutContains("1,12,12"); + } + + // ---------- VARIANT (variant_test) ---------- + + @Test + public void testTrinoCountVariant() throws Exception { + // Post-DELETE state of sparksql-variant-type-sql.commands is 2 rows (id=1 + // updated, id=2 merged, id=3 inserted then deleted) - parity with the Hive + // count assertion in ITTestCustomTypeHiveSync#testVariantTypeWithHiveSyncSQL. + // count(*) doesn't deserialize the variant column so it's safe even if the + // plugin's variant decoding has gaps. + trino.execute("SELECT count(*) FROM variant_test") + .expectToSucceed() + .assertStdOutContains("2"); + } + + @Test + public void testTrinoIntrospectsVariantColumn() throws Exception { + // Schema-level smoke: the variant_data column must surface in Trino's view of + // the table. Catches metastore-side regressions where Hive sync drops or + // mistypes the variant column entirely, separately from the + // value-projection question (which depends on how the plugin maps VARIANT). + trino.execute("DESCRIBE variant_test") + .expectToSucceed() + .assertStdOutContains("variant_data"); + } + + @Test + public void testTrinoProjectsVariantValue() throws Exception { + // The native trino-hudi plugin exposes VARIANT as ROW(metadata VARBINARY, + // value VARBINARY) with no top-level JSON/string decoding. But Spark's + // Variant binary format stores leaf string values as UTF-8 bytes inside + // the value component, so we can decode the bytes with from_utf8() and + // LIKE-match the seeded payload. Non-UTF8 framing bytes around the leaf + // become U+FFFD replacement chars, which the wildcard tolerates. This is + // real content round-trip: write {"key":"value1-updated"}, read the same + // payload back through the plugin. + trino.execute("SELECT from_utf8(variant_data.value) LIKE '%value1-updated%' " + + "FROM variant_test WHERE id = 1") + .expectToSucceed() + .assertStdOutContains("true"); + } + + @Test + public void testTrinoProjectsVariantMergedRow() throws Exception { + // Same content-level round-trip for the MERGE-rewritten row. id=2's seed + // is {"key":"value2-merged"}; verifying that exact payload survives the + // MERGE write path -> hive sync -> Trino read end-to-end. + trino.execute("SELECT from_utf8(variant_data.value) LIKE '%value2-merged%' " + + "FROM variant_test WHERE id = 2") + .expectToSucceed() + .assertStdOutContains("true"); + } + + @Test + public void testTrinoVariantValuesDifferAcrossRows() throws Exception { + // Invariant: id=1 ({"key":"value1-updated"}) and id=2 ({"key":"value2-merged"}) + // must produce different value bytes. Trino's count(DISTINCT ...) supports + // VARBINARY natively, so no base64 wrapping is needed. Catches the + // "projection returns same bytes for every row" regression that per-row + // content matches would silently miss. + trino.execute("SELECT count(DISTINCT variant_data.value) FROM variant_test") + .expectToSucceed() + .assertStdOutContains("2"); + } + + @Test + public void testTrinoVariantDeletedRowAbsent() throws Exception { + // id=3 was MERGE-inserted into dt=2024-01-02 then DELETEd. Mirrors + // testTrinoBlobDeletedRowAbsent for the variant table - catches the case + // where DELETE silently no-ops on a Variant-bearing row. + trino.execute("SELECT count(*) FROM variant_test WHERE id = 3") + .expectToSucceed() + .assertStdOutContains("0"); + } + + @Test + public void testTrinoVariantEmptiedPartitionInvisible() throws Exception { + // Same shape as the BLOB table: dt=2024-01-02 held only id=3, so after the + // DELETE the grouped read must return the data-bearing partition alone. As + // above this asserts per-partition row visibility, not partition pruning. + trino.execute("SELECT dt, count(*) FROM variant_test GROUP BY dt ORDER BY dt") + .expectToSucceed() + .assertStdOutContains("2024-01-01,2") + .assertStdOutContains("2024-01-02", 0); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoSmoke.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoSmoke.java new file mode 100644 index 0000000000000..2c5b44268189a --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoSmoke.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.hudi.integ2.testcontainers.trino; + +import org.apache.hudi.integ2.testcontainers.ITTestBaseTestcontainers; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Smoke coverage for the native trino-hudi connector running inside the integ2 + * testcontainers harness. Cheapest signal that the plugin loaded, the metastore + * is reachable, and the CLI can round-trip a query. + * + *

    Skipped unless the {@code trino} compose profile is active (see + * {@link #assumeTrinoProfile()}), since the coordinator container only starts then. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ITTestTrinoSmoke extends ITTestBaseTestcontainers { + + @BeforeAll + public void setupOnce() throws Exception { + assumeTrinoProfile(); + initializeServices(); + trino.waitUntilReady(); + } + + @Test + public void testShowCatalogsListsHudi() throws Exception { + // Asserts the native plugin registered. With connector.name=hudi (per + // HudiConnectorFactory#getName) the catalog appears under that name; if the + // plugin failed to load the catalog file would have made Trino fail to start + // and we will never reach this assertion. + trino.execute("system", "runtime", "SHOW CATALOGS") + .expectToSucceed() + .assertStdOutContains("hudi"); + } + + @Test + public void testShowSchemasFromHudiReachesMetastore() throws Exception { + // The `default` schema is created by Hive at first contact with the metastore. + // Asserting it appears here proves the connector can talk to thrift://hivemetastore:9083. + trino.execute("hudi", "default", "SHOW SCHEMAS") + .expectToSucceed() + .assertStdOutContains("default"); + } + + @Test + public void testSelectOneRoundtrip() throws Exception { + trino.execute("system", "runtime", "SELECT 1") + .expectToSucceed() + .assertStdOutContains("1"); + } +} diff --git a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoStockTicks.java b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoStockTicks.java new file mode 100644 index 0000000000000..43dfdd18134ea --- /dev/null +++ b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/trino/ITTestTrinoStockTicks.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.hudi.integ2.testcontainers.trino; + +import org.apache.hudi.integ2.testcontainers.ITTestBaseTestcontainers; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Paths; + +/** + * End-to-end coverage that the native trino-hudi connector can read both COW and + * MOR tables that came from Spark + Hive sync. Mirrors the retired + * {@code docker/demo/trino-batch1.commands} demo flow (removed together with the + * rest of the legacy trino-coordinator path) but uses a self-contained spark-sql + * fixture (see {@code sparksql-stock-ticks-trino.commands}) instead of the full + * Kafka/streaming pipeline, which integ2 doesn't otherwise exercise. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ITTestTrinoStockTicks extends ITTestBaseTestcontainers { + + private static final String STOCK_TICKS_COW_PATH = "/user/hive/warehouse/stock_ticks_cow"; + private static final String STOCK_TICKS_MOR_PATH = "/user/hive/warehouse/stock_ticks_mor"; + private static final String SPARKSQL_STOCK_TICKS_COMMANDS = + Paths.DEMO_DIR + "/sparksql-stock-ticks-trino.commands"; + + @BeforeAll + public void setupOnce() throws Exception { + assumeTrinoProfile(); + initializeServices(); + waitForHdfs(); + sparkAdhoc1.executeShellCommand("/bin/bash " + Paths.DEMO_SETUP).expectToSucceed(); + sparkAdhoc1.executeSQLFile(SPARKSQL_STOCK_TICKS_COMMANDS) + .expectToSucceed() + .assertStdOutContainsLine("STOCK_TICKS_COW_SETUP_SUCCESS") + .assertStdOutContainsLine("STOCK_TICKS_MOR_SETUP_SUCCESS") + .assertStdOutContainsLine("STOCK_TICKS_TRINO_SETUP_SUCCESS"); + trino.waitUntilReady(); + } + + @AfterAll + public void clean() throws Exception { + // JUnit runs @AfterAll even when the @BeforeAll assumption aborted setupOnce() + // before initializeServices(); nothing was seeded then, so nothing to clean. + if (sparkAdhoc1 == null) { + return; + } + sparkAdhoc1.executeShellCommand("hdfs dfs -rm -R -f " + + STOCK_TICKS_COW_PATH + " " + STOCK_TICKS_MOR_PATH).expectToSucceed(); + } + + // ---------- Queries reproduced from the retired docker/demo/trino-batch1.commands ---------- + + @Test + public void testTrinoReadsCowMaxTs() throws Exception { + // Original: select symbol, max(ts) from stock_ticks_cow group by symbol HAVING symbol = 'GOOG' + trino.execute("SELECT symbol, max(ts) FROM stock_ticks_cow GROUP BY symbol HAVING symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG") + .assertStdOutContains("2018-08-31 10:29:00"); + } + + @Test + public void testTrinoReadsMorRoMaxTs() throws Exception { + // Hive sync produces stock_ticks_mor_ro (RO view of base files). The fixture's + // UPDATE (ts 10:59:00) lives only in a log file, so _ro must keep serving the + // 10:29:00 base row - a 10:59:00 here means log records leaked into the RO view. + trino.execute("SELECT symbol, max(ts) FROM stock_ticks_mor_ro GROUP BY symbol HAVING symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG") + .assertStdOutContains("2018-08-31 10:29:00") + .assertStdOutContains("2018-08-31 10:59:00", 0); + } + + @Test + public void testTrinoReadsCowProjectedColumns() throws Exception { + // open == close == 1230.50 in the seed row, so "1230.5" appears twice in CSV output. + trino.execute("SELECT symbol, ts, volume, open, close FROM stock_ticks_cow WHERE symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG,2018-08-31 10:29:00,6330,1230.5,1230.5"); + } + + @Test + public void testTrinoReadsMorRoProjectedColumns() throws Exception { + // Same symbol-count pin as the _rt case below: the base row assert on its own + // would still pass with the log row returned next to it. + trino.execute("SELECT symbol, ts, volume, open, close FROM stock_ticks_mor_ro WHERE symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG,2018-08-31 10:29:00,6330,1230.5,1230.5") + .assertStdOutContains("GOOG", 1); + } + + @Test + public void testTrinoReadsMorRtMergedMaxTs() throws Exception { + // The fixture's UPDATE lands as a log-only delta; the _rt view must merge it + // on read. Pairs with testTrinoReadsMorRoMaxTs pinning _ro to the base row, + // so together they prove the connector takes different read paths for the + // two views instead of serving base files for both. + trino.execute("SELECT symbol, max(ts) FROM stock_ticks_mor_rt GROUP BY symbol HAVING symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG") + .assertStdOutContains("2018-08-31 10:59:00"); + } + + @Test + public void testTrinoReadsMorRtMergedProjectedColumns() throws Exception { + // Full merged row: every non-key column must come from the log record. The row + // assert alone would still pass if the base row came back alongside it, so pin + // the symbol count too - exactly one GOOG row may survive the merge. + trino.execute("SELECT symbol, ts, volume, open, close FROM stock_ticks_mor_rt WHERE symbol = 'GOOG'") + .expectToSucceed() + .assertStdOutContains("GOOG,2018-08-31 10:59:00,9021,1227.25,1227.5") + .assertStdOutContains("GOOG", 1); + } +} diff --git a/hudi-io/pom.xml b/hudi-io/pom.xml index d431354de4a0e..dbc0d74f46a20 100644 --- a/hudi-io/pom.xml +++ b/hudi-io/pom.xml @@ -185,7 +185,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl ${log4j2.version} provided diff --git a/hudi-io/src/main/java/org/apache/hudi/common/metrics/Registry.java b/hudi-io/src/main/java/org/apache/hudi/common/metrics/Registry.java index 7373481c44aa4..65f4496597c7d 100644 --- a/hudi-io/src/main/java/org/apache/hudi/common/metrics/Registry.java +++ b/hudi-io/src/main/java/org/apache/hudi/common/metrics/Registry.java @@ -98,13 +98,12 @@ static Registry getRegistryOfClass(String tableName, String registryName, String Registry registry = REGISTRY_MAP.computeIfAbsent(key, k -> { String registryFullName = tableName.isEmpty() ? registryName : tableName + "." + registryName; Registry r = (Registry) ReflectionUtils.loadClass(clazz, registryFullName); - LOG.info("Created a new registry " + r); + LOG.info("Created a new registry {}", r); return r; }); if (!registry.getClass().getName().equals(clazz)) { - LOG.error("Registry with name " + registryName + " already exists with a different class " + registry.getClass().getName() - + " than the requested class " + clazz); + LOG.error("Registry with name {} already exists with a different class {} than the requested class {}", registryName, registry.getClass().getName(), clazz); } return registry; } diff --git a/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java b/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java index 49c3e6c9dea3c..6a6bd436ab419 100644 --- a/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java +++ b/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java @@ -50,7 +50,7 @@ public static Class getClass(String clazzName) { try { return Class.forName(c); } catch (ClassNotFoundException e) { - throw new HoodieException("Unable to load class", e); + throw new HoodieException("Unable to load class " + c, e); } }); } @@ -136,7 +136,7 @@ public static Stream getTopLevelClassesInClasspath(Class clazz) { try { resources = classLoader.getResources(path); } catch (IOException e) { - log.error("Unable to fetch Resources in package " + e.getMessage()); + log.error("Unable to fetch Resources in package {}", packageName, e); } List directories = new ArrayList<>(); while (Objects.requireNonNull(resources).hasMoreElements()) { @@ -144,7 +144,7 @@ public static Stream getTopLevelClassesInClasspath(Class clazz) { try { directories.add(new File(resource.toURI())); } catch (URISyntaxException e) { - log.error("Unable to get " + e.getMessage()); + log.error("Unable to get URI for {}", resource, e); } } List classes = new ArrayList<>(); diff --git a/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java b/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java index 3c5545a6bf71c..8d72a20d71993 100644 --- a/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java +++ b/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java @@ -21,9 +21,11 @@ import javax.annotation.Nullable; +import java.io.Serializable; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -119,6 +121,53 @@ public static byte[] getUTF8Bytes(String str) { return str.getBytes(StandardCharsets.UTF_8); } + /** + * Serializable comparator ordering strings by their unsigned UTF-8 byte representation. See + * {@link #compareUtf8Bytes(String, String)} for the rationale and null-handling contract. + */ + public static final Comparator UTF8_LEXICOGRAPHIC_COMPARATOR = + (Comparator & Serializable) StringUtils::compareUtf8Bytes; + + /** + * Compares two strings by their unsigned UTF-8 byte order, matching the ordering HFiles enforce + * (HBase's {@code CellComparatorImpl}). Unlike {@link String#compareTo(String)} (UTF-16 code unit + * order), this stays consistent with HFile ordering for non-ASCII / binary keys. + * + *

    Neither argument may be {@code null}; like {@link String#compareTo(String)}, a {@code null} + * argument throws {@link NullPointerException}. + * + *

    This comparison does not materialize the UTF-8 byte arrays. It compares UTF-16 code units + * directly and handles supplementary characters specially to preserve UTF-8 byte order. + * + *

    Assumes well-formed UTF-16 input. For strings containing unpaired surrogates the result no + * longer matches {@code String#getBytes(UTF_8)} byte order: the encoder replaces an unpaired + * surrogate with {@code '?'} while this method sorts it after every BMP character. Production + * callers derive keys by decoding UTF-8, which cannot produce unpaired surrogates. + * + *

    Ported from Google Firebase Firestore's {@code compareUtf8Strings}. + */ + public static int compareUtf8Bytes(String s1, String s2) { + // Source: https://github.com/firebase/firebase-android-sdk/blob/f05e4bcb7f86f3b21833b1e0960d793b800d38d1/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java#L76-L132 + // The identity check intentionally avoids scanning when both references point to the same + // non-null String while preserving the method's fail-fast null contract. + if (s1 == s2 && s1 != null) { + return 0; + } + + final int length = Math.min(s1.length(), s2.length()); + for (int i = 0; i < length; i++) { + final char char1 = s1.charAt(i); + final char char2 = s2.charAt(i); + if (char1 != char2) { + return (Character.isSurrogate(char1) == Character.isSurrogate(char2)) + ? Character.compare(char1, char2) + : Character.isSurrogate(char1) ? 1 : -1; + } + } + + return Integer.compare(s1.length(), s2.length()); + } + public static String fromUTF8Bytes(byte[] bytes) { return fromUTF8Bytes(bytes, 0, bytes.length); } diff --git a/hudi-io/src/main/java/org/apache/hudi/common/util/ValidationUtils.java b/hudi-io/src/main/java/org/apache/hudi/common/util/ValidationUtils.java index 1cf2d977bccbf..94384ff30247b 100644 --- a/hudi-io/src/main/java/org/apache/hudi/common/util/ValidationUtils.java +++ b/hudi-io/src/main/java/org/apache/hudi/common/util/ValidationUtils.java @@ -80,11 +80,16 @@ public static void checkState(final boolean expression, String errorMessage) { } /** - * Ensures the truth of an expression, throwing the custom errorMessage otherwise. + * Ensures the truth of an expression involving the state of the calling instance, but not + * involving any parameters to the calling method. + * + * @param expression a boolean expression + * @param errorMessageSupplier supplies the error message, evaluated only when the check fails + * @throws IllegalStateException if {@code expression} is false */ public static void checkState(final boolean expression, final Supplier errorMessageSupplier) { if (!expression) { - throw new IllegalArgumentException(errorMessageSupplier.get()); + throw new IllegalStateException(errorMessageSupplier.get()); } } } diff --git a/hudi-io/src/main/java/org/apache/hudi/io/hfile/HFileDataBlock.java b/hudi-io/src/main/java/org/apache/hudi/io/hfile/HFileDataBlock.java index cbf3e719f6a02..551cfd2c961b6 100644 --- a/hudi-io/src/main/java/org/apache/hudi/io/hfile/HFileDataBlock.java +++ b/hudi-io/src/main/java/org/apache/hudi/io/hfile/HFileDataBlock.java @@ -53,7 +53,9 @@ public class HFileDataBlock extends HFileBlock { // so the latest timestamp is used. private static final long LATEST_TIMESTAMP = Long.MAX_VALUE; - // End offset of content in the block, relative to the start of the start of the block + // End offset of content in the block, relative to the start of the block. The key-values + // occupy exactly uncompressedSizeWithoutHeader bytes after the header; the checksum trails + // the content and is not part of it, so it must not be subtracted here. protected final int uncompressedContentEndRelativeOffset; private final List entriesToWrite = new ArrayList<>(); @@ -64,7 +66,7 @@ protected HFileDataBlock(HFileContext context, super(context, HFileBlockType.DATA, byteBuff, startOffsetInBuff); this.uncompressedContentEndRelativeOffset = - this.uncompressedEndOffset - this.sizeCheckSum - this.startOffsetInBuff; + this.uncompressedEndOffset - this.startOffsetInBuff; } // For write purpose. diff --git a/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java b/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java index 43a547744baa2..4d35654b0baf5 100644 --- a/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java +++ b/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java @@ -19,14 +19,21 @@ package org.apache.hudi.common.util; +import org.apache.hudi.io.hfile.UTF8StringKey; + import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -268,4 +275,133 @@ public void testStripEnd() { assertEquals("abc", StringUtils.stripEnd("abc", "")); assertEquals("abc", StringUtils.stripEnd("abcabab", "ab")); } + + @Test + public void testCompareUtf8BytesAsciiMatchesStringCompareTo() { + // Pure ASCII bytes equal their UTF-16 code unit values, so UTF-8 byte order and + // String.compareTo order coincide. + assertEquals(Integer.signum("apple".compareTo("banana")), Integer.signum(StringUtils.compareUtf8Bytes("apple", "banana"))); + assertEquals(Integer.signum("banana".compareTo("apple")), Integer.signum(StringUtils.compareUtf8Bytes("banana", "apple"))); + assertEquals(0, StringUtils.compareUtf8Bytes("apple", "apple")); + } + + @Test + public void testCompareUtf8BytesSupplementaryPairFlipsOrderVsStringCompareTo() { + // U+E000 (BMP private-use, UTF-8 lead byte 0xEE) vs U+20000 (supplementary plane, UTF-8 lead byte + // 0xF0). In UTF-16, U+20000 is encoded as a surrogate pair starting with 0xD840, which is < 0xE000, + // so String.compareTo orders U+20000 first. In UTF-8 byte order 0xF0 > 0xEE, flipping the order -- + // exactly the pathological shape that breaks HFile's forward-only seek under String.compareTo. + String bmpPrivateUse = new String(Character.toChars(0xE000)); + String supplementary = new String(Character.toChars(0x20000)); + + assertTrue(bmpPrivateUse.compareTo(supplementary) > 0, + "String.compareTo should order U+E000 after U+20000 (UTF-16 code unit order)"); + assertTrue(StringUtils.compareUtf8Bytes(bmpPrivateUse, supplementary) < 0, + "compareUtf8Bytes should order U+E000 before U+20000 (UTF-8 byte order)"); + } + + @Test + public void testCompareUtf8BytesEmptyPrefixAndIdenticalStrings() { + assertTrue(StringUtils.compareUtf8Bytes("", "a") < 0); + assertTrue(StringUtils.compareUtf8Bytes("a", "") > 0); + assertEquals(0, StringUtils.compareUtf8Bytes("", "")); + assertTrue(StringUtils.compareUtf8Bytes("ab", "abc") < 0); + assertTrue(StringUtils.compareUtf8Bytes("abc", "ab") > 0); + assertEquals(0, StringUtils.compareUtf8Bytes("abc", "abc")); + assertEquals(0, StringUtils.compareUtf8Bytes(new String("abc"), new String("abc"))); + } + + @Test + public void testCompareUtf8BytesDocumentsUnpairedSurrogateBehavior() { + String unpairedSurrogate = String.valueOf((char) 0xD800); + String replacementCharacter = String.valueOf((char) 0xFFFD); + + // Java's UTF-8 encoder replaces the unpaired surrogate with '?' while the Firestore-derived + // comparator orders all surrogate code units after BMP characters. Production callers decode + // UTF-8 into well-formed UTF-16, so malformed strings are outside this method's contract. + assertTrue(StringUtils.compareUtf8Bytes(unpairedSurrogate, replacementCharacter) > 0); + assertTrue(StringUtils.compareUtf8Bytes(unpairedSurrogate, "?") > 0); + } + + @Test + public void testCompareUtf8BytesMatchesEncodedByteOrder() { + String[] alphabet = { + // One-byte UTF-8 characters, including the upper boundary. + "?", + "a", + String.valueOf((char) 0x007F), + // Two-byte UTF-8 lower and upper boundaries. + String.valueOf((char) 0x0080), + String.valueOf((char) 0x07FF), + // Three-byte UTF-8 boundaries around the surrogate range, plus U+FFFD. + String.valueOf((char) 0x0800), + String.valueOf((char) 0xD7FF), + String.valueOf((char) 0xE000), + String.valueOf((char) 0xFFFD), + // Four-byte UTF-8 supplementary characters, including two sharing a high surrogate. + "😀", // U+1F600 + new String(Character.toChars(0x20000)), + new String(Character.toChars(0x20001)), + new String(Character.toChars(0x10FFFF)) + }; + + // Generate every sequence of zero to three code points from the alphabet. This covers cases + // where strings differ before, within, or after a supplementary character. + List values = new ArrayList<>(); + values.add(""); + for (String first : alphabet) { + values.add(first); + for (String second : alphabet) { + values.add(first + second); + for (String third : alphabet) { + values.add(first + second + third); + } + } + } + + // Pre-encode each value once, then use the production HFile key comparator as the oracle. + UTF8StringKey[] hfileKeys = values.stream() + .map(UTF8StringKey::new) + .toArray(UTF8StringKey[]::new); + + // Compare only the sign because Comparator does not prescribe the magnitude of its result. + for (int leftIndex = 0; leftIndex < values.size(); leftIndex++) { + String left = values.get(leftIndex); + for (int rightIndex = 0; rightIndex < values.size(); rightIndex++) { + String right = values.get(rightIndex); + assertEquals( + Integer.signum(hfileKeys[leftIndex].compareTo(hfileKeys[rightIndex])), + Integer.signum(StringUtils.compareUtf8Bytes(left, right)), + () -> "left=" + Arrays.toString(left.codePoints().toArray()) + + " right=" + Arrays.toString(right.codePoints().toArray())); + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void testUtf8LexicographicComparatorSerializableAndRejectsNull() throws Exception { + // Like String.compareTo, a null argument is rejected. + assertThrows(NullPointerException.class, () -> StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR.compare(null, "a")); + assertThrows(NullPointerException.class, () -> StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR.compare("a", null)); + assertThrows(NullPointerException.class, () -> StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR.compare(null, null)); + + // The comparator is declared as (Comparator & Serializable) so Spark can capture it inside + // serialized closures. Round-trip it through Java serialization and confirm the deserialized + // instance still orders keys by UTF-8 bytes for the divergent U+E000 vs U+20000 pair (U+E000's + // UTF-8 lead byte 0xEE sorts before U+20000's 0xF0, the reverse of String.compareTo / UTF-16). + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR); + } + Comparator deserialized; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (Comparator) ois.readObject(); + } + + String bmpPrivateUse = new String(Character.toChars(0xE000)); + String supplementary = new String(Character.toChars(0x20000)); + assertTrue(deserialized.compare(bmpPrivateUse, supplementary) < 0, + "Deserialized comparator should order U+E000 before U+20000 (UTF-8 byte order)"); + } } diff --git a/hudi-io/src/test/java/org/apache/hudi/io/hfile/TestHFileMultiBlockScan.java b/hudi-io/src/test/java/org/apache/hudi/io/hfile/TestHFileMultiBlockScan.java new file mode 100644 index 0000000000000..e36410f856b56 --- /dev/null +++ b/hudi-io/src/test/java/org/apache/hudi/io/hfile/TestHFileMultiBlockScan.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.hudi.io.hfile; + +import org.apache.hudi.io.ByteArraySeekableDataInputStream; +import org.apache.hudi.io.ByteBufferBackedInputStream; +import org.apache.hudi.io.compress.CompressionCodec; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.ByteArrayOutputStream; +import java.util.Locale; + +import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Reproduces a full-scan record drop on native-writer HFiles that span many data blocks. A large + * block size packs many records per block; the trailer entry count is correct, but a + * {@code seekTo()} then {@code next()} forward scan stops short of a block's content end because + * the content-end bound subtracts the trailing checksum size. The drop only surfaces once the + * checksum region exceeds the last record's size, which is why small-block tests never caught it. + */ +public class TestHFileMultiBlockScan { + + @ParameterizedTest + @CsvSource({ + "NONE, 64, 5000", "GZIP, 64, 5000", + "NONE, 1048576, 200000", "GZIP, 1048576, 200000", + "NONE, 65536, 200000", "GZIP, 65536, 200000" + }) + public void fullScanReturnsAllRecords(String codec, int blockSize, int numRecords) throws Exception { + HFileContext context = new HFileContext.Builder() + .blockSize(blockSize) + .compressionCodec(CompressionCodec.valueOf(codec)) + .build(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (HFileWriter writer = new HFileWriterImpl(context, baos)) { + for (int i = 0; i < numRecords; i++) { + writer.append(key(i), getUTF8Bytes(value(i))); + } + } + byte[] fileBytes = baos.toByteArray(); + + try (HFileReader reader = new HFileReaderImpl( + new ByteArraySeekableDataInputStream(new ByteBufferBackedInputStream(fileBytes)), + fileBytes.length)) { + reader.initializeMetadata(); + assertEquals(numRecords, reader.getNumKeyValueEntries(), + "trailer entry count for codec=" + codec + " blockSize=" + blockSize); + + String scenario = "codec=" + codec + " blockSize=" + blockSize + " numRecords=" + numRecords; + int scanned = 0; + int firstSkipAt = -1; + boolean hasNext = reader.seekTo(); + while (hasNext) { + KeyValue kv = reader.getKeyValue().get(); + if (firstSkipAt < 0 && !key(scanned).equals(kv.getKey().getContentInString())) { + firstSkipAt = scanned; + } + scanned++; + hasNext = reader.next(); + } + assertEquals(numRecords, scanned, + "forward scan returned fewer records than written; " + scenario + + " firstSkipAtPosition=" + firstSkipAt); + } + } + + private static String key(int i) { + return String.format(Locale.ROOT, "%010d", i); + } + + private static String value(int i) { + return String.format(Locale.ROOT, "value-%010d", i); + } +} diff --git a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/AbstractConnectWriter.java b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/AbstractConnectWriter.java index 70fefe4316032..628a51d16852c 100644 --- a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/AbstractConnectWriter.java +++ b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/AbstractConnectWriter.java @@ -35,7 +35,9 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Base Hudi Writer that manages reading the raw Kafka records and @@ -53,6 +55,13 @@ public abstract class AbstractConnectWriter implements ConnectWriter fileIdByPartitionPath = new HashMap<>(); public AbstractConnectWriter(KafkaConnectConfigs connectConfigs, KeyGenerator keyGenerator, @@ -61,28 +70,36 @@ public AbstractConnectWriter(KafkaConnectConfigs connectConfigs, this.keyGenerator = keyGenerator; this.schemaProvider = schemaProvider; this.instantTime = instantTime; + this.kafkaValueConverter = connectConfigs.getKafkaValueConverter(); } @Override public void writeRecord(SinkRecord record) throws IOException { - AvroConvertor convertor = new AvroConvertor(schemaProvider.getSourceHoodieSchema()); Option avroRecord; - switch (connectConfigs.getKafkaValueConverter()) { + switch (kafkaValueConverter) { case KAFKA_AVRO_CONVERTER: avroRecord = Option.of((GenericRecord) record.value()); break; case KAFKA_STRING_CONVERTER: + if (convertor == null) { + convertor = new AvroConvertor(schemaProvider.getSourceHoodieSchema()); + } avroRecord = Option.of(convertor.fromJson((String) record.value())); break; case KAFKA_JSON_CONVERTER: throw new UnsupportedEncodingException("Currently JSON objects are not supported"); default: - throw new IOException("Unsupported Kafka Format type (" + connectConfigs.getKafkaValueConverter() + ")"); + throw new IOException("Unsupported Kafka Format type (" + kafkaValueConverter + ")"); } // Tag records with a file ID based on kafka partition and hudi partition. HoodieRecord hoodieRecord = new HoodieAvroRecord<>(keyGenerator.getKey(avroRecord.get()), new HoodieAvroPayload(avroRecord)); - String fileId = KafkaConnectUtils.hashDigest(String.format("%s-%s", record.kafkaPartition(), hoodieRecord.getPartitionPath())); + String partitionPath = hoodieRecord.getPartitionPath(); + String fileId = fileIdByPartitionPath.get(partitionPath); + if (fileId == null) { + fileId = KafkaConnectUtils.hashDigest(record.kafkaPartition() + "-" + partitionPath); + fileIdByPartitionPath.put(partitionPath, fileId); + } hoodieRecord.unseal(); hoodieRecord.setCurrentLocation(new HoodieRecordLocation(instantTime, fileId)); hoodieRecord.setNewLocation(new HoodieRecordLocation(instantTime, fileId)); diff --git a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/BufferedConnectWriter.java b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/BufferedConnectWriter.java index 2893839acd75e..09b61ae608fae 100644 --- a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/BufferedConnectWriter.java +++ b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/BufferedConnectWriter.java @@ -40,7 +40,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.LinkedList; import java.util.List; /** @@ -109,11 +108,11 @@ public List flushRecords() { if (!bufferedRecords.isEmpty()) { if (isMorTable) { writeStatuses = writeClient.upsertPreppedRecords( - new LinkedList<>(bufferedRecords.values()), + new ArrayList<>(bufferedRecords.values()), instantTime); } else { writeStatuses = writeClient.bulkInsertPreppedRecords( - new LinkedList<>(bufferedRecords.values()), + new ArrayList<>(bufferedRecords.values()), instantTime, Option.empty()); } } diff --git a/hudi-platform-service/hudi-metaserver/pom.xml b/hudi-platform-service/hudi-metaserver/pom.xml index 341faa2a01e32..038901de6d8ed 100644 --- a/hudi-platform-service/hudi-metaserver/pom.xml +++ b/hudi-platform-service/hudi-metaserver/pom.xml @@ -77,7 +77,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl ${log4j2.version} compile diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieTimelineCleanupUtil.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieTimelineCleanupUtil.java new file mode 100644 index 0000000000000..f8583d5d5d2a7 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieTimelineCleanupUtil.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.hudi; + +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator; +import org.apache.hudi.common.table.timeline.HoodieTimeline; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Date; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class HoodieTimelineCleanupUtil { + private static final Logger LOG = LoggerFactory.getLogger(HoodieTimelineCleanupUtil.class); + + public static List inflightWriteCommitsOlderThan(HoodieTableMetaClient metaClient, long ageMinutes, boolean includeIngestionCommits) { + long goBackMs = Duration.ofMinutes(ageMinutes).toMillis(); + String oldestAllowedTimestamp = HoodieInstantTimeGenerator.formatDate(new Date(System.currentTimeMillis() - goBackMs)); + + Stream inflightInstants = metaClient + .reloadActiveTimeline() + .getWriteTimeline() + .filterInflightsAndRequested() + .findInstantsBefore(oldestAllowedTimestamp) + .getInstants().stream(); + + if (!includeIngestionCommits) { + Predicate ingestionCommitsFilter = + (x) -> x.getAction().equals(HoodieTimeline.COMMIT_ACTION) || x.getAction().equals(HoodieTimeline.DELTA_COMMIT_ACTION); + inflightInstants = inflightInstants.filter(ingestionCommitsFilter.negate()); + } + List inflightInstantsList = inflightInstants.collect(Collectors.toList()); + LOG.info("Inflight commits older than {} minutes: {}", ageMinutes, inflightInstantsList); + return inflightInstantsList; + } +} diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java index 75b5e6637f9a5..8b6466d5117d6 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/BaseDatasetBulkInsertCommitActionExecutor.java @@ -24,12 +24,18 @@ import org.apache.hudi.client.HoodieWriteResult; import org.apache.hudi.client.SparkRDDWriteClient; import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.client.clustering.update.strategy.SparkAllowUpdateStrategy; import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.model.HoodieFileGroupId; +import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.CommitUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.data.HoodieJavaRDD; import org.apache.hudi.exception.HoodieException; @@ -41,8 +47,10 @@ import org.apache.hudi.table.BulkInsertPartitioner; import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.HoodieWriteMetadata; +import org.apache.hudi.table.action.cluster.strategy.UpdateStrategy; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; @@ -51,9 +59,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static org.apache.hudi.config.HoodieWriteConfig.WRITE_STATUS_STORAGE_LEVEL_VALUE; +@Slf4j public abstract class BaseDatasetBulkInsertCommitActionExecutor implements Serializable { protected final transient HoodieWriteConfig writeConfig; @@ -109,6 +120,11 @@ public final HoodieWriteResult execute(Dataset records, boolean isTablePart BulkInsertPartitioner> bulkInsertPartitionerRows = getPartitioner(populateMetaFields, isTablePartitioned); Dataset hoodieDF = HoodieDatasetBulkInsertHelper.prepareForBulkInsert(records, writeConfig, table.getMetaClient().getTableConfig(), bulkInsertPartitionerRows, instantTime); + // Reject INSERT_OVERWRITE / INSERT_OVERWRITE_TABLE against partitions with pending + // clustering before any writes materialize. Subclasses override getFileGroupsBeingReplaced; + // default is a no-op (empty set) which preserves the non-overwrite paths. + rejectIfOverlappingPendingClustering(hoodieDF); + HoodieWriteMetadata> result = buildHoodieWriteMetadata(doExecute(hoodieDF, bulkInsertPartitionerRows.arePartitionRecordsSorted())); afterExecute(result); @@ -141,4 +157,77 @@ protected BulkInsertPartitioner> getPartitioner(boolean populateMet } protected abstract Map> getPartitionToReplacedFileIds(HoodieData writeStatuses); + + /** + * Returns the file groups this operation will replace. Default is empty (non-overwrite paths). + * Bulk-insert overwrite executors override this so the overlap-with-pending-clustering check + * can fire before any writes materialize. + * + * @param preparedRecords the dataset after {@code HoodieDatasetBulkInsertHelper.prepareForBulkInsert} + * has populated the {@code _hoodie_partition_path} meta field, so dynamic + * partition resolution can read it. + */ + protected Set getFileGroupsBeingReplaced(Dataset preparedRecords) { + return Collections.emptySet(); + } + + /** + * Mirrors {@code BaseSparkCommitActionExecutor#clusteringHandleUpdate} for the bulk-insert row + * path: if any of the file groups this operation will replace are in pending clustering, route + * through the configured {@code hoodie.clustering.updates.strategy}. With the default + * {@code SparkRejectUpdateStrategy} this throws {@code HoodieClusteringUpdateException}; with + * {@code SparkAllowUpdateStrategy} (and {@code !isRollbackPendingClustering()}) the overlap is + * deferred to the conflict-resolution strategy, matching the existing Spark-side behavior. + */ + protected void rejectIfOverlappingPendingClustering(Dataset preparedRecords) { + Set fileGroupsInPendingClustering = table.getFileSystemView() + .getFileGroupsInPendingClustering().map(Pair::getKey).collect(Collectors.toSet()); + if (fileGroupsInPendingClustering.isEmpty()) { + return; + } + Set fileGroupsToBeReplaced = getFileGroupsBeingReplaced(preparedRecords); + if (fileGroupsToBeReplaced.isEmpty()) { + return; + } + + HoodieEngineContext engineContext = writeClient.getEngineContext(); + UpdateStrategy> updateStrategy = loadClusteringUpdateStrategy( + engineContext, fileGroupsInPendingClustering, fileGroupsToBeReplaced); + if (updateStrategy instanceof SparkAllowUpdateStrategy && !writeConfig.isRollbackPendingClustering()) { + return; + } + // handleUpdate consumes only fileGroupsToBeReplaced on this path (no tagged records to + // inspect for the bulk-insert overwrite case), so pass an empty HoodieData. + updateStrategy.handleUpdate(engineContext.emptyHoodieData()); + } + + /** + * Loads {@code hoodie.clustering.updates.strategy} via reflection, preferring the 4-arg + * constructor (with {@code fileGroupsToBeReplaced}) and falling back to the legacy 3-arg + * constructor for custom strategies that pre-date this PR. + */ + @SuppressWarnings("unchecked") + private UpdateStrategy> loadClusteringUpdateStrategy( + HoodieEngineContext engineContext, + Set fileGroupsInPendingClustering, + Set fileGroupsToBeReplaced) { + String strategyClass = writeConfig.getClusteringUpdatesStrategyClass(); + try { + return (UpdateStrategy>) ReflectionUtils.loadClass( + strategyClass, + new Class[] {HoodieEngineContext.class, HoodieTable.class, Set.class, Set.class}, + engineContext, table, fileGroupsInPendingClustering, fileGroupsToBeReplaced); + } catch (HoodieException ex) { + if (!(ex.getCause() instanceof NoSuchMethodException)) { + throw ex; + } + log.warn("Clustering update strategy {} is missing the 4-arg constructor with " + + "fileGroupsToBeReplaced; falling back to the 3-arg constructor. INSERT_OVERWRITE " + + "overlap with pending clustering will not be detected for this strategy.", strategyClass); + return (UpdateStrategy>) ReflectionUtils.loadClass( + strategyClass, + new Class[] {HoodieEngineContext.class, HoodieTable.class, Set.class}, + engineContext, table, fileGroupsInPendingClustering); + } + } } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java index deaeac4df45f4..397407adb6b31 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteCommitActionExecutor.java @@ -23,6 +23,8 @@ import org.apache.hudi.client.WriteStatus; import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieFileGroupId; +import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.Option; @@ -33,12 +35,14 @@ import org.apache.hudi.data.HoodieJavaPairRDD; import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; import org.apache.spark.sql.Row; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; public class DatasetBulkInsertOverwriteCommitActionExecutor extends BaseDatasetBulkInsertCommitActionExecutor { @@ -56,6 +60,46 @@ protected Option> doExecute(Dataset records, boolea .bulkInsert(records, instantTime, table, writeConfig, arePartitionRecordsSorted, false)); } + /** + * For INSERT_OVERWRITE: enumerate latest file groups in the targeted partitions so the caller + * can reject overlap with pending clustering before the bulk-insert materializes. Mirrors + * {@code SparkInsertOverwriteCommitActionExecutor#getFileGroupsBeingReplaced}; called by the + * base class on the prepared dataset (after {@code prepareForBulkInsert} populates the + * {@code _hoodie_partition_path} meta field), so dynamic partition resolution can read it. + */ + @Override + protected Set getFileGroupsBeingReplaced(Dataset preparedRecords) { + List partitionPaths = resolveTargetPartitions(preparedRecords); + if (partitionPaths.isEmpty()) { + return Collections.emptySet(); + } + return partitionPaths.stream() + .flatMap(partitionPath -> table.getSliceView().getLatestFileSlices(partitionPath) + .map(FileSlice::getFileGroupId)) + .collect(Collectors.toSet()); + } + + /** + * Resolves the partition paths this overwrite will replace. Subclasses override for the + * table-wide variant (enumerate every partition). + */ + protected List resolveTargetPartitions(Dataset preparedRecords) { + if (!table.isPartitioned()) { + return Collections.singletonList(StringUtils.EMPTY_STRING); + } + String staticOverwritePartitionPaths = writeConfig.getStringOrDefault(HoodieInternalConfig.STATIC_OVERWRITE_PARTITION_PATHS); + if (StringUtils.nonEmpty(staticOverwritePartitionPaths)) { + return Arrays.asList(staticOverwritePartitionPaths.split(",")); + } + // Dynamic partition path: read the populated _hoodie_partition_path meta field. The base + // class invokes this hook after HoodieDatasetBulkInsertHelper.prepareForBulkInsert, so the + // field is guaranteed to be present and populated by the configured key generator. + return preparedRecords.select(HoodieRecord.PARTITION_PATH_METADATA_FIELD) + .distinct() + .as(Encoders.STRING()) + .collectAsList(); + } + @Override public WriteOperationType getWriteOperationType() { return WriteOperationType.INSERT_OVERWRITE; diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java index d35325360745f..3dc1df5dc2700 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/commit/DatasetBulkInsertOverwriteTableCommitActionExecutor.java @@ -28,6 +28,9 @@ import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.data.HoodieJavaPairRDD; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; + import java.util.Collections; import java.util.List; import java.util.Map; @@ -44,6 +47,14 @@ public WriteOperationType getWriteOperationType() { return WriteOperationType.INSERT_OVERWRITE_TABLE; } + @Override + protected List resolveTargetPartitions(Dataset preparedRecords) { + // Table-wide overwrite replaces every file group in every partition; enumerate them all. + List partitionPaths = FSUtils.getAllPartitionPaths(writeClient.getEngineContext(), + table.getMetaClient(), writeConfig.getMetadataConfig()); + return partitionPaths == null ? Collections.emptyList() : partitionPaths; + } + @Override protected Map> getPartitionToReplacedFileIds(HoodieData writeStatuses) { HoodieEngineContext context = writeClient.getEngineContext(); diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BaseHoodiePartitionValues.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BaseHoodiePartitionValues.scala new file mode 100644 index 0000000000000..a6fd7e238f69c --- /dev/null +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BaseHoodiePartitionValues.scala @@ -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.hudi + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.util.{ArrayData, MapData} +import org.apache.spark.sql.types.{DataType, Decimal} +import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String} + +/** + * Abstract base class for HoodiePartitionValues implementations. + * Contains all common delegation logic to the underlying InternalRow. + */ +abstract class BaseHoodiePartitionValues(val values: InternalRow) extends HoodiePartitionValues { + + override def numFields: Int = { + values.numFields + } + + override def setNullAt(i: Int): Unit = { + values.setNullAt(i) + } + + override def update(i: Int, value: Any): Unit = { + values.update(i, value) + } + + override def isNullAt(ordinal: Int): Boolean = { + values.isNullAt(ordinal) + } + + override def getBoolean(ordinal: Int): Boolean = { + values.getBoolean(ordinal) + } + + override def getByte(ordinal: Int): Byte = { + values.getByte(ordinal) + } + + override def getShort(ordinal: Int): Short = { + values.getShort(ordinal) + } + + override def getInt(ordinal: Int): Int = { + values.getInt(ordinal) + } + + override def getLong(ordinal: Int): Long = { + values.getLong(ordinal) + } + + override def getFloat(ordinal: Int): Float = { + values.getFloat(ordinal) + } + + override def getDouble(ordinal: Int): Double = { + values.getDouble(ordinal) + } + + override def getDecimal(ordinal: Int, precision: Int, scale: Int): Decimal = { + values.getDecimal(ordinal, precision, scale) + } + + override def getUTF8String(ordinal: Int): UTF8String = { + values.getUTF8String(ordinal) + } + + override def getBinary(ordinal: Int): Array[Byte] = { + values.getBinary(ordinal) + } + + override def getInterval(ordinal: Int): CalendarInterval = { + values.getInterval(ordinal) + } + + override def getStruct(ordinal: Int, numFields: Int): InternalRow = { + values.getStruct(ordinal, numFields) + } + + override def getArray(ordinal: Int): ArrayData = { + values.getArray(ordinal) + } + + override def getMap(ordinal: Int): MapData = { + values.getMap(ordinal) + } + + override def get(ordinal: Int, dataType: DataType): AnyRef = { + values.get(ordinal, dataType) + } +} diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BucketIndexSupport.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BucketIndexSupport.scala index 71c0ac4d379a1..e9aee3ab78b48 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BucketIndexSupport.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/BucketIndexSupport.scala @@ -27,6 +27,7 @@ import org.apache.hudi.index.HoodieIndex import org.apache.hudi.index.HoodieIndex.IndexType import org.apache.hudi.index.bucket.BucketIdentifier import org.apache.hudi.keygen.KeyGenerator +import org.apache.hudi.keygen.KeyGenUtils import org.apache.hudi.keygen.factory.HoodieSparkKeyGeneratorFactory import org.apache.avro.generic.GenericData @@ -212,7 +213,7 @@ class BucketIndexSupport(spark: SparkSession, if (bucketHashFields == null || bucketHashFields.isEmpty) { Option.apply(null) } else { - Option.apply(JavaConverters.seqAsJavaListConverter(bucketHashFields.split(",")).asJava) + Option.apply(KeyGenUtils.getIndexKeyFields(bucketHashFields)) } } @@ -224,4 +225,3 @@ class BucketIndexSupport(spark: SparkSession, object BucketIndexSupport { val INDEX_NAME = "BUCKET" } - diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala index 445f052ee4f6a..b2548029e6216 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala @@ -35,6 +35,7 @@ import org.apache.hudi.keygen.factory.HoodieSparkKeyGeneratorFactory.{getKeyGene import org.apache.hudi.sync.common.HoodieSyncConfig import org.apache.hudi.util.{JFunction, SparkConfigUtils} +import org.apache.spark.sql.SQLContext import org.apache.spark.sql.execution.datasources.{DataSourceUtils => SparkDataSourceUtils} import org.apache.spark.sql.hudi.HoodieSqlCommonUtils import org.slf4j.LoggerFactory @@ -1033,9 +1034,87 @@ object DataSourceOptionsHelper { private val log = LoggerFactory.getLogger(DataSourceOptionsHelper.getClass) // Prefix constants for config normalization + private val HOODIE_PREFIX = "hoodie." private val SPARK_HOODIE_PREFIX = "spark.hoodie." private val SPARK_PREFIX = "spark." + /** + * Collects `hoodie.*` and `spark.hoodie.*` configs from the SparkConf, normalizes the + * `spark.hoodie.*` keys to canonical `hoodie.*`, and merges with explicit DataFrame + * options. Explicit options win over SparkConf. + * + * This is the read-path entry point: reads have always picked up session-level `hoodie.*` + * confs (e.g. `hoodie.datasource.query.type`), so both prefixes are forwarded here. + * Do NOT use this for writes — see `collectSparkHoodieConfs` for why ambient `hoodie.*` + * confs must not be forwarded to the write path. + * + * Example (SparkConf has both prefixes set; explicit options override): + * {{{ + * SparkConf: spark.hoodie.X = "a", hoodie.Y = "b" + * optParams: hoodie.X = "c" + * result: hoodie.X = "c" // explicit wins over both prefixes + * hoodie.Y = "b" + * }}} + */ + def collectHoodieAndSparkHoodieConfs(sqlContext: SQLContext, + optParams: Map[String, String]): Map[String, String] = + collectConfsByPrefix(sqlContext, optParams, includeHoodiePrefix = true) + + /** + * Collects only `spark.hoodie.*` configs from the SparkConf, normalizes them to canonical + * `hoodie.*`, and merges with explicit DataFrame options. Explicit options win over SparkConf. + * + * This is the write-path entry point. It deliberately does NOT forward bare `hoodie.*` + * session confs: unlike reads, the DataFrame write path historically honored only the + * explicit `.option(...)` map, so injecting ambient `hoodie.*` session state (e.g. a + * session-level `hoodie.datasource.write.operation` or `hoodie.logfile.data.block.format`) + * would silently change every `df.write`. The bug this addresses (HUDI-#18649) is about + * `--conf spark.hoodie.X=Y` being dropped on writes, which only requires forwarding the + * `spark.hoodie.*` form. + * + * Example: + * {{{ + * SparkConf: spark.hoodie.X = "a", hoodie.Y = "b" // bare hoodie.Y is NOT forwarded + * optParams: hoodie.Z = "c" + * result: hoodie.X = "a" + * hoodie.Z = "c" + * }}} + */ + def collectSparkHoodieConfs(sqlContext: SQLContext, + optParams: Map[String, String]): Map[String, String] = + collectConfsByPrefix(sqlContext, optParams, includeHoodiePrefix = false) + + private def collectConfsByPrefix(sqlContext: SQLContext, + optParams: Map[String, String], + includeHoodiePrefix: Boolean): Map[String, String] = { + val sparkConfs = sqlContext.getAllConfs.filter { + case (key, _) => + key.startsWith(SPARK_HOODIE_PREFIX) || (includeHoodiePrefix && key.startsWith(HOODIE_PREFIX)) + } + normalizeSparkHoodiePrefix(sparkConfs) ++ optParams + } + + /** + * Strips the `spark.` prefix from `spark.hoodie.*` keys so downstream code only sees + * canonical `hoodie.*` keys. If both `spark.hoodie.X` and `hoodie.X` are present, the + * latter wins (explicit options/configs override the SparkConf-prefixed form). + * + * The function is idempotent: running it on an already-normalized map is a no-op. + * Both `collectHoodieAndSparkHoodieConfs` (the entry-point helper) and + * `parametersWithReadDefaults` / `parametersWithWriteDefaults` (the per-path defaulting + * helpers) call it; this defense-in-depth ensures callers that bypass + * `collectHoodieAndSparkHoodieConfs` (e.g., SQL `ALTER TABLE` paths) still get + * normalized configs. + */ + def normalizeSparkHoodiePrefix(parameters: Map[String, String]): Map[String, String] = { + val rekeyedSparkHoodie = parameters.collect { + case (key, value) if key.startsWith(SPARK_HOODIE_PREFIX) => + (key.stripPrefix(SPARK_PREFIX), value) + } + val nonSparkHoodie = parameters.filterNot(_._1.startsWith(SPARK_HOODIE_PREFIX)) + rekeyedSparkHoodie ++ nonSparkHoodie + } + // put all the configs with alternatives here private val allConfigsWithAlternatives = List( DataSourceReadOptions.QUERY_TYPE, @@ -1132,13 +1211,8 @@ object DataSourceOptionsHelper { // 2) spark.hoodie.* (normalized to hoodie.*) // 3) hoodie.* / explicit data source options // NOTE: If both spark.hoodie.X and hoodie.X are set, hoodie.X wins. - val normalizedSparkHoodieConfigs = parameters.collect { - case (key, value) if key.startsWith(SPARK_HOODIE_PREFIX) => (key.stripPrefix(SPARK_PREFIX), value) - } - val paramsWithoutSparkHoodie = parameters.filterNot(_._1.startsWith(SPARK_HOODIE_PREFIX)) - val paramsWithGlobalProps = DFSPropertiesConfiguration.getGlobalProps.asScala.toMap ++ - normalizedSparkHoodieConfigs ++ - paramsWithoutSparkHoodie + val normalized = normalizeSparkHoodiePrefix(parameters) + val paramsWithGlobalProps = DFSPropertiesConfiguration.getGlobalProps.asScala.toMap ++ normalized val queryType = paramsWithGlobalProps.get(IS_QUERY_AS_RO_TABLE) .map(is => if (is.toBoolean) QUERY_TYPE_READ_OPTIMIZED_OPT_VAL else QUERY_TYPE_SNAPSHOT_OPT_VAL) .getOrElse(paramsWithGlobalProps.getOrElse(QUERY_TYPE.key, QUERY_TYPE.defaultValue())) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DefaultSource.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DefaultSource.scala index bf8dee324f4ff..48fb989a2c9f9 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DefaultSource.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DefaultSource.scala @@ -107,17 +107,14 @@ class DefaultSource extends RelationProvider throw new HoodieException("Glob paths are not supported for read paths as of Hudi 1.2.0") } - val hoodieAndSparkHoodieSqlConfs = sqlContext.getAllConfs.filter { - case (key, _) => key.startsWith("hoodie.") || key.startsWith("spark.hoodie.") - } // Add default options for unspecified read options keys. // Effective precedence (low -> high): // 1) global DFS props - // 2) spark.hoodie.* SQL confs (normalized in parametersWithReadDefaults) + // 2) spark.hoodie.* SQL confs (normalized to hoodie.* in collectHoodieAndSparkHoodieConfs) // 3) hoodie.* SQL confs // 4) explicit DataFrame/DataSource options val parameters = DataSourceOptionsHelper.parametersWithReadDefaults( - hoodieAndSparkHoodieSqlConfs ++ optParams) + DataSourceOptionsHelper.collectHoodieAndSparkHoodieConfs(sqlContext, optParams)) // Get the table base path val tablePath = DataSourceUtils.getTablePath(storage, Seq(new StoragePath(path.get)).asJava) @@ -134,7 +131,21 @@ class DefaultSource extends RelationProvider parameters } - val relation = DefaultSource.createRelation(sqlContext, metaClient, schema, options.toMap) + // Spark's DataSource.resolveRelation() invokes this 3-arg overload directly via the + // SchemaRelationProvider path when a user-supplied schema is present (e.g. + // spark.read.schema(...).load(path)). The 2-arg overload catches + // HoodieSchemaNotFoundException and returns an EmptyRelation, but that catch is bypassed + // on this path, so we mirror the same handling here. Preserve the caller-supplied schema + // so subsequent query analysis (e.g. column resolution in WHERE clauses) sees the + // HMS-known columns even though the on-disk table is schemaless. The 2-arg overload also + // re-enters this method with schema=null, so we must fall back to an empty StructType + // when schema is null to avoid an NPE in the 2-arg overload's relation.schema.isEmpty check. + val relation = try { + DefaultSource.createRelation(sqlContext, metaClient, schema, options.toMap) + } catch { + case _: HoodieSchemaNotFoundException => + new EmptyRelation(sqlContext, Option(schema).getOrElse(new StructType())) + } log.info(s"Created relation ${relation.getClass.getSimpleName} with ${options.size} resolved options") relation } @@ -159,11 +170,20 @@ class DefaultSource extends RelationProvider mode: SaveMode, optParams: Map[String, String], df: DataFrame): BaseRelation = { + // Pull `spark.hoodie.*` from SparkConf, normalize to canonical `hoodie.*`, and merge + // with explicit options (explicit options win), so configs like + // `--conf spark.hoodie.datasource.hive_sync.use_spark_catalog=true` are honored on + // writes too. Unlike the read path, we deliberately do NOT forward bare `hoodie.*` + // session confs here: the DataFrame write path historically honored only the explicit + // `.option(...)` map, and injecting ambient session `hoodie.*` state would silently + // change every write. `HoodieSparkSqlWriter` and downstream callers see only canonical keys. + val effectiveOpts = + DataSourceOptionsHelper.collectSparkHoodieConfs(sqlContext, optParams) try { - if (optParams.get(OPERATION.key).contains(BOOTSTRAP_OPERATION_OPT_VAL)) { - HoodieSparkSqlWriter.bootstrap(sqlContext, mode, optParams, df) + if (effectiveOpts.get(OPERATION.key).contains(BOOTSTRAP_OPERATION_OPT_VAL)) { + HoodieSparkSqlWriter.bootstrap(sqlContext, mode, effectiveOpts, df) } else { - val (success, _, _, _, _, _) = HoodieSparkSqlWriter.write(sqlContext, mode, optParams, df) + val (success, _, _, _, _, _) = HoodieSparkSqlWriter.write(sqlContext, mode, effectiveOpts, df) if (!success) { throw new HoodieException("Failed to write to Hudi") } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala index 1b40b0fe57015..c6e37f5bb6dd9 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala @@ -107,11 +107,63 @@ object HoodieCLIUtils extends Logging { } } + /** + * Parse a comma-separated string of key=value pairs into a Map. + * + * Notes: + * - Whitespace surrounding keys/values is trimmed; empty tokens (e.g. from a + * trailing comma or `", ,"`) are silently ignored. + * - The delimiter is the first `=` in a token, so values may themselves + * contain `=` (e.g. `k=a=b` parses to `k -> "a=b"`). + * - Values cannot contain literal commas; the parser does not support + * escaping. Configs that need commas should be set via Spark conf instead. + * - If the same key appears more than once, a WARN is logged and the last + * occurrence wins (consistent with `toMap`'s last-write-wins semantics). + * + * @throws IllegalArgumentException if a non-empty token does not contain `=` + * or has an empty key. + */ def extractOptions(s: String): Map[String, String] = { - StringUtils.split(s, ",").asScala - .map(split => StringUtils.split(split, "=")) - .map(pair => pair.get(0) -> pair.get(1)) - .toMap + if (s == null) { + Map.empty + } else { + // Single pass: build the result Map and collect duplicate keys at the + // same time, avoiding an intermediate Seq + groupBy + toMap chain. + val (result, duplicates) = StringUtils.split(s, ",").asScala + .map(_.trim) + .filter(_.nonEmpty) + .map(parseOptionToken) + .foldLeft((Map.empty[String, String], Set.empty[String])) { + case ((acc, dups), (key, value)) => + val newDups = if (acc.contains(key)) dups + key else dups + (acc + (key -> value), newDups) + } + + if (duplicates.nonEmpty) { + logWarning(s"Duplicate option keys detected: ${duplicates.mkString(", ")}. " + + "The last occurrence will take effect.") + } + result + } + } + + private def parseOptionToken(token: String): (String, String) = { + val delimiterIndex = token.indexOf('=') + if (delimiterIndex <= 0) { + throw new IllegalArgumentException( + s"Invalid options format: '$token'. Expected 'key=value' pairs separated by commas, " + + "for example: 'k1=v1,k2=v2'.") + } + + val key = token.substring(0, delimiterIndex).trim + if (key.isEmpty) { + throw new IllegalArgumentException( + s"Invalid options format: '$token'. Option key must not be empty and options should " + + "follow 'key=value' format.") + } + + val value = token.substring(delimiterIndex + 1).trim + key -> value } def getLockOptions(tablePath: String, schema: String, lockConfig: TypedProperties): Map[String, String] = { diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala index df5f495a2be3a..a9e4f903b1946 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala @@ -20,7 +20,7 @@ package org.apache.hudi import org.apache.hudi.DataSourceWriteOptions.INSERT_DROP_DUPS import org.apache.hudi.avro.{AvroRecordContext, AvroSchemaCache, HoodieAvroUtils} -import org.apache.hudi.common.config.TypedProperties +import org.apache.hudi.common.config.{RecordMergeMode, TypedProperties} import org.apache.hudi.common.fs.FSUtils import org.apache.hudi.common.model._ import org.apache.hudi.common.model.WriteOperationType.isChangingRecords @@ -78,6 +78,12 @@ object HoodieCreateRecordUtils { val preppedSparkSqlMergeInto = args.preppedSparkSqlMergeInto val preppedWriteOperation = args.preppedWriteOperation val orderingFields = args.tableConfig.getOrderingFields + val recordMergeMode = args.tableConfig.getRecordMergeMode + val payloadClass = config.getPayloadClass + // Ordering values are not required for COMMIT_TIME_ORDERING or OverwriteWithLatestAvroPayload. + // Table version 6 may not have a merge mode set, so the payload class check is still needed. + val requiresOrderingValue = !((recordMergeMode == RecordMergeMode.COMMIT_TIME_ORDERING) + || classOf[OverwriteWithLatestAvroPayload].getName.equals(payloadClass)) val shouldDropPartitionColumns = config.getBoolean(DataSourceWriteOptions.DROP_PARTITION_COLUMNS) val recordType = config.getRecordMerger.getRecordType @@ -150,11 +156,8 @@ object HoodieCreateRecordUtils { avroRecWithoutMeta } val hoodieRecord = if (shouldCombine && !orderingFields.isEmpty) { - val orderingVal = OrderingValues.create( - orderingFields, - JFunction.toJavaFunction[String, Comparable[_]]( - field => HoodieAvroUtils.getNestedFieldVal(avroRec, field, false, - consistentLogicalTimestampEnabled).asInstanceOf[Comparable[_]])) + val orderingVal = getOrderingValue(orderingFields, avroRec, hoodieKey.getRecordKey, + consistentLogicalTimestampEnabled, requiresOrderingValue) HoodieRecordUtils.createHoodieRecord(processedRecord, orderingVal, hoodieKey, config.getPayloadClass, null, recordLocation, requiresPayload, isDelete) } else { @@ -282,4 +285,34 @@ object HoodieCreateRecordUtils { (new HoodieKey(recordKey, partitionPath), recordLocation) } + + /** + * Gets the ordering value from the ordering fields of an Avro record. + * When `requiresOrderingValue` is false (e.g., COMMIT_TIME_ORDERING or OverwriteWithLatestAvroPayload), + * null values are allowed and a default ordering value is used. + * Otherwise, throws IllegalArgumentException if any ordering field has a null value. + */ + private def getOrderingValue(orderingFields: java.util.List[String], + avroRec: GenericRecord, + recordKey: String, + consistentLogicalTimestampEnabled: Boolean, + requiresOrderingValue: Boolean): Comparable[_] = { + OrderingValues.create( + orderingFields, + JFunction.toJavaFunction[String, Comparable[_]](field => { + val fieldVal = HoodieAvroUtils.getNestedFieldVal(avroRec, field, false, consistentLogicalTimestampEnabled) + if (fieldVal == null) { + if (requiresOrderingValue) { + throw new IllegalArgumentException( + s"Ordering field '$field' has null value for record key '$recordKey'. " + + s"Please ensure all records have non-null values for the ordering field, " + + s"or use a payload class that doesn't require ordering (e.g., OverwriteWithLatestAvroPayload).") + } + // Return default ordering value for payloads that don't require ordering + OrderingValues.getDefault.asInstanceOf[Comparable[_]] + } else { + fieldVal.asInstanceOf[Comparable[_]] + } + })) + } } diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodieFileScanRDD.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileScanRDD.scala similarity index 77% rename from hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodieFileScanRDD.scala rename to hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileScanRDD.scala index 5e9792a0677d1..92e9caf0d6350 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodieFileScanRDD.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieFileScanRDD.scala @@ -24,11 +24,11 @@ import org.apache.spark.sql.catalyst.expressions.AttributeReference import org.apache.spark.sql.execution.datasources.{FilePartition, FileScanRDD, PartitionedFile} import org.apache.spark.sql.types.StructType -class Spark40HoodieFileScanRDD(@transient private val sparkSession: SparkSession, - read: PartitionedFile => Iterator[InternalRow], - @transient filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty) +class HoodieFileScanRDD(@transient private val sparkSession: SparkSession, + read: PartitionedFile => Iterator[InternalRow], + @transient filePartitions: Seq[FilePartition], + readDataSchema: StructType, + metadataColumns: Seq[AttributeReference] = Seq.empty) extends FileScanRDD(sparkSession, read, filePartitions, readDataSchema, metadataColumns) with HoodieUnsafeRDD { diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala index 3d9b908c4d310..eadbdd80bd31c 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala @@ -171,7 +171,7 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext, val requestedSchema = requiredSchema.schema val instantRange = InstantRange.builder().rangeType(RangeType.EXACT_MATCH).explicitInstants(validInstants.value).build() val readerContext = new HoodieAvroReaderContext(storageConf, metaClient.getTableConfig, HOption.of(instantRange), HOption.empty().asInstanceOf[HOption[HPredicate]]) - val fileGroupReader: HoodieFileGroupReader[IndexedRecord] = HoodieFileGroupReader.newBuilder() + val fileGroupReader: HoodieFileGroupReader[IndexedRecord] = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(tableState.latestCommitTimestamp.orNull) @@ -181,13 +181,13 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext, .withProps(properties) .withDataSchema(tableSchema.schema) .withRequestedSchema(requestedSchema) - .withInternalSchema(HOption.ofNullable(tableSchema.internalSchema.orNull)) + .withInternalSchemaOpt(HOption.ofNullable(tableSchema.internalSchema.orNull)) .build() convertAvroToRowIterator(fileGroupReader.getClosableIterator, requestedSchema) } else { val readerContext = new SparkFileFormatInternalRowReaderContext(fileGroupBaseFileReader.value, optionalFilters, Seq.empty, storageConf, metaClient.getTableConfig) - val fileGroupReader = HoodieFileGroupReader.newBuilder() + val fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(tableState.latestCommitTimestamp.orNull) @@ -197,7 +197,7 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext, .withProps(properties) .withDataSchema(tableSchema.schema) .withRequestedSchema(requiredSchema.schema) - .withInternalSchema(HOption.ofNullable(tableSchema.internalSchema.orNull)) + .withInternalSchemaOpt(HOption.ofNullable(tableSchema.internalSchema.orNull)) .build() convertCloseableIterator(fileGroupReader.getClosableIterator) } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala index 5a40f45c12412..bf0187c69ba5b 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala @@ -28,9 +28,11 @@ import org.apache.hudi.common.util.ConfigUtils import org.apache.hudi.config.HoodieWriteConfig import org.apache.hudi.exception.{HoodieException, SchemaCompatibilityException} import org.apache.hudi.internal.schema.InternalSchema +import org.apache.hudi.internal.schema.Type import org.apache.hudi.internal.schema.convert.InternalSchemaConverter import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils.reconcileSchemaRequirements +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils import org.apache.spark.sql.types.StructField import org.apache.spark.sql.types.StructType @@ -141,10 +143,26 @@ object HoodieSchemaUtils { InternalSchemaConverter.fixNullOrdering(sourceSchema) } + // Parse the per-field timestamp overrides once and thread the parsed map through the + // upfront guard and every downstream branch — reduces cognitive load and matches the + // "single source of truth" theme of the config. + val timestampLogicalTypeOverrides = SchemaChangeUtils.parseTimestampLogicalTypeOverrides( + opts.getOrElse(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key, + HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.defaultValue)) + + // Reconcile timestamp precision up front so every downstream branch (including the + // non-reconcile default path, whose Avro compatibility check is logical-type-blind) is + // guarded: an unverified micros/millis change throws here rather than silently flipping the + // table on the next commit. + val precisionReconciledSourceSchema = AvroSchemaEvolutionUtils.reconcileTimestampLogicalType( + canonicalizedSourceSchema, latestTableSchema, timestampLogicalTypeOverrides) + if (shouldReconcileSchema) { - deduceWriterSchemaWithReconcile(sourceSchema, canonicalizedSourceSchema, latestTableSchema, internalSchemaOpt, opts) + deduceWriterSchemaWithReconcile(sourceSchema, precisionReconciledSourceSchema, latestTableSchema, + internalSchemaOpt, opts, timestampLogicalTypeOverrides) } else { - deduceWriterSchemaWithoutReconcile(sourceSchema, canonicalizedSourceSchema, latestTableSchema, opts) + deduceWriterSchemaWithoutReconcile(sourceSchema, precisionReconciledSourceSchema, latestTableSchema, + opts, timestampLogicalTypeOverrides) } } } @@ -157,7 +175,8 @@ object HoodieSchemaUtils { private def deduceWriterSchemaWithoutReconcile(sourceSchema: HoodieSchema, canonicalizedSourceSchema: HoodieSchema, latestTableSchema: HoodieSchema, - opts: Map[String, String]): HoodieSchema = { + opts: Map[String, String], + timestampLogicalTypeOverrides: java.util.Map[String, Type]): HoodieSchema = { // NOTE: In some cases we need to relax constraint of incoming dataset's schema to be compatible // w/ the table's one and allow schemas to diverge. This is required in cases where // partial updates will be performed (for ex, `MERGE INTO` Spark SQL statement) and as such @@ -173,7 +192,8 @@ object HoodieSchemaUtils { if (!mergeIntoWrites && !shouldValidateSchemasCompatibility && !allowAutoEvolutionColumnDrop) { // Default behaviour val reconciledSchema = if (setNullForMissingColumns) { - HoodieSchema.fromAvroSchema(AvroSchemaEvolutionUtils.reconcileSchema(canonicalizedSourceSchema.toAvroSchema(), latestTableSchema.toAvroSchema(), setNullForMissingColumns)) + AvroSchemaEvolutionUtils.reconcileSchema(canonicalizedSourceSchema, latestTableSchema, + setNullForMissingColumns, timestampLogicalTypeOverrides) } else { canonicalizedSourceSchema } @@ -199,13 +219,15 @@ object HoodieSchemaUtils { canonicalizedSourceSchema: HoodieSchema, latestTableSchema: HoodieSchema, internalSchemaOpt: Option[InternalSchema], - opts: Map[String, String]): HoodieSchema = { + opts: Map[String, String], + timestampLogicalTypeOverrides: java.util.Map[String, Type]): HoodieSchema = { internalSchemaOpt match { case Some(internalSchema) => // Apply schema evolution, by auto-merging write schema and read schema val setNullForMissingColumns = opts.getOrElse(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key(), HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.defaultValue()).toBoolean - val mergedInternalSchema = AvroSchemaEvolutionUtils.reconcileSchema(canonicalizedSourceSchema.toAvroSchema(), internalSchema, setNullForMissingColumns) + val mergedInternalSchema = AvroSchemaEvolutionUtils.reconcileSchema(canonicalizedSourceSchema, internalSchema, + setNullForMissingColumns, timestampLogicalTypeOverrides) val evolvedSchema = InternalSchemaConverter.convert(mergedInternalSchema, latestTableSchema.getFullName) val shouldRemoveMetaDataFromInternalSchema = sourceSchema.getFields.asScala.filter(f => f.name().equalsIgnoreCase(HoodieRecord.RECORD_KEY_METADATA_FIELD)).isEmpty if (shouldRemoveMetaDataFromInternalSchema) HoodieCommonSchemaUtils.removeMetadataFields(evolvedSchema) else evolvedSchema @@ -257,9 +279,7 @@ object HoodieSchemaUtils { */ private def canonicalizeSchema(sourceSchema: HoodieSchema, latestTableSchema: HoodieSchema, opts : Map[String, String], shouldReorderColumns: Boolean): HoodieSchema = { - HoodieSchema.fromAvroSchema( - reconcileSchemaRequirements(sourceSchema.toAvroSchema(), latestTableSchema.toAvroSchema(), shouldReorderColumns) - ) + reconcileSchemaRequirements(sourceSchema, latestTableSchema, shouldReorderColumns) } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala index aaa79929256f6..9137433d3178b 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala @@ -991,7 +991,7 @@ class HoodieSparkSqlWriterInternal { extraPreCommitFn: Option[BiConsumer[HoodieTableMetaClient, HoodieCommitMetadata]] ): (Boolean, HOption[java.lang.String], HOption[java.lang.String]) = { val hasErrors = new AtomicBoolean(false) - log.info("Proceeding to commit the write.") + log.debug("Proceeding to commit the write.") // get extra metadata from props // 1. properties starting with commit metadata key prefix // 2. properties related to checkpoint in spark streaming @@ -1020,7 +1020,7 @@ class HoodieSparkSqlWriterInternal { common.util.Option.empty() } - log.info(s"Compaction Scheduled is $compactionInstant") + log.debug(s"Compaction Scheduled is $compactionInstant") val asyncClusteringEnabled = isAsyncClusteringEnabled(client, parameters) val clusteringInstant: common.util.Option[java.lang.String] = @@ -1030,12 +1030,12 @@ class HoodieSparkSqlWriterInternal { common.util.Option.empty() } - log.info(s"Clustering Scheduled is $clusteringInstant") + log.debug(s"Clustering Scheduled is $clusteringInstant") val metaSyncSuccess = metaSync(spark, HoodieWriterUtils.convertMapToHoodieConfig(parameters), tableInstantInfo.basePath, schema) - log.info(s"Is Async Compaction Enabled ? $asyncCompactionEnabled") + log.debug(s"Is Async Compaction Enabled ? $asyncCompactionEnabled") (commitSuccess && metaSyncSuccess, compactionInstant, clusteringInstant) } else { (false, common.util.Option.empty(), common.util.Option.empty()) @@ -1045,7 +1045,7 @@ class HoodieSparkSqlWriterInternal { private def isAsyncCompactionEnabled(client: SparkRDDWriteClient[_], tableConfig: HoodieTableConfig, parameters: Map[String, String], configuration: Configuration): Boolean = { - log.info(s"Config.inlineCompactionEnabled ? ${client.getConfig.inlineCompactionEnabled}") + log.debug(s"Config.inlineCompactionEnabled ? ${client.getConfig.inlineCompactionEnabled}") (asyncCompactionTriggerFnDefined && !client.getConfig.inlineCompactionEnabled && parameters.get(ASYNC_COMPACT_ENABLE.key).exists(r => r.toBoolean) && tableConfig.getTableType == MERGE_ON_READ) @@ -1053,7 +1053,7 @@ class HoodieSparkSqlWriterInternal { private def isAsyncClusteringEnabled(client: SparkRDDWriteClient[_], parameters: Map[String, String]): Boolean = { - log.info(s"Config.asyncClusteringEnabled ? ${client.getConfig.isAsyncClusteringEnabled}") + log.debug(s"Config.asyncClusteringEnabled ? ${client.getConfig.isAsyncClusteringEnabled}") (asyncClusteringTriggerFnDefined && !client.getConfig.inlineClusteringEnabled && client.getConfig.isAsyncClusteringEnabled) } @@ -1099,6 +1099,9 @@ class HoodieSparkSqlWriterInternal { } } val mergedParams = mutable.Map.empty ++ HoodieWriterUtils.parametersWithWriteDefaults(translatedOptsWithMappedTableConfig.toMap) + if (!mergedParams.contains(HoodieTableConfig.TYPE.key) && mergedParams.contains(TABLE_TYPE.key)) { + mergedParams(HoodieTableConfig.TYPE.key) = mergedParams(TABLE_TYPE.key) + } if (mergedParams.contains(KEYGENERATOR_CLASS_NAME.key) && !mergedParams.contains(HoodieTableConfig.KEY_GENERATOR_TYPE.key)) { mergedParams(HoodieTableConfig.KEY_GENERATOR_TYPE.key) = KeyGeneratorType.fromClassName(mergedParams(DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key)).name } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala index e76af4a2733d8..54459ff61e00a 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala @@ -53,8 +53,12 @@ object HoodieWriterUtils { * Add default options for unspecified write options keys. */ def parametersWithWriteDefaults(parameters: Map[String, String]): Map[String, String] = { + // Strip the `spark.` prefix from `spark.hoodie.*` keys so write/hive_sync configs + // forwarded from SparkConf reach Hudi under their canonical names. Mirrors what + // parametersWithReadDefaults does for the read path. + val normalizedParams = DataSourceOptionsHelper.normalizeSparkHoodiePrefix(parameters) val globalProps = DFSPropertiesConfiguration.getGlobalProps.asScala - val props = TypedProperties.fromMap(parameters.asJava) + val props = TypedProperties.fromMap(normalizedParams.asJava) val hoodieConfig: HoodieConfig = new HoodieConfig(props) hoodieConfig.setDefaultValue(OPERATION) hoodieConfig.setDefaultValue(TABLE_TYPE) @@ -87,7 +91,7 @@ object HoodieWriterUtils { hoodieConfig.setDefaultValue(RECONCILE_SCHEMA) hoodieConfig.setDefaultValue(DROP_PARTITION_COLUMNS) hoodieConfig.setDefaultValue(KEYGENERATOR_CONSISTENT_LOGICAL_TIMESTAMP_ENABLED) - Map() ++ hoodieConfig.getProps.asScala ++ globalProps ++ DataSourceOptionsHelper.translateConfigurations(parameters) + Map() ++ hoodieConfig.getProps.asScala ++ globalProps ++ DataSourceOptionsHelper.translateConfigurations(normalizedParams) } /** diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala index 7a97367bb3c59..cc316243d2664 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala @@ -62,6 +62,7 @@ import org.apache.spark.unsafe.types.UTF8String import java.io.Closeable import java.util import java.util.{Collections, Locale} +import java.util.function.UnaryOperator import java.util.stream.Collectors import scala.annotation.tailrec @@ -228,7 +229,9 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit, props.getBoolean(DISK_MAP_BITCASK_COMPRESSION_ENABLED.key(), DISK_MAP_BITCASK_COMPRESSION_ENABLED.defaultValue()), getClass.getSimpleName) - private val internalRowToJsonStringConverterMap: mutable.Map[Integer, InternalRowToJsonStringConverter] = mutable.Map.empty + // Per-schema cache of the (image projection, json converter) used to build CDC before/after + // images. Keyed by the record's schema id so schema evolution is handled correctly. + private val cdcImageConverterMap: mutable.Map[Integer, (UnaryOperator[InternalRow], InternalRowToJsonStringConverter)] = mutable.Map.empty private def needLoadNextFile: Boolean = { !recordIter.hasNext && @@ -513,13 +516,15 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit, } private def loadFileSlice(fileSlice: FileSlice, readerContext: SparkFileFormatInternalRowReaderContext): Iterator[BufferedRecord[InternalRow]] = { - val fileGroupReader = HoodieFileGroupReader.newBuilder() + val fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile) + .withLogFiles(fileSlice.getLogFiles) + .withPartitionPath(fileSlice.getPartitionPath) .withDataSchema(schema) .withRequestedSchema(schema) - .withInternalSchema(toJavaOption(originTableSchema.internalSchema)) + .withInternalSchemaOpt(toJavaOption(originTableSchema.internalSchema)) .withProps(readerProperties) .withLatestCommitTime(split.changes.last.getInstant) .build() @@ -557,9 +562,20 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit, * Convert InternalRow to json string. */ private def convertBufferedRecordToJsonString(record: BufferedRecord[InternalRow]): UTF8String = { - internalRowToJsonStringConverterMap.getOrElseUpdate(record.getSchemaId, - new InternalRowToJsonStringConverter(HoodieInternalRowUtils.getCachedSchema(readerContext.getRecordContext.decodeAvroSchema(record.getSchemaId)))) - .convert(record.getRecord) + val (imageProjection, converter) = cdcImageConverterMap.getOrElseUpdate(record.getSchemaId, { + val recordSchema = readerContext.getRecordContext.decodeAvroSchema(record.getSchemaId) + // CDC before/after images must contain only business columns. Records read from base/log + // files carry the _hoodie_* meta columns (kept on the InternalRow because they are needed + // internally for record keying and merging), while images served from the supplemental CDC + // log already have them stripped at write time (HoodieCDCLogger). Project each record onto + // the meta-stripped image schema so every inference case produces a schema-consistent, + // business-columns-only image. + val imageSchema = HoodieSchemaUtils.removeMetadataFields(recordSchema) + val projection = readerContext.getRecordContext.projectRecord(recordSchema, imageSchema) + val converter = new InternalRowToJsonStringConverter(HoodieInternalRowUtils.getCachedSchema(imageSchema)) + (projection, converter) + }) + converter.convert(imageProjection.apply(record.getRecord)) } /** diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala new file mode 100644 index 0000000000000..821eebcce2300 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala @@ -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.spark.sql + +import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering +import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} +import org.apache.spark.sql.execution.datasources.DataSourceStrategy +import org.apache.spark.sql.types.DataType + +/** + * Base implementation of [[HoodieCatalystExpressionUtils]] carrying the method bodies that are + * identical across all supported Spark versions. Methods relying on Spark APIs that changed + * across versions are implemented in the per-version `HoodieSparkXXCatalystExpressionUtils` + * objects (or in [[HoodieSpark4CatalystExpressionUtils]] when shared within a Spark major version). + */ +abstract class BaseHoodieCatalystExpressionUtils extends HoodieCatalystExpressionUtils with PredicateHelper { + + override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = { + DataSourceStrategy.normalizeExprs(exprs, attributes) + } + + override def extractPredicatesWithinOutputSet(condition: Expression, + outputSet: AttributeSet): Option[Expression] = { + super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) + } + + override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { + expr match { + case OrderPreservingTransformation(attrRef) => Some(attrRef) + case _ => None + } + } + + def canUpCast(fromType: DataType, toType: DataType): Boolean = + Cast.canUpCast(fromType, toType) + + /** + * Matches order-preserving date/time parsing expressions whose case-class shapes differ across + * Spark versions (currently [[org.apache.spark.sql.catalyst.expressions.ParseToDate]] and + * [[org.apache.spark.sql.catalyst.expressions.ParseToTimestamp]]), returning the source child + * expression that the order-preserving transformation matching should recurse into + */ + protected def unapplyOrderPreservingDateParsing(expr: Expression): Option[Expression] + + private object OrderPreservingTransformation { + def unapply(expr: Expression): Option[AttributeReference] = { + expr match { + // Date/Time Expressions + case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) + case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) + case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) + case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) + case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + + // String Expressions + case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) + // Left API change: Improve RuntimeReplaceable + // https://issues.apache.org/jira/browse/SPARK-38240 + case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + + // Math Expressions + // Binary + case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) + case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) + case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) + case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) + // Unary + case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) + case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) + + // Other + case cast @ Cast(OrderPreservingTransformation(attrRef), _, _, _) + if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) + + // Identity transformation + case attrRef: AttributeReference => Some(attrRef) + // Date/time parsing expressions whose shapes are Spark-version-specific + case _ => unapplyOrderPreservingDateParsing(expr) match { + case Some(child) => unapply(child) + case None => None + } + } + } + } +} diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystPlanUtils.scala index 498f3f276dc01..c8ceb713a6a3a 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystPlanUtils.scala @@ -20,14 +20,14 @@ package org.apache.spark.sql import org.apache.hudi.SparkAdapterSupport import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.TableOutputResolver +import org.apache.spark.sql.catalyst.analysis.{ResolvedTable, TableOutputResolver} import org.apache.spark.sql.catalyst.catalog.CatalogStorageFormat import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} import org.apache.spark.sql.catalyst.plans.JoinType -import org.apache.spark.sql.catalyst.plans.logical.{InsertIntoStatement, Join, JoinHint, LogicalPlan} +import org.apache.spark.sql.catalyst.plans.logical.{CreateIndex, DropIndex, HoodieShowIndexes, InsertIntoStatement, Join, JoinHint, LogicalPlan, RefreshIndex} import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} import org.apache.spark.sql.execution.{ExtendedMode, SimpleMode} -import org.apache.spark.sql.execution.command.{CreateTableLikeCommand, ExplainCommand} +import org.apache.spark.sql.execution.command.{CreateTableLikeCommand, ExplainCommand, RepairTableCommand} import org.apache.spark.sql.execution.datasources.LogicalRelation import org.apache.spark.sql.internal.SQLConf import org.apache.spark.sql.types.StructType @@ -37,12 +37,17 @@ trait BaseHoodieCatalystPlanUtils extends HoodieCatalystPlansUtils { /** * Instantiates [[ProjectionOverSchema]] utility */ - def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema + def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = + ProjectionOverSchema(schema, output) /** * Un-applies [[ResolvedTable]] that had its signature changed in Spark 3.2 */ - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] + def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = + plan match { + case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) + case _ => None + } def resolveOutputColumns(tableName: String, expected: Seq[Attribute], @@ -77,7 +82,72 @@ trait BaseHoodieCatalystPlanUtils extends HoodieCatalystPlansUtils { a.sameOutput(b) } - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = None + override def isRepairTable(plan: LogicalPlan): Boolean = { + plan.isInstanceOf[RepairTableCommand] + } + + override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { + plan match { + case rtc: RepairTableCommand => + Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) + case _ => + None + } + } + + override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { + plan match { + case ci@CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => + Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) + case _ => + None + } + } + + override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { + plan match { + case ci@DropIndex(table, indexName, ignoreIfNotExists) => + Some((table, indexName, ignoreIfNotExists)) + case _ => + None + } + } + + override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { + plan match { + case ci@HoodieShowIndexes(table, output) => + Some((table, output)) + case _ => + None + } + } + + override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { + plan match { + case ci@RefreshIndex(table, indexName) => + Some((table, indexName)) + case _ => + None + } + } + + override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { + plan match { + case insert: InsertIntoStatement => + Some((insert.table, insert.userSpecifiedCols, insert.partitionSpec, insert.query, insert.overwrite, insert.ifPartitionNotExists)) + case _ => + None + } + } + + override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { + plan match { + case insert: InsertIntoStatement => + Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) + case _ => + None + } + } } object BaseHoodieCatalystPlanUtils extends SparkAdapterSupport { diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystExpressionUtils.scala deleted file mode 100644 index cf63383ef35a7..0000000000000 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystExpressionUtils.scala +++ /dev/null @@ -1,37 +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.spark.sql - -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression} -abstract class HoodieSpark3CatalystExpressionUtils extends HoodieCatalystExpressionUtils { - - /** - * The attribute name may differ from the one in the schema if the query analyzer - * is case insensitive. We should change attribute names to match the ones in the schema, - * so we do not need to worry about case sensitivity anymore - */ - def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] - - /** - * Returns a filter that its reference is a subset of `outputSet` and it contains the maximum - * constraints from `condition`. This is used for predicate push-down - * When there is no such filter, `None` is returned. - */ - def extractPredicatesWithinOutputSet(condition: Expression, - outputSet: AttributeSet): Option[Expression] -} diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala similarity index 85% rename from hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala rename to hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala index 028ebebf0bf59..b11987a40c675 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala @@ -18,10 +18,7 @@ package org.apache.spark.sql.avro import org.apache.avro.Schema -import org.apache.avro.file.FileReader -import org.apache.avro.generic.GenericRecord import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.internal.SQLConf import org.apache.spark.sql.types._ @@ -34,6 +31,9 @@ import scala.collection.JavaConverters._ * This code is borrowed, so that we can better control compatibility w/in Spark minor * branches (3.2.x, 3.1.x, etc) * + * This copy omits the upstream `RowReader` trait: it is unused in Hudi and references + * the version-specific vendored `AvroDeserializer`, which is not visible to this module. + * * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY */ private[sql] object AvroUtils extends Logging { @@ -55,45 +55,6 @@ private[sql] object AvroUtils extends Logging { case _ => false } - // The trait provides iterator-like interface for reading records from an Avro file, - // deserializing and returning them as internal rows. - trait RowReader { - protected val fileReader: FileReader[GenericRecord] - protected val deserializer: AvroDeserializer - protected val stopPosition: Long - - private[this] var completed = false - private[this] var currentRow: Option[InternalRow] = None - - def hasNextRow: Boolean = { - while (!completed && currentRow.isEmpty) { - val r = fileReader.hasNext && !fileReader.pastSync(stopPosition) - if (!r) { - fileReader.close() - completed = true - currentRow = None - } else { - val record = fileReader.next() - // the row must be deserialized in hasNextRow, because AvroDeserializer#deserialize - // potentially filters rows - currentRow = deserializer.deserialize(record).asInstanceOf[Option[InternalRow]] - } - } - currentRow.isDefined - } - - def nextRow: InternalRow = { - if (currentRow.isEmpty) { - hasNextRow - } - val returnRow = currentRow - currentRow = None // free up hasNextRow to consume more Avro records, if not exhausted - returnRow.getOrElse { - throw new NoSuchElementException("next on empty iterator") - } - } - } - /** Wrapper for a pair of matched fields, one Catalyst and one corresponding Avro field. */ private[sql] case class AvroMatchedField( catalystField: StructField, diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/BaseHoodieNestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieNestedSchemaPruning.scala similarity index 77% rename from hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/BaseHoodieNestedSchemaPruning.scala rename to hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieNestedSchemaPruning.scala index 9b6e26984401f..42e59eea5c51d 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/BaseHoodieNestedSchemaPruning.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieNestedSchemaPruning.scala @@ -37,12 +37,9 @@ import org.apache.spark.sql.util.SchemaUtils.restoreOriginalOutputNames * NOTE: This class is borrowed from Spark 3.2.1, with modifications adapting it to handle [[HoodieBaseRelation]], * instead of [[HadoopFsRelation]] */ -abstract class BaseHoodieNestedSchemaPruning extends Rule[LogicalPlan] { +class HoodieNestedSchemaPruning extends Rule[LogicalPlan] { import org.apache.spark.sql.catalyst.expressions.SchemaPruning._ - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], requiredSchema: StructType): Seq[AttributeReference] - override def apply(plan: LogicalPlan): LogicalPlan = if (conf.nestedSchemaPruningEnabled) { apply0(plan) @@ -50,13 +47,51 @@ abstract class BaseHoodieNestedSchemaPruning extends Rule[LogicalPlan] { plan } - protected def apply0(plan: LogicalPlan): LogicalPlan + private def apply0(plan: LogicalPlan): LogicalPlan = + plan transformDown { + // NOTE: The relation is matched by type rather than by destructuring [[LogicalRelation]], + // since the arity of its unapply differs across the Spark versions this module + // compiles against. This is modified to accommodate for Hudi's custom relations, + // given that original [[NestedSchemaPruning]] rule is tightly coupled w/ + // [[HadoopFsRelation]] + // TODO generalize to any file-based relation + case op @ PhysicalOperation(projects, filters, l: LogicalRelation) => + l.relation match { + case relation: HoodieBaseRelation if relation.canPruneRelationSchema => + prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, + prunedDataSchema => { + val prunedRelation = + relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) + buildPrunedRelation(l, prunedRelation) + }).getOrElse(op) + case _ => op + } + } + + // Prune the given output to make it consistent with `requiredSchema`. + private def getPrunedOutput(output: Seq[AttributeReference], + requiredSchema: StructType): Seq[AttributeReference] = { + // We need to replace the expression ids of the pruned relation output attributes + // with the expression ids of the original relation output attributes so that + // references to the original relation's output are not broken + val outputIdMap = output.map(att => (att.name, att.exprId)).toMap + // NOTE: The attributes are constructed inline (equivalent to StructType#toAttributes before + // Spark 3.5 and DataTypeUtils#toAttributes since, see SPARK-44353) so that this code + // compiles against every supported Spark version + requiredSchema + .map(f => AttributeReference(f.name, f.dataType, f.nullable, f.metadata)()) + .map { + case att if outputIdMap.contains(att.name) => + att.withExprId(outputIdMap(att.name)) + case att => att + } + } /** * This method returns optional logical plan. `None` is returned if no nested field is required or * all nested fields are required. */ - protected def prunePhysicalColumns(output: Seq[AttributeReference], + private def prunePhysicalColumns(output: Seq[AttributeReference], projects: Seq[NamedExpression], filters: Seq[Expression], dataSchema: StructType, @@ -148,7 +183,7 @@ abstract class BaseHoodieNestedSchemaPruning extends Rule[LogicalPlan] { * Builds a pruned logical relation from the output of the output relation and the schema of the * pruned base relation. */ - protected def buildPrunedRelation(outputRelation: LogicalRelation, + private def buildPrunedRelation(outputRelation: LogicalRelation, prunedBaseRelation: BaseRelation): LogicalRelation = { val prunedOutput = getPrunedOutput(outputRelation.output, prunedBaseRelation.schema) outputRelation.copy(relation = prunedBaseRelation, output = prunedOutput) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala index 65dec06cd3e00..c653d3f0f9e53 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala @@ -131,25 +131,35 @@ class SparkLanceReaderBase(enableVectorizedReader: Boolean) extends SparkColumna null } - // Honor `hoodie.read.blob.inline.mode`. CONTENT (default) materializes INLINE bytes in - // the `data` column; DESCRIPTOR surfaces per-row {position, size} which the descriptor - // iterator rewrites into Hudi OUT_OF_LINE references. Non-blob Lance columns ignore - // the option regardless. + // Honor `hoodie.read.blob.inline.mode`. DESCRIPTOR (default) surfaces per-row + // {position, size} which the descriptor iterator turns into a synthesized `reference` + // while leaving `type = INLINE`; CONTENT is the opt-in mode that materializes INLINE + // bytes in the `data` column. Non-blob Lance columns ignore the option regardless. val blobMode = resolveBlobReadMode(storageConf) val readOpts = FileReadOptions.builder().blobReadMode(blobMode).build() - val arrowReader = lanceReader.readAll(columnNames, null, DEFAULT_BATCH_SIZE, readOpts) // Compose the DESCRIPTOR-aware blob transform only when the user opted into that mode // AND the request actually has BLOB columns (otherwise the rewrite has nothing to do). - val blobFieldNames: java.util.Set[String] = - iteratorSchema.fields.collect { case f if isBlobField(f) => f.name }.toSet.asJava - val blobTransform = if (blobMode == BlobReadMode.DESCRIPTOR && !blobFieldNames.isEmpty) { - new BlobDescriptorTransform(blobFieldNames, filePath) + val blobFieldNames: Set[String] = + iteratorSchema.fields.collect { case f if isBlobField(f) => f.name }.toSet + val blobTransform = if (blobMode == BlobReadMode.DESCRIPTOR && blobFieldNames.nonEmpty) { + new BlobDescriptorTransform(blobFieldNames.asJava, filePath) } else { null } - lanceIterator = new LanceRecordIterator( - allocator, lanceReader, arrowReader, iteratorSchema, filePath, blobTransform) + // lance-core 4.0.0 aborts the JVM when a single readAll stream crosses Lance's internal + // BLOB page boundary (512 rows). For BLOB-containing reads, drain the file in <=512-row + // range chunks (one fresh readAll each); non-BLOB reads keep the single streamed reader. + // The detection recurses so a nested BLOB (unsupported by the writer today) still chunks. + lanceIterator = if (containsBlobField(iteratorSchema)) { + LanceRecordIterator.chunkedBlobReader( + allocator, lanceReader, columnNames, readOpts, lanceReader.numRows(), + iteratorSchema, filePath, blobTransform) + } else { + val arrowReader = lanceReader.readAll(columnNames, null, DEFAULT_BATCH_SIZE, readOpts) + new LanceRecordIterator( + allocator, lanceReader, arrowReader, iteratorSchema, filePath, blobTransform) + } // Register cleanup listener Option(TaskContext.get()).foreach { ctx => @@ -250,6 +260,14 @@ class SparkLanceReaderBase(enableVectorizedReader: Boolean) extends SparkColumna .getType == HoodieSchemaType.BLOB } + /** Recursively checks for a BLOB field (see [[isBlobField]]) at any nesting depth. */ + private def containsBlobField(dt: DataType): Boolean = dt match { + case s: StructType => s.fields.exists(f => isBlobField(f) || containsBlobField(f.dataType)) + case a: ArrayType => containsBlobField(a.elementType) + case m: MapType => containsBlobField(m.valueType) + case _ => false + } + private def forceFieldNullable(field: StructField): StructField = field.copy(nullable = true, dataType = forceTypeNullable(field.dataType)) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/orc/SparkOrcReaderBase.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/orc/SparkOrcReaderBase.scala index ca16e323246db..fa056921aeac6 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/orc/SparkOrcReaderBase.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/orc/SparkOrcReaderBase.scala @@ -19,6 +19,7 @@ package org.apache.spark.sql.execution.datasources.orc +import org.apache.hudi.SparkAdapterSupport import org.apache.hudi.common.util import org.apache.hudi.internal.schema.InternalSchema import org.apache.hudi.storage.StorageConfiguration @@ -32,19 +33,25 @@ import org.apache.orc.{OrcConf, OrcFile, TypeDescription} import org.apache.orc.mapred.OrcStruct import org.apache.orc.mapreduce.OrcInputFormat import org.apache.spark.TaskContext +import org.apache.spark.memory.MemoryMode import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{Attribute, JoinedRow} +import org.apache.spark.sql.catalyst.expressions.JoinedRow import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection -import org.apache.spark.sql.execution.datasources.{PartitionedFile, RecordReaderIterator, SparkColumnarFileReader} +import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile, RecordReaderIterator, SparkColumnarFileReader} +import org.apache.spark.sql.internal.SQLConf import org.apache.spark.sql.sources._ import org.apache.spark.sql.types.StructType import org.apache.spark.util.Utils -abstract class SparkOrcReaderBase(enableVectorizedReader: Boolean, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean) extends SparkColumnarFileReader { +class SparkOrcReaderBase(enableVectorizedReader: Boolean, + dataSchema: StructType, + orcFilterPushDown: Boolean, + isCaseSensitive: Boolean, + capacity: Int, + memoryMode: MemoryMode, + batchReaderFactory: (Int, MemoryMode) => OrcColumnarBatchReader) + extends SparkColumnarFileReader with SparkAdapterSupport { /** * Read an individual ORC file * @@ -62,7 +69,7 @@ abstract class SparkOrcReaderBase(enableVectorizedReader: Boolean, val resultSchema = StructType(requiredSchema.fields ++ partitionSchema.fields) val conf = storageConf.unwrap() - val filePath = partitionedFileToPath(file) + val filePath = new Path(sparkAdapter.getSparkPartitionedFileUtils.getPathFromPartitionedFile(file).toUri) val fs = filePath.getFileSystem(conf) val readerOptions = OrcFile.readerOptions(conf).filesystem(fs) @@ -96,7 +103,7 @@ abstract class SparkOrcReaderBase(enableVectorizedReader: Boolean, val taskAttemptContext = new TaskAttemptContextImpl(taskConf, attemptId) if (enableVectorizedReader) { - val batchReader = buildReader() + val batchReader = batchReaderFactory(capacity, memoryMode) // SPARK-23399 Register a task completion listener first to call `close()` in all cases. // There is a possibility that `initialize` and `initBatch` hit some errors (like OOM) // after opening a file. @@ -120,7 +127,8 @@ abstract class SparkOrcReaderBase(enableVectorizedReader: Boolean, val iter = new RecordReaderIterator[OrcStruct](orcRecordReader) Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ => iter.close())) - val fullSchema = structTypeToAttributes(requiredSchema) ++ structTypeToAttributes(partitionSchema) + val schemaUtils = sparkAdapter.getSchemaUtils + val fullSchema = schemaUtils.toAttributes(requiredSchema) ++ schemaUtils.toAttributes(partitionSchema) val unsafeProjection = GenerateUnsafeProjection.generate(fullSchema, fullSchema) val deserializer = new OrcDeserializer(requiredSchema, requestedColIds) @@ -134,10 +142,52 @@ abstract class SparkOrcReaderBase(enableVectorizedReader: Boolean, } } } +} - def partitionedFileToPath(file: PartitionedFile): Path - - def buildReader(): OrcColumnarBatchReader +object SparkOrcReaderBase { + /** + * Get ORC file reader + * + * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc + * @param sqlConf the [[SQLConf]] used for the read + * @param options passed as a param to the file format + * @param hadoopConf some configs will be set for the hadoopConf + * @param dataSchema schema of the data + * @param batchReaderFactory creates the [[OrcColumnarBatchReader]] from the batch size and memory + * mode; the reader constructor differs across Spark versions + * @return ORC file reader + */ + def build(vectorized: Boolean, + sqlConf: SQLConf, + options: Map[String, String], + hadoopConf: Configuration, + dataSchema: StructType, + batchReaderFactory: (Int, MemoryMode) => OrcColumnarBatchReader): SparkOrcReaderBase = { + //set hadoopconf + hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) + hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) + hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) + + val memoryMode = if (sqlConf.offHeapColumnVectorEnabled) { + MemoryMode.OFF_HEAP + } else { + MemoryMode.ON_HEAP + } - def structTypeToAttributes(schema: StructType): Seq[Attribute] + val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && + options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, + throw new IllegalArgumentException( + "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + + "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) + .equals("true") + + new SparkOrcReaderBase( + enableVectorizedReader = enableVectorizedReader && vectorized, + dataSchema = dataSchema, + orcFilterPushDown = sqlConf.orcFilterPushDown, + isCaseSensitive = sqlConf.caseSensitiveAnalysis, + capacity = sqlConf.orcVectorizedReaderBatchSize, + memoryMode = memoryMode, + batchReaderFactory = batchReaderFactory) + } } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala index de4ffb400d4c2..3fe8c6ff62f71 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala @@ -304,7 +304,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, val readerContext = new SparkFileFormatInternalRowReaderContext( fileGroupBaseFileReader.value, filters, requiredFilters, storageConf, metaClient.getTableConfig, sparkRequiredSchema = Some(requiredSchema)) - readerContext.setEnableLogicalTimestampFieldRepair(storageConf.getBoolean(ENABLE_LOGICAL_TIMESTAMP_REPAIR, true)) + readerContext.enableLogicalTimestampFieldRepair(storageConf.getBoolean(ENABLE_LOGICAL_TIMESTAMP_REPAIR, true)) val props = metaClient.getTableConfig.getProps options.foreach(kv => props.setProperty(kv._1, kv._2)) props.put(HoodieMemoryConfig.MAX_MEMORY_FOR_MERGE.key(), String.valueOf(maxMemoryPerCompaction)) @@ -313,14 +313,16 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, } else { 0 } - val reader = HoodieFileGroupReader.newBuilder() + val reader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(queryTimestamp) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile) + .withLogFiles(fileSlice.getLogFiles) + .withPartitionPath(fileSlice.getPartitionPath) .withDataSchema(dataSchema) .withRequestedSchema(requestedSchema) - .withInternalSchema(internalSchemaOpt) + .withInternalSchemaOpt(internalSchemaOpt) .withProps(props) .withStart(file.start) .withLength(baseFileLength) diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/hudi/Spark35ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/BaseResolveHudiAlterTableCommand.scala similarity index 52% rename from hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/hudi/Spark35ResolveHudiAlterTableCommand.scala rename to hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/BaseResolveHudiAlterTableCommand.scala index 8e0f41c2b9964..029ba63b4b41c 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/hudi/Spark35ResolveHudiAlterTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/BaseResolveHudiAlterTableCommand.scala @@ -30,32 +30,38 @@ import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCom * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. * for alter table column commands. */ -class Spark35ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { +abstract class BaseResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { def apply(plan: LogicalPlan): LogicalPlan = { if (ProvidesHoodieConfig.isSchemaEvolutionEnabled(sparkSession)) { - plan.resolveOperatorsUp { - case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => - HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) - case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => - HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) - case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => - HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) - case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => - HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) - case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => - HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) - case alter@AlterColumn(ResolvedHoodieV2TablePlan(t), _, _, _, _, _, _) if alter.resolved => - HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) - case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => - HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) - } + plan.resolveOperatorsUp(resolveCommonCommand.orElse(resolveAlterColumnCommand)) } else { plan } } - object ResolvedHoodieV2TablePlan { + private def resolveCommonCommand: PartialFunction[LogicalPlan, LogicalPlan] = { + case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => + HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) + case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => + HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) + case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => + HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) + case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => + HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) + case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => + HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) + case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => + HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) + } + + /** + * Resolves the ALTER TABLE ... ALTER COLUMN command, whose logical plan differs between + * the Spark 3.x (AlterColumn) and Spark 4.x (AlterColumns) branches. + */ + protected def resolveAlterColumnCommand: PartialFunction[LogicalPlan, LogicalPlan] + + protected object ResolvedHoodieV2TablePlan { def unapply(plan: LogicalPlan): Option[HoodieInternalV2Table] = { plan match { case ResolvedTable(_, _, v2Table: HoodieInternalV2Table, _) => Some(v2Table) @@ -64,4 +70,3 @@ class Spark35ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Ru } } } - diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSparkBaseAnalysis.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSparkBaseAnalysis.scala index 88ad684c1e2fd..5160706e415cb 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSparkBaseAnalysis.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSparkBaseAnalysis.scala @@ -183,7 +183,14 @@ case class ResolveReferences(spark: SparkSession) extends Rule[LogicalPlan] val sourceTable = if (sourceTableO.resolved) sourceTableO else analyzer.execute(sourceTableO) val m = mO.asInstanceOf[MergeIntoTable].copy(targetTable = targetTable, sourceTable = sourceTable) // END: custom Hudi change - EliminateSubqueryAliases(targetTable) match { + // If the source table still has unresolved references (e.g. a non-existent + // column in the source query, or a missing source table), return the + // partially-resolved MIT and let Spark's CheckAnalysis surface the error. + // Continuing into the resolve-assignments path would either lose the column + // context or throw a less informative UnresolvedException. + if (!sourceTable.resolved) { + m + } else EliminateSubqueryAliases(targetTable) match { case r: NamedRelation if r.skipSchemaResolution => // Do not resolve the expression if the target table accepts any schema. // This allows data sources to customize their own resolution logic using diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala index 2cc94c3e14792..ae2ac079187d6 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/BasicStagedTable.scala @@ -38,7 +38,7 @@ case class BasicStagedTable(ident: Identifier, table: Table, catalog: TableCatalog) extends SupportsWrite with StagedTable { override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = { - info match { + table match { case supportsWrite: SupportsWrite => supportsWrite.newWriteBuilder(info) case _ => throw new HoodieException(s"Table `${ident.name}` does not support writes.") } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala index 2527602653023..f5ef66a04b92c 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala @@ -74,7 +74,7 @@ class HoodieCatalog extends DelegatingCatalogExtension } else { BasicStagedTable( ident, - super.createTable(ident, schema, partitions, properties), + createOrLoadTable(ident, schema, partitions, properties), this) } } @@ -92,7 +92,7 @@ class HoodieCatalog extends DelegatingCatalogExtension super.dropTable(ident) BasicStagedTable( ident, - super.createTable(ident, schema, partitions, properties), + createOrLoadTable(ident, schema, partitions, properties), this) } } @@ -115,11 +115,25 @@ class HoodieCatalog extends DelegatingCatalogExtension } BasicStagedTable( ident, - super.createTable(ident, schema, partitions, properties), + createOrLoadTable(ident, schema, partitions, properties), this) } } + /** + * Creates the table in the delegate catalog and returns it, never null. + * + * A delegate is allowed to return null from `createTable`: `V2SessionCatalog` does so deliberately, to save the + * `loadTable` round-trip for a `CREATE TABLE` without `AS SELECT`. Load the table that was just created in that + * case, so that the staged table is never backed by a null table. Spark guards its own staging the same way. + */ + private def createOrLoadTable(ident: Identifier, + schema: StructType, + partitions: Array[Transform], + properties: util.Map[String, String]): Table = { + Option(super.createTable(ident, schema, partitions, properties)).getOrElse(loadTable(ident)) + } + override def loadTable(ident: Identifier): Table = { super.loadTable(ident) match { case V1Table(catalogTable0) if sparkAdapter.isHoodieTable(catalogTable0) => diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/payload/ExpressionPayload.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/payload/ExpressionPayload.scala index 7fab437b294f9..a33fe583a6dbf 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/payload/ExpressionPayload.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/payload/ExpressionPayload.scala @@ -22,7 +22,7 @@ import org.apache.hudi.HoodieSchemaConversionUtils.{convertHoodieSchemaToDataTyp import org.apache.hudi.SparkAdapterSupport.sparkAdapter import org.apache.hudi.avro.HoodieAvroUtils import org.apache.hudi.common.model.{DefaultHoodieRecordPayload, HoodiePayloadProps, HoodieRecord, HoodieRecordPayload, OverwriteWithLatestAvroPayload} -import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaUtils} +import org.apache.hudi.common.schema.{HoodieAvroSchemaCache, HoodieSchema, HoodieSchemaUtils} import org.apache.hudi.common.util.{BinaryUtil, ConfigUtils, HoodieRecordUtils, Option => HOption, OrderingValues, StringUtils, ValidationUtils} import org.apache.hudi.common.util.ValidationUtils.checkState import org.apache.hudi.config.HoodieWriteConfig @@ -116,7 +116,7 @@ class ExpressionPayload(@transient record: GenericRecord, // Get the Evaluator for each condition and update assignments. val updateConditionAndAssignments = - getEvaluator(updateConditionAndAssignmentsText.toString, HoodieSchema.fromAvroSchema(inputRecord.asAvro.getSchema)) + getEvaluator(updateConditionAndAssignmentsText.toString, HoodieAvroSchemaCache.intern(inputRecord.asAvro.getSchema)) for ((conditionEvaluator, assignmentEvaluator) <- updateConditionAndAssignments if resultRecordOpt == null) { @@ -145,7 +145,7 @@ class ExpressionPayload(@transient record: GenericRecord, // Process delete val deleteConditionText = properties.get(ExpressionPayload.PAYLOAD_DELETE_CONDITION) if (deleteConditionText != null) { - val (deleteConditionEvaluator, _) = getEvaluator(deleteConditionText.toString, HoodieSchema.fromAvroSchema(inputRecord.asAvro.getSchema)).head + val (deleteConditionEvaluator, _) = getEvaluator(deleteConditionText.toString, HoodieAvroSchemaCache.intern(inputRecord.asAvro.getSchema)).head val deleteConditionEvalResult = deleteConditionEvaluator.apply(inputRecord.asRow) .get(0, BooleanType) .asInstanceOf[Boolean] @@ -206,7 +206,7 @@ class ExpressionPayload(@transient record: GenericRecord, * multiple times for different expression evaluation invocations */ case class ConvertibleRecord(private val avro: GenericRecord) extends Logging { - private lazy val row: InternalRow = getAvroDeserializerFor(HoodieSchema.fromAvroSchema(avro.getSchema)).deserialize(avro) match { + private lazy val row: InternalRow = getAvroDeserializerFor(HoodieAvroSchemaCache.intern(avro.getSchema)).deserialize(avro) match { case Some(row) => row.asInstanceOf[InternalRow] case None => logError(s"Failed to deserialize Avro record `${avro.toString}` as Catalyst row") @@ -231,7 +231,7 @@ class ExpressionPayload(@transient record: GenericRecord, properties.get(ExpressionPayload.PAYLOAD_INSERT_CONDITION_AND_ASSIGNMENTS).toString // Get the evaluator for each condition and insert assignment. val insertConditionAndAssignments = - ExpressionPayload.getEvaluator(insertConditionAndAssignmentsText, HoodieSchema.fromAvroSchema(inputRecord.asAvro.getSchema)) + ExpressionPayload.getEvaluator(insertConditionAndAssignmentsText, HoodieAvroSchemaCache.intern(inputRecord.asAvro.getSchema)) var resultRecordOpt: HOption[IndexedRecord] = null for ((conditionEvaluator, assignmentEvaluator) <- insertConditionAndAssignments if resultRecordOpt == null) { @@ -243,7 +243,7 @@ class ExpressionPayload(@transient record: GenericRecord, if (conditionEvalResult) { val writerSchema = getWriterSchema(properties, false) val resultingRow = assignmentEvaluator.apply(inputRecord.asRow) - val resultingAvroRecord = getAvroSerializerFor(HoodieSchema.fromAvroSchema(writerSchema.getAvroSchema)) + val resultingAvroRecord = getAvroSerializerFor(HoodieAvroSchemaCache.intern(writerSchema.getAvroSchema)) .serialize(resultingRow) .asInstanceOf[GenericRecord] @@ -315,7 +315,7 @@ class ExpressionPayload(@transient record: GenericRecord, */ private def joinRecord(sourceRecord: IndexedRecord, targetRecord: IndexedRecord, props: Properties): GenericRecord = { val leftSchema = sourceRecord.getSchema - val joinSchema = getMergedSchema(HoodieSchema.fromAvroSchema(leftSchema), HoodieSchema.fromAvroSchema(targetRecord.getSchema)) + val joinSchema = getMergedSchema(HoodieAvroSchemaCache.intern(leftSchema), HoodieAvroSchemaCache.intern(targetRecord.getSchema)) // TODO rebase onto JoinRecord val values = new Array[AnyRef](joinSchema.getFields.size()) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/streaming/HoodieStreamSourceV2.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/streaming/HoodieStreamSourceV2.scala index d104108f6c0cf..11a44f58a0b63 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/streaming/HoodieStreamSourceV2.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/streaming/HoodieStreamSourceV2.scala @@ -184,7 +184,7 @@ class HoodieStreamSourceV2(sqlContext: SQLContext, } private def translateCheckpoint(commitTime: String): String = { - if (CheckpointUtils.shouldTargetCheckpointV2(writeTableVersion.versionCode(), getClass.getName)) { + if (writeTableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT)) { commitTime } else { CheckpointUtils.convertToCheckpointV1ForCommitTime( diff --git a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java index 18d488098c8f8..d4e1e3aeba7df 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java +++ b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java @@ -321,6 +321,25 @@ void testFieldReordering() { assertEquals(expected, deduceWriterSchema(end, start, true)); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testExistingColumnRelaxedToNullableEvolves(boolean setNullForMissingColumns) { + // Table has field2 as a required boolean; the incoming (source) schema relaxed it to nullable, same + // column set otherwise. The deduced writer schema must evolve field2 to nullable regardless of the + // set.null.for.missing.columns flag -- with the flag on this used to silently stay required, so records + // with null in field2 failed the write / were quarantined. + HoodieSchema table = createRecord("relaxRec", + createPrimitiveField("field1", HoodieSchemaType.INT), + createPrimitiveField("field2", HoodieSchemaType.BOOLEAN)); + HoodieSchema incoming = createRecord("relaxRec", + createPrimitiveField("field1", HoodieSchemaType.INT), + createNullablePrimitiveField("field2", HoodieSchemaType.BOOLEAN)); + HoodieSchema expected = createRecord("relaxRec", + createPrimitiveField("field1", HoodieSchemaType.INT), + createNullablePrimitiveField("field2", HoodieSchemaType.BOOLEAN)); + assertEquals(expected, deduceWriterSchema(incoming, table, setNullForMissingColumns)); + } + private static HoodieSchema deduceWriterSchema(HoodieSchema incomingSchema, HoodieSchema latestTableSchema) { return deduceWriterSchema(incomingSchema, latestTableSchema, false); } diff --git a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestDataSourceOptions.scala b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestDataSourceOptions.scala index 20d61973f213b..7dfce2faf5714 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestDataSourceOptions.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestDataSourceOptions.scala @@ -22,9 +22,11 @@ package org.apache.hudi import org.apache.hudi.common.config.{DFSPropertiesConfiguration, HoodieCommonConfig} import org.apache.hudi.common.table.HoodieTableConfig +import org.apache.spark.sql.SQLContext import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue} +import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue} import org.junit.jupiter.api.Test +import org.mockito.Mockito.{mock, when} class TestDataSourceOptions { @Test @@ -91,6 +93,133 @@ class TestDataSourceOptions { assertEquals(DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL, params3(DataSourceReadOptions.QUERY_TYPE.key)) } + @Test + def testNormalizeSparkHoodiePrefixStripsSparkPrefix(): Unit = { + val result = DataSourceOptionsHelper.normalizeSparkHoodiePrefix(Map( + "spark.hoodie.datasource.query.type" -> "snapshot", + "spark.hoodie.datasource.hive_sync.use_spark_catalog" -> "true", + "non.hoodie.key" -> "ignored" + )) + + assertEquals("snapshot", result("hoodie.datasource.query.type")) + assertEquals("true", result("hoodie.datasource.hive_sync.use_spark_catalog")) + assertEquals("ignored", result("non.hoodie.key")) + assertFalse(result.contains("spark.hoodie.datasource.query.type")) + assertFalse(result.contains("spark.hoodie.datasource.hive_sync.use_spark_catalog")) + } + + @Test + def testNormalizeSparkHoodiePrefixPrefersHoodieOverSparkHoodie(): Unit = { + val result = DataSourceOptionsHelper.normalizeSparkHoodiePrefix(Map( + "spark.hoodie.datasource.query.type" -> DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL, + "hoodie.datasource.query.type" -> DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL + )) + + assertEquals(DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL, + result("hoodie.datasource.query.type")) + assertFalse(result.contains("spark.hoodie.datasource.query.type")) + } + + @Test + def testNormalizeSparkHoodiePrefixIsIdempotent(): Unit = { + val once = DataSourceOptionsHelper.normalizeSparkHoodiePrefix(Map( + "spark.hoodie.datasource.query.type" -> "snapshot", + "hoodie.other.key" -> "v" + )) + val twice = DataSourceOptionsHelper.normalizeSparkHoodiePrefix(once) + + assertEquals(once, twice) + } + + @Test + def testCollectHoodieAndSparkHoodieConfsReturnsCanonicalKeys(): Unit = { + val sqlContext = mock(classOf[SQLContext]) + when(sqlContext.getAllConfs).thenReturn(Map( + "spark.hoodie.datasource.query.type" -> DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL, + "hoodie.datasource.write.operation" -> "upsert", + "spark.sql.shuffle.partitions" -> "200" // non-hoodie, must be filtered out + )) + + val result = DataSourceOptionsHelper.collectHoodieAndSparkHoodieConfs(sqlContext, Map.empty) + + assertEquals(DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL, + result("hoodie.datasource.query.type")) + assertEquals("upsert", result("hoodie.datasource.write.operation")) + assertFalse(result.contains("spark.hoodie.datasource.query.type")) + assertFalse(result.contains("spark.sql.shuffle.partitions")) + } + + @Test + def testCollectHoodieAndSparkHoodieConfsExplicitOptionsWin(): Unit = { + val sqlContext = mock(classOf[SQLContext]) + when(sqlContext.getAllConfs).thenReturn(Map( + "spark.hoodie.datasource.write.operation" -> "insert", + "hoodie.datasource.write.precombine.field" -> "ts_from_conf" + )) + + val result = DataSourceOptionsHelper.collectHoodieAndSparkHoodieConfs(sqlContext, Map( + "hoodie.datasource.write.operation" -> "upsert", // explicit overrides spark.hoodie.* + "hoodie.datasource.write.precombine.field" -> "ts_from_options" // explicit overrides hoodie.* + )) + + assertEquals("upsert", result("hoodie.datasource.write.operation")) + assertEquals("ts_from_options", result("hoodie.datasource.write.precombine.field")) + } + + @Test + def testCollectSparkHoodieConfsForwardsOnlySparkPrefix(): Unit = { + val sqlContext = mock(classOf[SQLContext]) + when(sqlContext.getAllConfs).thenReturn(Map( + "spark.hoodie.datasource.hive_sync.use_spark_catalog" -> "true", // forwarded (the --conf use case) + "hoodie.logfile.data.block.format" -> "parquet", // bare hoodie.* must NOT leak into writes + "hoodie.datasource.write.operation" -> "bulk_insert", // bare hoodie.* must NOT leak into writes + "spark.sql.shuffle.partitions" -> "200" // non-hoodie, filtered out + )) + + val result = DataSourceOptionsHelper.collectSparkHoodieConfs(sqlContext, Map.empty) + + assertEquals("true", result("hoodie.datasource.hive_sync.use_spark_catalog")) + assertFalse(result.contains("hoodie.logfile.data.block.format")) + assertFalse(result.contains("hoodie.datasource.write.operation")) + assertFalse(result.contains("spark.sql.shuffle.partitions")) + } + + @Test + def testCollectSparkHoodieConfsExplicitOptionsWin(): Unit = { + val sqlContext = mock(classOf[SQLContext]) + when(sqlContext.getAllConfs).thenReturn(Map( + "spark.hoodie.datasource.write.operation" -> "insert" + )) + + val result = DataSourceOptionsHelper.collectSparkHoodieConfs(sqlContext, Map( + "hoodie.datasource.write.operation" -> "upsert" // explicit overrides spark.hoodie.* + )) + + assertEquals("upsert", result("hoodie.datasource.write.operation")) + assertFalse(result.contains("spark.hoodie.datasource.write.operation")) + } + + @Test + def testWriteDefaultsSupportSparkHoodieConfigs(): Unit = { + val params = HoodieWriterUtils.parametersWithWriteDefaults(Map( + "spark.hoodie.datasource.write.operation" -> "upsert" + )) + + assertEquals("upsert", params(DataSourceWriteOptions.OPERATION.key)) + assertFalse(params.contains("spark.hoodie.datasource.write.operation")) + } + + @Test + def testWriteDefaultsPreferHoodieOverSparkHoodieWhenBothSet(): Unit = { + val params = HoodieWriterUtils.parametersWithWriteDefaults(Map( + "spark.hoodie.datasource.write.operation" -> "insert", + "hoodie.datasource.write.operation" -> "upsert" + )) + + assertEquals("upsert", params(DataSourceWriteOptions.OPERATION.key)) + assertFalse(params.contains("spark.hoodie.datasource.write.operation")) + } + @AfterEach def cleanup(): Unit = { DFSPropertiesConfiguration.clearGlobalProps() diff --git a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestHoodieCLIUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestHoodieCLIUtils.scala new file mode 100644 index 0000000000000..ca4869286c26b --- /dev/null +++ b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/TestHoodieCLIUtils.scala @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hudi + +import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows, assertTrue} +import org.junit.jupiter.api.Test + +class TestHoodieCLIUtils { + + @Test + def testExtractOptionsBasic(): Unit = { + val parsed = HoodieCLIUtils.extractOptions("k1=v1,k2=v2") + assertEquals(2, parsed.size) + assertEquals("v1", parsed("k1")) + assertEquals("v2", parsed("k2")) + } + + @Test + def testExtractOptionsTrimsWhitespace(): Unit = { + val parsed = HoodieCLIUtils.extractOptions(" k1 = v1 , k2= v 2 ") + assertEquals("v1", parsed("k1")) + // internal whitespace inside value is preserved, only edges are trimmed + assertEquals("v 2", parsed("k2")) + } + + @Test + def testExtractOptionsIgnoresEmptyTokens(): Unit = { + // trailing comma, consecutive commas, leading comma — all silently ignored + val parsed = HoodieCLIUtils.extractOptions(",k1=v1,, ,k2=v2,") + assertEquals(2, parsed.size) + assertEquals("v1", parsed("k1")) + assertEquals("v2", parsed("k2")) + } + + @Test + def testExtractOptionsValueContainsEquals(): Unit = { + // only the first `=` should be treated as a delimiter + val parsed = HoodieCLIUtils.extractOptions("k=a=b=c") + assertEquals(1, parsed.size) + assertEquals("a=b=c", parsed("k")) + } + + @Test + def testExtractOptionsAllowsEmptyValue(): Unit = { + val parsed = HoodieCLIUtils.extractOptions("k=") + assertEquals(1, parsed.size) + assertEquals("", parsed("k")) + } + + @Test + def testExtractOptionsDuplicateKeyLastWins(): Unit = { + val parsed = HoodieCLIUtils.extractOptions("k=v1,k=v2,k=v3") + assertEquals(1, parsed.size) + assertEquals("v3", parsed("k")) + } + + @Test + def testExtractOptionsNullAndEmpty(): Unit = { + assertTrue(HoodieCLIUtils.extractOptions(null).isEmpty) + assertTrue(HoodieCLIUtils.extractOptions("").isEmpty) + assertTrue(HoodieCLIUtils.extractOptions(" ").isEmpty) + assertTrue(HoodieCLIUtils.extractOptions(",,, ").isEmpty) + } + + @Test + def testExtractOptionsThrowsOnMissingDelimiter(): Unit = { + val ex = assertThrows( + classOf[IllegalArgumentException], + () => HoodieCLIUtils.extractOptions("k1=v1,invalid")) + assertTrue(ex.getMessage.contains("invalid")) + } + + @Test + def testExtractOptionsThrowsOnEmptyKey(): Unit = { + val ex = assertThrows( + classOf[IllegalArgumentException], + () => HoodieCLIUtils.extractOptions("=v")) + assertTrue(ex.getMessage.contains("key=value") || ex.getMessage.contains("Option key")) + } + + @Test + def testExtractOptionsThrowsOnWhitespaceKey(): Unit = { + assertThrows( + classOf[IllegalArgumentException], + () => HoodieCLIUtils.extractOptions(" =v")) + } +} diff --git a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.scala b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.scala new file mode 100644 index 0000000000000..7efc70a82abf8 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/catalog/TestBasicStagedTable.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.hudi.catalog + +import org.apache.hudi.exception.HoodieException + +import org.apache.spark.sql.connector.catalog.{Identifier, SupportsWrite, Table, TableCatalog} +import org.apache.spark.sql.connector.write.{LogicalWriteInfo, WriteBuilder} +import org.junit.jupiter.api.Assertions.{assertSame, assertThrows, assertTrue} +import org.junit.jupiter.api.Test +import org.mockito.Mockito.{mock, when} + +class TestBasicStagedTable { + + private val ident = Identifier.of(Array("db"), "tbl") + + @Test + def testNewWriteBuilderDelegatesToWritableTable(): Unit = { + val table = mock(classOf[SupportsWrite]) + val info = mock(classOf[LogicalWriteInfo]) + val writeBuilder = mock(classOf[WriteBuilder]) + when(table.newWriteBuilder(info)).thenReturn(writeBuilder) + + val staged = BasicStagedTable(ident, table, mock(classOf[TableCatalog])) + + assertSame(writeBuilder, staged.newWriteBuilder(info)) + } + + @Test + def testNewWriteBuilderThrowsWhenTableIsNotWritable(): Unit = { + val staged = BasicStagedTable(ident, mock(classOf[Table]), mock(classOf[TableCatalog])) + + val ex = assertThrows(classOf[HoodieException], + () => staged.newWriteBuilder(mock(classOf[LogicalWriteInfo]))) + assertTrue(ex.getMessage.contains("`tbl` does not support writes")) + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/main/java/org/apache/hudi/cli/ArchiveExecutorUtils.java b/hudi-spark-datasource/hudi-spark/src/main/java/org/apache/hudi/cli/ArchiveExecutorUtils.java index 772450903e5fb..b52d1965e0407 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/java/org/apache/hudi/cli/ArchiveExecutorUtils.java +++ b/hudi-spark-datasource/hudi-spark/src/main/java/org/apache/hudi/cli/ArchiveExecutorUtils.java @@ -40,6 +40,7 @@ import org.apache.spark.api.java.JavaSparkContext; import java.io.IOException; +import java.util.Map; /** * Archive Utils. @@ -53,12 +54,26 @@ public static int archive(JavaSparkContext jsc, int maxCommits, int commitsRetained, boolean enableMetadata, - String basePath) throws IOException { + String basePath, + Map options) throws IOException { + // NOTE on builder ordering: + // `withArchivalConfig`/`withCleanConfig`/`withMetadataConfig` each call + // `putAll(subConfig.getProps())` onto `writeConfig.getProps()`, which + // includes every key filled in by `setDefaults` during the sub-config's + // `build()`. If `withProps(conf)` ran BEFORE them, those defaults would + // overwrite the user's options (e.g. `hoodie.keep.min.commits`). + // + // Therefore `withProps(conf)` is intentionally placed LAST so user-supplied + // options reliably win over sub-config defaults. Named procedure params + // (min/max/retain/enableMetadata) are forwarded via the dedicated builders + // below; if the caller wants those to win over a same-name key in `conf`, + // the procedure layer is responsible for not putting that key into `conf`. HoodieWriteConfig config = HoodieWriteConfig.newBuilder().withPath(basePath) .withArchivalConfig(HoodieArchivalConfig.newBuilder().archiveCommitsWith(minCommits, maxCommits).build()) .withCleanConfig(HoodieCleanConfig.newBuilder().retainCommits(commitsRetained).build()) .withEmbeddedTimelineServerEnabled(false) .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(enableMetadata).build()) + .withProps(options) .build(); HoodieEngineContext context = new HoodieSparkEngineContext(jsc); HoodieSparkTable table = HoodieSparkTable.create(config, context); diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala index d01627ae1fd56..8872c5269f50e 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala @@ -18,8 +18,8 @@ package org.apache.spark.sql.hudi.analysis import org.apache.hudi.{HoodieSchemaUtils, HoodieSparkUtils, SparkAdapterSupport} -import org.apache.hudi.common.util.{ReflectionUtils, ValidationUtils} import org.apache.hudi.common.util.ReflectionUtils.loadClass +import org.apache.hudi.common.util.ValidationUtils import org.apache.spark.sql.SparkSession import org.apache.spark.sql.catalyst.TableIdentifier @@ -30,7 +30,7 @@ import org.apache.spark.sql.catalyst.optimizer.ReplaceExpressions import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.catalyst.rules.Rule import org.apache.spark.sql.execution.command._ -import org.apache.spark.sql.execution.datasources.{CreateTable, LogicalRelation} +import org.apache.spark.sql.execution.datasources.{CreateTable, HoodieNestedSchemaPruning, LogicalRelation} import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.{isMetaField, removeMetaFields} import org.apache.spark.sql.hudi.analysis.HoodieAnalysis.{sparkAdapter, MatchCreateIndex, MatchCreateTableLike, MatchDropIndex, MatchInsertIntoStatement, MatchMergeIntoTable, MatchRefreshIndex, MatchShowIndexes, ResolvesToHudiTable} import org.apache.spark.sql.hudi.blob.ReadBlobRule @@ -95,16 +95,10 @@ object HoodieAnalysis extends SparkAdapterSupport { } val resolveAlterTableCommandsClass = - if (HoodieSparkUtils.isSpark4_1) { - "org.apache.spark.sql.hudi.Spark41ResolveHudiAlterTableCommand" - } else if (HoodieSparkUtils.isSpark4_0) { - "org.apache.spark.sql.hudi.Spark40ResolveHudiAlterTableCommand" - } else if (HoodieSparkUtils.gteqSpark3_5) { - "org.apache.spark.sql.hudi.Spark35ResolveHudiAlterTableCommand" - } else if (HoodieSparkUtils.isSpark3_4) { - "org.apache.spark.sql.hudi.Spark34ResolveHudiAlterTableCommand" - } else if (HoodieSparkUtils.isSpark3_3) { - "org.apache.spark.sql.hudi.Spark33ResolveHudiAlterTableCommand" + if (HoodieSparkUtils.isSpark4) { + "org.apache.spark.sql.hudi.Spark4ResolveHudiAlterTableCommand" + } else if (HoodieSparkUtils.isSpark3) { + "org.apache.spark.sql.hudi.Spark3ResolveHudiAlterTableCommand" } else { throw new IllegalStateException("Unsupported Spark version") } @@ -143,21 +137,7 @@ object HoodieAnalysis extends SparkAdapterSupport { // Default rules ) - val nestedSchemaPruningClass = - if (HoodieSparkUtils.isSpark4_1) { - "org.apache.spark.sql.execution.datasources.Spark41NestedSchemaPruning" - } else if (HoodieSparkUtils.isSpark4_0) { - "org.apache.spark.sql.execution.datasources.Spark40NestedSchemaPruning" - } else if (HoodieSparkUtils.gteqSpark3_5) { - "org.apache.spark.sql.execution.datasources.Spark35NestedSchemaPruning" - } else if (HoodieSparkUtils.gteqSpark3_4) { - "org.apache.spark.sql.execution.datasources.Spark34NestedSchemaPruning" - } else { - // spark 3.3 - "org.apache.spark.sql.execution.datasources.Spark33NestedSchemaPruning" - } - - val nestedSchemaPruningRule = ReflectionUtils.loadClass(nestedSchemaPruningClass).asInstanceOf[Rule[LogicalPlan]] + val nestedSchemaPruningRule = new HoodieNestedSchemaPruning rules += (_ => nestedSchemaPruningRule) // NOTE: [[HoodiePruneFileSourcePartitions]] is a replica in kind to Spark's @@ -322,7 +302,12 @@ object HoodieAnalysis extends SparkAdapterSupport { analyzer.execute(plan) } - if (resolved.output.exists(attr => isMetaField(attr.name))) { + // If the plan is still not fully resolved (e.g., it references non-existent + // tables or columns), fall through. Spark's CheckAnalysis runs later and + // produces precise UNRESOLVED_COLUMN / TABLE_OR_VIEW_NOT_FOUND errors with + // "did you mean" suggestions; intercepting UnresolvedException here would + // discard that context. Only inspect the output once the plan is resolved. + if (resolved.resolved && resolved.output.exists(attr => isMetaField(attr.name))) { Some(resolved.output) } else { None @@ -423,7 +408,8 @@ case class ResolveImplementationsEarly(spark: SparkSession) extends Rule[Logical // Convert to CreateHoodieTableAsSelectCommand case ct @ CreateTable(table, mode, Some(query)) if sparkAdapter.isHoodieTable(table) && ct.query.forall(_.resolved) => - val alignedQuery = stripMetaFieldAttributes(query) + val alignedQuery = alignCtasQueryByPartitionOrder( + stripMetaFieldAttributes(query), table.partitionColumnNames) CreateHoodieTableAsSelectCommand(table, mode, alignedQuery) case ct: CreateTable => @@ -445,6 +431,37 @@ case class ResolveImplementationsEarly(spark: SparkSession) extends Rule[Logical case _ => plan } } + + private def alignCtasQueryByPartitionOrder(query: LogicalPlan, partitionColumns: Seq[String]): LogicalPlan = { + if (partitionColumns.isEmpty) { + query + } else { + val resolver = spark.sessionState.conf.resolver + val (dataAttrs, partitionAttrs) = query.output.partition { attr => + !partitionColumns.exists(partition => resolver(partition, attr.name)) + } + + if (partitionAttrs.size != partitionColumns.size) { + throw new HoodieAnalysisException(s"Partition columns ${partitionColumns.mkString("[", ", ", "]")} " + + s"do not match query output ${query.output.map(_.name).mkString("[", ", ", "]")}") + } + + val alreadyAligned = partitionColumns.zip(partitionAttrs).forall { + case (partition, attr) => resolver(partition, attr.name) + } + // Avoid adding a redundant Project when partition columns are already in the table-defined order. + if (alreadyAligned) { + query + } else { + val orderedPartitionAttrs = partitionColumns.map { partition => + partitionAttrs.find(attr => resolver(partition, attr.name)).getOrElse { + throw new HoodieAnalysisException(s"Cannot resolve partition column $partition in CTAS query output") + } + } + Project(dataAttrs ++ orderedPartitionAttrs, query) + } + } + } } /** diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ArchiveCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ArchiveCommitsProcedure.scala index efc5a0cc5c2a3..6dd578c91cf61 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ArchiveCommitsProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ArchiveCommitsProcedure.scala @@ -17,8 +17,10 @@ package org.apache.spark.sql.hudi.command.procedures -import org.apache.hudi.SparkAdapterSupport +import org.apache.hudi.{HoodieCLIUtils, SparkAdapterSupport} import org.apache.hudi.cli.ArchiveExecutorUtils +import org.apache.hudi.common.config.HoodieMetadataConfig +import org.apache.hudi.config.{HoodieArchivalConfig, HoodieCleanConfig} import org.apache.spark.internal.Logging import org.apache.spark.sql.Row @@ -26,17 +28,36 @@ import org.apache.spark.sql.types._ import java.util.function.Supplier +import scala.collection.JavaConverters._ + class ArchiveCommitsProcedure extends BaseProcedure with ProcedureBuilder with SparkAdapterSupport with Logging { + // NOTE: min_commits / max_commits / retain_commits / enable_metadata are + // intentionally declared WITHOUT default values. Whether a caller actually + // passed them is determined by `isArgDefined`; their effective values fall + // back to the corresponding ConfigProperty defaults (see `call`). private val PARAMETERS = Array[ProcedureParameter]( ProcedureParameter.optional(0, "table", DataTypes.StringType), ProcedureParameter.optional(1, "path", DataTypes.StringType), - ProcedureParameter.optional(2, "min_commits", DataTypes.IntegerType, 20), - ProcedureParameter.optional(3, "max_commits", DataTypes.IntegerType, 30), - ProcedureParameter.optional(4, "retain_commits", DataTypes.IntegerType, 10), - ProcedureParameter.optional(5, "enable_metadata", DataTypes.BooleanType, true) + ProcedureParameter.optional(2, "min_commits", DataTypes.IntegerType), + ProcedureParameter.optional(3, "max_commits", DataTypes.IntegerType), + ProcedureParameter.optional(4, "retain_commits", DataTypes.IntegerType), + ProcedureParameter.optional(5, "enable_metadata", DataTypes.BooleanType), + // free-form hoodie.* config overrides; format: 'k1=v1,k2=v2' + ProcedureParameter.optional(6, "options", DataTypes.StringType) + ) + + // Mapping of (named parameter -> hoodie.* config key) used both to merge + // named-parameter overrides on top of `options` and to back-fill scalar + // values fed to ArchiveExecutorUtils. Listed once to keep the named-param + // <-> ConfigProperty wiring in a single place. + private val NAMED_PARAM_TO_CONFIG_KEY: Seq[(ProcedureParameter, String)] = Seq( + PARAMETERS(2) -> HoodieArchivalConfig.MIN_COMMITS_TO_KEEP.key(), + PARAMETERS(3) -> HoodieArchivalConfig.MAX_COMMITS_TO_KEEP.key(), + PARAMETERS(4) -> HoodieCleanConfig.CLEANER_COMMITS_RETAINED.key(), + PARAMETERS(5) -> HoodieMetadataConfig.ENABLE.key() ) private val OUTPUT_TYPE = new StructType(Array[StructField]( @@ -52,20 +73,74 @@ class ArchiveCommitsProcedure extends BaseProcedure val tableName = getArgValueOrDefault(args, PARAMETERS(0)) val tablePath = getArgValueOrDefault(args, PARAMETERS(1)) - - val minCommits = getArgValueOrDefault(args, PARAMETERS(2)).get.asInstanceOf[Int] - val maxCommits = getArgValueOrDefault(args, PARAMETERS(3)).get.asInstanceOf[Int] - val retainCommits = getArgValueOrDefault(args, PARAMETERS(4)).get.asInstanceOf[Int] - val enableMetadata = getArgValueOrDefault(args, PARAMETERS(5)).get.asInstanceOf[Boolean] + val confs = getArchiveConfigs(args) + + val minCommits = parseInt(confs, + HoodieArchivalConfig.MIN_COMMITS_TO_KEEP.key(), + HoodieArchivalConfig.MIN_COMMITS_TO_KEEP.defaultValue()) + val maxCommits = parseInt(confs, + HoodieArchivalConfig.MAX_COMMITS_TO_KEEP.key(), + HoodieArchivalConfig.MAX_COMMITS_TO_KEEP.defaultValue()) + val retainCommits = parseInt(confs, + HoodieCleanConfig.CLEANER_COMMITS_RETAINED.key(), + HoodieCleanConfig.CLEANER_COMMITS_RETAINED.defaultValue()) + val enableMetadata = parseBoolean(confs, + HoodieMetadataConfig.ENABLE.key(), + HoodieMetadataConfig.ENABLE.defaultValue().toString) val basePath = getBasePath(tableName, tablePath) - Seq(Row(ArchiveExecutorUtils.archive(jsc, minCommits, maxCommits, retainCommits, enableMetadata, - basePath))) + basePath, + confs.asJava))) + } + + /** + * Build the effective hoodie.* config map by overlaying named parameters + * (only those the caller explicitly passed) on top of the user `options` + * string. Whether a parameter was explicitly passed is decided by + * `isArgDefined` rather than by checking the parameter's default, so the + * precedence semantics stay correct even if future maintainers add defaults + * back to the named parameters. + */ + private def getArchiveConfigs(args: ProcedureArgs): Map[String, String] = { + val optionConfs = getArgValueOrDefault(args, PARAMETERS(6)) + .map(p => HoodieCLIUtils.extractOptions(p.toString)) + .getOrElse(Map.empty[String, String]) + + NAMED_PARAM_TO_CONFIG_KEY.foldLeft(optionConfs) { + case (confs, (parameter, configKey)) => + if (isArgDefined(args, parameter)) { + confs + (configKey -> getArgValueOrDefault(args, parameter).get.toString) + } else { + confs + } + } + } + + private def parseInt(confs: Map[String, String], key: String, default: String): Int = { + val raw = confs.getOrElse(key, default) + try { + raw.toInt + } catch { + case _: NumberFormatException => + throw new IllegalArgumentException( + s"Invalid integer value for '$key': '$raw'. Expected a base-10 integer.") + } + } + + private def parseBoolean(confs: Map[String, String], key: String, default: String): Boolean = { + val raw = confs.getOrElse(key, default).trim.toLowerCase + raw match { + case "true" => true + case "false" => false + case _ => + throw new IllegalArgumentException( + s"Invalid boolean value for '$key': '$raw'. Expected 'true' or 'false'.") + } } override def build = new ArchiveCommitsProcedure() diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/CleanupStaleInflightCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/CleanupStaleInflightCommitsProcedure.scala new file mode 100644 index 0000000000000..66e31302e45bb --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/CleanupStaleInflightCommitsProcedure.scala @@ -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.spark.sql.hudi.command.procedures + +import org.apache.hudi.{HoodieCLIUtils, HoodieTimelineCleanupUtil} +import org.apache.hudi.client.SparkRDDWriteClient +import org.apache.hudi.common.HoodiePendingRollbackInfo +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.HoodieTimeline +import org.apache.hudi.common.util.{Option => HOption} +import org.apache.hudi.config.HoodieWriteConfig +import org.apache.hudi.hadoop.fs.HadoopFSUtils +import org.apache.hudi.table.HoodieSparkTable + +import org.apache.spark.internal.Logging +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, StructType} + +import java.util.function.Supplier + +import scala.collection.JavaConverters._ + +/** + * Spark SQL stored procedure to roll back stale inflight write commits older than a configurable + * age threshold. + * + * SAFETY WARNING: Unlike rollback_to_instant_time which targets a single named instant, this + * procedure rolls back a SET of instants matched by age. A misconfigured threshold can erase + * recent in-progress writes when include_ingestion_commits=true. Operators should call + * show_inflight_commits first to preview the pending instants, and use dry_run => true (see + * below) when in doubt about which instants will be processed. + * + * This procedure targets the write timeline (COMMIT, DELTA_COMMIT, COMPACTION, LOG_COMPACTION, + * REPLACE_COMMIT, CLUSTERING actions). Stale rollback, clean, or restore inflights visible in + * show_inflight_commits are NOT covered by this procedure. + * + * Compaction, log-compaction, and clustering inflights are handled via the targeted table + * methods (table.rollbackInflightCompaction / rollbackInflightLogCompaction / + * rollbackInflightClustering) — the supporting state (HoodieSparkTable, table-service client, + * pending-rollback lookup) is constructed lazily on the first such instant and reused. + * + * Parameters: + * - table: Required. Catalog name of the Hudi table. + * - allowed_inflight_interval_minutes: Optional (default 180). Instants older than this many + * minutes are considered stale and eligible for rollback. + * - include_ingestion_commits: Optional (default false). DANGEROUS when true: enabling + * this allows the procedure to roll back COMMIT_ACTION and + * DELTA_COMMIT_ACTION inflights, which means it can drop + * in-progress ingestion data. The default false is the + * safe choice; true is for operators recovering from a + * known-stuck ingestion job. + * - dry_run: Optional (default false). When true, the matched-instant + * set is resolved exactly as in normal mode but no rollback + * calls are issued. Each returned row carries + * rollback_status = NULL meaning "matched but not acted + * upon". Re-run with dry_run => false to act. + * + * Output columns (one row per processed instant): + * - instant_time: The instant's requested timestamp. + * - action: The action type. + * - rollback_status: true if the rollback succeeded, false if the rollback failed, + * NULL if dry_run was true (matched but not actioned). + * + * Example usage: + * {{{ + * -- Clean stale table-service inflights (default 180-min threshold) + * CALL cleanup_stale_inflight_commits(table => 'my_table'); + * + * -- Preview what would be processed without acting + * CALL cleanup_stale_inflight_commits(table => 'my_table', dry_run => true); + * + * -- Clean stale inflights older than 1 hour, including ingestion commits (DANGEROUS) + * CALL cleanup_stale_inflight_commits( + * table => 'my_table', + * allowed_inflight_interval_minutes => 60, + * include_ingestion_commits => true + * ); + * }}} + * + * For inflight commit types not covered by this procedure (clean, restore, rollback inflights), + * use hudi-cli's `repair rollback` command. + */ +class CleanupStaleInflightCommitsProcedure extends BaseProcedure with ProcedureBuilder with Logging { + + private val PARAMETERS = Array[ProcedureParameter]( + ProcedureParameter.required(0, "table", DataTypes.StringType), + ProcedureParameter.optional(1, "allowed_inflight_interval_minutes", DataTypes.IntegerType, 180), + ProcedureParameter.optional(2, "include_ingestion_commits", DataTypes.BooleanType, false), + ProcedureParameter.optional(3, "dry_run", DataTypes.BooleanType, false) + ) + + private val OUTPUT_TYPE = new StructType(Array[StructField]( + StructField("instant_time", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("action", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("rollback_status", DataTypes.BooleanType, nullable = true, Metadata.empty) + )) + + override def parameters: Array[ProcedureParameter] = PARAMETERS + + override def outputType: StructType = OUTPUT_TYPE + + override def build: Procedure = new CleanupStaleInflightCommitsProcedure() + + override def call(args: ProcedureArgs): Seq[Row] = { + super.checkArgs(PARAMETERS, args) + + val tableName = getArgValueOrDefault(args, PARAMETERS(0)) + val allowedMinutes = getArgValueOrDefault(args, PARAMETERS(1)).get.asInstanceOf[Int] + val includeIngestionCommits = getArgValueOrDefault(args, PARAMETERS(2)).get.asInstanceOf[Boolean] + val dryRun = getArgValueOrDefault(args, PARAMETERS(3)).get.asInstanceOf[Boolean] + + val basePath = getBasePath(tableName) + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(jsc.hadoopConfiguration)) + .setBasePath(basePath) + .build + + val staleInflights = HoodieTimelineCleanupUtil + .inflightWriteCommitsOlderThan(metaClient, allowedMinutes.toLong, includeIngestionCommits) + + if (staleInflights.isEmpty) { + Seq.empty[Row] + } else if (dryRun) { + // Dry-run: do not open the table for write — emit preview rows with NULL rollback_status + // to mean "matched but not actioned". Re-run with dry_run => false to act. + staleInflights.asScala.map { instant => + Row(instant.requestedTime, instant.getAction, null) + }.toSeq + } else { + // Pass ROLLBACK_USING_MARKERS_ENABLE=false via the createHoodieWriteClient confs Map. + // Inflight commits may not have marker files, so timeline-based rollback is required. + // The user-specified confs win over defaults / table config / session conf — see + // HoodieCLIUtils.scala "Priority: defaults < catalog props < table config < sparkSession conf < specified conf". + val confs = Map(HoodieWriteConfig.ROLLBACK_USING_MARKERS_ENABLE.key() -> "false") + var client: SparkRDDWriteClient[_] = null + try { + client = HoodieCLIUtils.createHoodieWriteClient(sparkSession, basePath, confs, + tableName.asInstanceOf[scala.Option[String]]) + + // Lazy state for compaction / log-compaction / clustering branches. + // For matched sets that contain no such instants, none of these are constructed. + lazy val tsClient = client.getTableServiceClient + lazy val table = HoodieSparkTable.create(client.getConfig, client.getEngineContext) + lazy val getPendingRollbackInstantFunc: java.util.function.Function[String, HOption[HoodiePendingRollbackInfo]] = + new java.util.function.Function[String, HOption[HoodiePendingRollbackInfo]] { + override def apply(commitToRollback: String): HOption[HoodiePendingRollbackInfo] = { + tsClient.getPendingRollbackInfo(table.getMetaClient, commitToRollback, false) + } + } + + val rows = staleInflights.asScala.map { instant => + val status: java.lang.Boolean = try { + val result: Boolean = instant.getAction match { + case HoodieTimeline.COMPACTION_ACTION => + table.rollbackInflightCompaction(instant, getPendingRollbackInstantFunc, client.getTransactionManager) + true + case HoodieTimeline.LOG_COMPACTION_ACTION => + table.rollbackInflightLogCompaction(instant, getPendingRollbackInstantFunc, client.getTransactionManager) + true + case HoodieTimeline.CLUSTERING_ACTION => + table.rollbackInflightClustering(instant, getPendingRollbackInstantFunc, client.getTransactionManager) + true + case _ => + // Recheck that the instant is still inflight before calling client.rollback(), + // which searches getCommitsTimeline() (completed + pending). Without this guard, + // a concurrent writer that completes the commit after detection could have its + // now-completed commit rolled back destructively. The table.rollbackInflight* + // branches above fail loudly via revertInstantFromInflightToRequested if the + // inflight is gone; this branch performs an equivalent safety check. + val stillInflight = metaClient.reloadActiveTimeline() + .filterInflightsAndRequested() + .containsInstant(instant.requestedTime) + if (!stillInflight) { + logWarning(s"Instant ${instant.requestedTime} is no longer inflight; " + + "skipping rollback to avoid rolling back a completed commit") + false + } else { + client.rollback(instant.requestedTime) + } + } + java.lang.Boolean.valueOf(result) + } catch { + case e: Exception => + logError(s"Failed to rollback inflight instant ${instant.requestedTime}", e) + java.lang.Boolean.FALSE + } + Row(instant.requestedTime, instant.getAction, status) + }.toSeq + + // Refresh catalog after all rollbacks, inside try — consistent with RollbackToInstantTimeProcedure. + // Not placed in finally to avoid refreshing on client-creation failures. + if (tableName.isDefined) { + spark.catalog.refreshTable(tableName.get.asInstanceOf[String]) + } + rows + } finally { + if (client != null) client.close() + } + } + } +} + +object CleanupStaleInflightCommitsProcedure { + val NAME = "cleanup_stale_inflight_commits" + + def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] { + override def get() = new CleanupStaleInflightCommitsProcedure() + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ExportInstantsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ExportInstantsProcedure.scala index 5ba9960d1d3c4..3281e9de653b6 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ExportInstantsProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ExportInstantsProcedure.scala @@ -87,17 +87,19 @@ class ExportInstantsProcedure extends BaseProcedure with ProcedureBuilder with L if (!new File(localFolder).isDirectory) throw new HoodieException(localFolder + " is not a valid local directory") - // The non archived instants can be listed from the Timeline. - val nonArchivedInstants: util.List[HoodieInstant] = metaClient + // The non archived instants can be listed from the Timeline. Wrap in a mutable list because the + // desc branch below reverses it in place via Collections.reverse, which fails on the read-only + // list produced by asJava over an immutable Scala collection. + val nonArchivedInstants: util.List[HoodieInstant] = new util.ArrayList[HoodieInstant](metaClient .getActiveTimeline .filterCompletedInstants.getInstants.iterator().asScala .filter((i: HoodieInstant) => actionSet.contains(i.getAction)) - .toList.asJava + .toList.asJava) - // Archived instants are in the commit archive files + // Archived instants are in the commit archive files (also mutable, for the same reason). val statuses: Array[FileStatus] = HadoopFSUtils.getFs(basePath, jsc.hadoopConfiguration()).globStatus(archivePath) - val archivedStatuses = List(statuses: _*) - .sortWith((f1, f2) => (f1.getModificationTime - f2.getModificationTime).toInt > 0).asJava + val archivedStatuses = new util.ArrayList[FileStatus](List(statuses: _*) + .sortWith((f1, f2) => (f1.getModificationTime - f2.getModificationTime).toInt > 0).asJava) if (desc) { Collections.reverse(nonArchivedInstants) diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala index 4319ce77c1c2a..fad6d39ed06c0 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala @@ -39,6 +39,7 @@ object HoodieProcedures { ,(DeleteSavepointProcedure.NAME, DeleteSavepointProcedure.builder) ,(RollbackToSavepointProcedure.NAME, RollbackToSavepointProcedure.builder) ,(RollbackToInstantTimeProcedure.NAME, RollbackToInstantTimeProcedure.builder) + ,(RestoreToInstantProcedure.NAME, RestoreToInstantProcedure.builder) ,(RunClusteringProcedure.NAME, RunClusteringProcedure.builder) ,(ShowClusteringProcedure.NAME, ShowClusteringProcedure.builder) ,(ShowCommitsProcedure.NAME, ShowCommitsProcedure.builder) @@ -84,6 +85,7 @@ object HoodieProcedures { ,(RepairDeduplicateProcedure.NAME, RepairDeduplicateProcedure.builder) ,(RepairMigratePartitionMetaProcedure.NAME, RepairMigratePartitionMetaProcedure.builder) ,(RepairOverwriteHoodiePropsProcedure.NAME, RepairOverwriteHoodiePropsProcedure.builder) + ,(RepairOrphanFilesProcedure.NAME, RepairOrphanFilesProcedure.builder) ,(RunCleanProcedure.NAME, RunCleanProcedure.builder) ,(ValidateHoodieSyncProcedure.NAME, ValidateHoodieSyncProcedure.builder) ,(ShowInvalidParquetProcedure.NAME, ShowInvalidParquetProcedure.builder) @@ -93,6 +95,8 @@ object HoodieProcedures { ,(ShowTablePropertiesProcedure.NAME, ShowTablePropertiesProcedure.builder) ,(HelpProcedure.NAME, HelpProcedure.builder) ,(ArchiveCommitsProcedure.NAME, ArchiveCommitsProcedure.builder) + ,(ShowInflightCommitsProcedure.NAME, ShowInflightCommitsProcedure.builder) + ,(CleanupStaleInflightCommitsProcedure.NAME, CleanupStaleInflightCommitsProcedure.builder) ,(RunTTLProcedure.NAME, RunTTLProcedure.builder) ,(DropPartitionProcedure.NAME, DropPartitionProcedure.builder) ,(TruncateTableProcedure.NAME, TruncateTableProcedure.builder) diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/PartitionBucketIndexManager.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/PartitionBucketIndexManager.scala index 6eaade123a649..4e044ecb6786f 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/PartitionBucketIndexManager.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/PartitionBucketIndexManager.scala @@ -225,14 +225,16 @@ class PartitionBucketIndexManager extends BaseProcedure // instantiate other supporting cast val internalSchemaOption: Option[InternalSchema] = Option.empty() // instantiate FG reader - val fileGroupReader = HoodieFileGroupReader.newBuilder() + val fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContextFactory.getContext) .withHoodieTableMetaClient(metaClient) .withLatestCommitTime(latestInstantTime.requestedTime()) - .withFileSlice(fileSlice) + .withBaseFileOption(fileSlice.getBaseFile) + .withLogFiles(fileSlice.getLogFiles) + .withPartitionPath(fileSlice.getPartitionPath) .withDataSchema(tableSchemaWithMetaFields) .withRequestedSchema(tableSchemaWithMetaFields) - .withInternalSchema(internalSchemaOption) // not support evolution of schema for now + .withInternalSchemaOpt(internalSchemaOption) // not support evolution of schema for now .withProps(metaClient.getTableConfig.getProps) .withShouldUseRecordPosition(false) .build() diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ProcedureParameterImpl.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ProcedureParameterImpl.scala index a7f4117047457..f5ff30c1b90b2 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ProcedureParameterImpl.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ProcedureParameterImpl.scala @@ -25,15 +25,14 @@ case class ProcedureParameterImpl(index: Int, name: String, dataType: DataType, extends ProcedureParameter { override def equals(other: Any): Boolean = { - val that = other.asInstanceOf[ProcedureParameterImpl] - val rtn = if (this == other) { + if (this eq other.asInstanceOf[AnyRef]) { true } else if (other == null || (getClass ne other.getClass)) { false } else { + val that = other.asInstanceOf[ProcedureParameterImpl] index == that.index && required == that.required && default == that.default && Objects.equals(name, that.name) && Objects.equals(dataType, that.dataType) } - rtn } override def hashCode: Int = Seq(index, name, dataType, required, default).hashCode() diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala new file mode 100644 index 0000000000000..2d15215f0c66b --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala @@ -0,0 +1,336 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.hudi.command.procedures + +import org.apache.hudi.common.config.HoodieMetadataConfig +import org.apache.hudi.common.engine.HoodieLocalEngineContext +import org.apache.hudi.common.fs.FSUtils +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.metadata.{FileSystemBackedTableMetadata, HoodieBackedTableMetadata} +import org.apache.hudi.storage.{HoodieStorageUtils, StoragePath, StoragePathInfo} +import org.apache.hudi.table.repair.RepairUtils + +import org.apache.spark.internal.Logging +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, StructType} + +import java.util.function.Supplier + +import scala.collection.JavaConverters._ + +/** + * Spark SQL stored procedure that finds and optionally removes orphan data files — files + * that exist on the filesystem but are not referenced by any commit (active or archived). + * + * Handles COW (base files) and MOR (base + log files), and all commit action types + * (COMMIT, DELTA_COMMIT, REPLACE_COMMIT). The detection reuses + * [[org.apache.hudi.table.repair.RepairUtils]], the same logic that backs the + * `HoodieRepairTool` spark-submit utility, so results are consistent with that tool. + * + * Usage: + * {{{ + * -- View mode (default): list orphan files without touching them + * CALL repair_orphan_files(table => 'my_table') + * + * -- Scoped to one partition + * CALL repair_orphan_files(table => 'my_table', partition => '2024/01/15') + * + * -- Cleanup: move orphan files to a backup location + * CALL repair_orphan_files( + * table => 'my_table', + * dry_run => false, + * backup_path => '/user/hudi/orphan_files_backup' + * ) + * }}} + * + * For very large tables, scope to one partition at a time using `partition =>` to avoid + * collecting all orphan paths to the driver at once. The `max_orphans` parameter (default + * 100,000) acts as a safety cap: if the detected count exceeds it the procedure fails with + * a clear error instead of silently causing a driver OOM. + */ +class RepairOrphanFilesProcedure extends BaseProcedure with ProcedureBuilder with Logging { + + private val PARAMETERS = Array[ProcedureParameter]( + ProcedureParameter.optional(0, "table", DataTypes.StringType, null), + ProcedureParameter.optional(1, "path", DataTypes.StringType, null), + ProcedureParameter.optional(2, "partition", DataTypes.StringType, ""), + ProcedureParameter.optional(3, "dry_run", DataTypes.BooleanType, true), + ProcedureParameter.optional(4, "backup_path", DataTypes.StringType, ""), + ProcedureParameter.optional(5, "archived_start_ts", DataTypes.StringType, ""), + ProcedureParameter.optional(6, "archived_end_ts", DataTypes.StringType, ""), + ProcedureParameter.optional(7, "max_orphans", DataTypes.IntegerType, 100000) + ) + + private val OUTPUT_TYPE = new StructType(Array[StructField]( + StructField("partition", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("file_name", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("instant_time", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("backup_path", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("status", DataTypes.StringType, nullable = true, Metadata.empty) + )) + + def parameters: Array[ProcedureParameter] = PARAMETERS + + def outputType: StructType = OUTPUT_TYPE + + override def call(args: ProcedureArgs): Seq[Row] = { + super.checkArgs(PARAMETERS, args) + + val tableName = getArgValueOrDefault(args, PARAMETERS(0)) + val tablePathOpt = getArgValueOrDefault(args, PARAMETERS(1)) + val partition = getArgValueOrDefault(args, PARAMETERS(2)).get.asInstanceOf[String] + val dryRun = getArgValueOrDefault(args, PARAMETERS(3)).get.asInstanceOf[Boolean] + val backupPath = getArgValueOrDefault(args, PARAMETERS(4)).get.asInstanceOf[String] + val archivedStartTs = getArgValueOrDefault(args, PARAMETERS(5)).get.asInstanceOf[String] + val archivedEndTs = getArgValueOrDefault(args, PARAMETERS(6)).get.asInstanceOf[String] + val maxOrphans = getArgValueOrDefault(args, PARAMETERS(7)).get.asInstanceOf[Int] + + if (!dryRun && backupPath.isEmpty) { + throw new IllegalArgumentException("backup_path is required when dry_run is false") + } + + // Phase 1: Partition listing (driver) + val basePath = getBasePath(tableName, tablePathOpt) + val metaClient = createMetaClient(jsc, basePath) + + val partitions: java.util.List[String] = + if (partition.nonEmpty) { + java.util.Collections.singletonList(partition) + } else { + // Use FileSystemBackedTableMetadata (filesystem listing, no MDT) for partition discovery. + // This avoids any reliance on the metadata table being present/consistent — the same + // approach HoodieRepairTool uses. + new FileSystemBackedTableMetadata( + new HoodieLocalEngineContext(metaClient.getStorageConf), + metaClient.getTableConfig, metaClient.getStorage, basePath).getAllPartitionPaths + } + + if (partitions.isEmpty) { + Seq.empty + } else { + doRepairOrphanFiles(basePath, metaClient, partitions, dryRun, backupPath, + archivedStartTs, archivedEndTs, maxOrphans) + } + } + + private def doRepairOrphanFiles( + basePath: String, + metaClient: HoodieTableMetaClient, + partitions: java.util.List[String], + dryRun: Boolean, + backupPath: String, + archivedStartTs: String, + archivedEndTs: String, + maxOrphans: Int): Seq[Row] = { + // Build the active and archived timelines once on the driver, loading completed-instant + // details into memory so executors can read commit metadata without further I/O. This is + // the same pattern as HoodieRepairTool: the loaded timelines are serializable and captured + // by the RDD closure below. The HoodieTableMetaClient itself is not captured (not needed + // on executors once details are loaded). + val activeTimeline = metaClient.getActiveTimeline + val archivedTimeline = + if (archivedStartTs.nonEmpty) metaClient.getArchivedTimeline(archivedStartTs) + else metaClient.getArchivedTimeline() + archivedTimeline.loadCompletedInstantDetailsInMemory() + + // StorageConfiguration is Serializable and is the only stateful value captured into the + // closure; storage handles are rebuilt per task from it. + val storageConf = metaClient.getStorageConf + val basePathStr = basePath + val archStartTs = archivedStartTs + val archEndTs = archivedEndTs + + // Phase 2: Parallel orphan file detection (Spark RDD, one task per partition). Each task + // lists its own partition and runs detection locally, so only the (small) set of orphan + // candidates is collected back to the driver rather than the full file listing. + val orphanRelPaths: List[String] = jsc.parallelize(partitions, partitions.size()) + .rdd + .flatMap { partitionStr => + val storage = HoodieStorageUtils.getStorage(basePathStr, storageConf) + val partPath = FSUtils.getAbsolutePartitionPath(new StoragePath(basePathStr), partitionStr) + // getAllDataFilesInPartition handles FileNotFoundException (partition deleted between + // listing and task execution) by returning an empty list rather than throwing. + val allStatuses = FSUtils.getAllDataFilesInPartition(storage, partPath) + val allPaths = allStatuses.asScala.map((info: StoragePathInfo) => info.getPath).asJava + + val instantToFilesMap = RepairUtils.tagInstantsOfBaseAndLogFiles(basePathStr, allPaths) + + if (instantToFilesMap.isEmpty) { + Iterator.empty + } else { + // Optionally scope detection to instants within [archived_start_ts, archived_end_ts]. + // Instants outside the range are left untouched (not reported as orphans). + val instants = instantToFilesMap.keySet.asScala.toSeq.sorted.filter { instant => + (archStartTs.isEmpty || instant >= archStartTs) && + (archEndTs.isEmpty || instant <= archEndTs) + } + + instants.flatMap { instant => + RepairUtils.findInstantFilesToRemove( + instant, + instantToFilesMap.get(instant), + activeTimeline, + archivedTimeline + ).asScala + }.iterator + } + } + .collect() + .toList + + if (orphanRelPaths.size > maxOrphans) { + throw new IllegalStateException( + s"Found ${orphanRelPaths.size} orphan candidates, which exceeds max_orphans=$maxOrphans. " + + s"Re-run with partition => '' to scope to one partition at a time, " + + s"or raise max_orphans if you are sure the driver has enough memory.") + } + + // Phase 3: Metadata table (MDT) safety check (driver). + // Files still visible in the MDT are not true orphans — surface them as + // SKIPPED_PRESENT_IN_MDT (per-file exclusion) so the operator sees which candidates the + // safety check held back, rather than silently dropping them. + val mdtConfig = HoodieMetadataConfig.newBuilder + .enable(true).ignoreSpuriousDeletes(true).build + val mdtReader = new HoodieBackedTableMetadata( + new HoodieLocalEngineContext(metaClient.getStorageConf), metaClient.getStorage, mdtConfig, basePath) + + val mdtUnsafePaths: Set[String] = + if (mdtReader.enabled) { + val byPartition = orphanRelPaths.groupBy(partitionOf) + val unsafe = byPartition.flatMap { case (partRel, paths) => + val mdtPartPath = FSUtils.getAbsolutePartitionPath(new StoragePath(basePath), partRel) + val mdtNames = mdtReader.getAllFilesInPartition(mdtPartPath).asScala + .map(_.getPath.getName).toSet + paths.filter(p => mdtNames.contains(new StoragePath(p).getName)) + }.toSet + if (unsafe.nonEmpty) { + logWarning(s"Found ${unsafe.size} orphan candidate(s) still visible in MDT — " + + s"emitting as SKIPPED_PRESENT_IN_MDT (per-file exclusion): $unsafe") + } + unsafe + } else { + logWarning("Metadata table not enabled; skipping MDT safety cross-check") + Set.empty[String] + } + + val safeOrphanPaths = orphanRelPaths.filterNot(mdtUnsafePaths.contains) + + // Phase 4: Build result rows. + val skippedRows: Seq[Row] = mdtUnsafePaths.toSeq.map { relPath => + val fileName = new StoragePath(relPath).getName + val partRel = partitionOf(relPath) + val instantTime = FSUtils.getCommitTime(fileName) + Row(partRel, fileName, instantTime, "", "SKIPPED_PRESENT_IN_MDT") + } + + val safeRows: Seq[Row] = if (dryRun) { + safeOrphanPaths.map { relPath => + val fileName = new StoragePath(relPath).getName + val partRel = partitionOf(relPath) + val instantTime = FSUtils.getCommitTime(fileName) + Row(partRel, fileName, instantTime, "", "IDENTIFIED") + } + } else { + val storage = metaClient.getStorage + val tableNm = metaClient.getTableConfig.getTableName // always from metaClient — the 'table' + // proc arg is null when invoked via path => + safeOrphanPaths.map { relPath => + val fileName = new StoragePath(relPath).getName + val partRel = partitionOf(relPath) + val instantTime = FSUtils.getCommitTime(fileName) + val srcPath = new StoragePath(basePath, relPath) + // Non-partitioned tables have partRel="" — back up directly under /. + val destDir = + if (partRel.isEmpty) new StoragePath(s"$backupPath/$tableNm") + else new StoragePath(s"$backupPath/$tableNm/$partRel") + val destPath = new StoragePath(destDir, fileName) + + // Each storage op is wrapped to capture exception class+message — a backup can fail for + // permissions, missing parent, RPC, or concurrent-delete reasons, and a status of + // BACKUP_FAILED with no log line leaves the operator with nothing to diagnose. Causes + // are accumulated and logged once at the end iff the final outcome is a failure. + val causes = scala.collection.mutable.ArrayBuffer.empty[String] + + val dirCreated: Boolean = + try { + val ok = storage.createDirectory(destDir) + if (!ok) causes += s"createDirectory($destDir)=false (likely permissions or destDir exists as a file)" + ok + } catch { + case t: Throwable => + causes += s"createDirectory($destDir) threw ${t.getClass.getSimpleName}: ${t.getMessage}" + false + } + + val moved: Boolean = + if (!dirCreated) { + false + } else { + try { + val ok = storage.rename(srcPath, destPath) + if (!ok) causes += s"rename returned false (srcPath missing, destPath exists, or cross-volume rename)" + ok + } catch { + case t: Throwable => + causes += s"rename threw ${t.getClass.getSimpleName}: ${t.getMessage}" + false + } + } + + // A concurrent cleaner may have already deleted srcPath — that is success, not failure. + val srcStillExists: Boolean = + try { + storage.exists(srcPath) + } catch { + case t: Throwable => + // Cannot confirm the concurrent-delete recovery path; treat as failure. + causes += s"exists($srcPath) threw ${t.getClass.getSimpleName}: ${t.getMessage}" + true + } + val succeeded = moved || !srcStillExists + + if (!succeeded) { + logWarning(s"BACKUP_FAILED for $srcPath -> $destPath; causes: ${causes.mkString("; ")}") + } + val status = if (succeeded) "BACKED_UP" else "BACKUP_FAILED" + val backupOut = if (succeeded) destPath.toString else "" + Row(partRel, fileName, instantTime, backupOut, status) + } + } + + safeRows ++ skippedRows + } + + // Extract the relative partition path from a relative file path using string ops. This avoids + // any path-normalization surprises and works uniformly for partitioned and non-partitioned + // tables (root files yield ""). + private def partitionOf(relPath: String): String = { + val s = relPath.lastIndexOf('/') + if (s < 0) "" else relPath.substring(0, s) + } + + override def build: Procedure = new RepairOrphanFilesProcedure() +} + +object RepairOrphanFilesProcedure { + val NAME = "repair_orphan_files" + + def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] { + override def get(): ProcedureBuilder = new RepairOrphanFilesProcedure() + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RestoreToInstantProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RestoreToInstantProcedure.scala new file mode 100644 index 0000000000000..ca5a2fc924ae7 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RestoreToInstantProcedure.scala @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.hudi.command.procedures + +import org.apache.hudi.HoodieCLIUtils +import org.apache.hudi.avro.model.HoodieRestoreMetadata +import org.apache.hudi.client.SparkRDDWriteClient +import org.apache.hudi.common.config.HoodieMetadataConfig +import org.apache.hudi.common.fs.ConsistencyGuardConfig +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.HoodieInstant +import org.apache.hudi.config.HoodieWriteConfig +import org.apache.hudi.exception.HoodieException +import org.apache.hudi.hadoop.fs.HadoopFSUtils +import org.apache.hudi.storage.StoragePath + +import org.apache.hadoop.fs.Path +import org.apache.spark.internal.Logging +import org.apache.spark.sql.Row +import org.apache.spark.sql.hudi.command.procedures.RestoreToInstantProcedure._ +import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, StructType} + +import java.util.function.Supplier + +import scala.collection.JavaConverters._ + +/** + * Stored procedure to perform a full point-in-time table restore to a given instant. + * + * Unlike [[RollbackToSavepointProcedure]] (which requires a savepoint at the target instant), + * this procedure calls restoreToInstant() directly and works on any arbitrary instant on the + * active timeline. + * + * Parameters: + * - table / path: identifies the Hudi table (one must be provided) + * - instant_time: target commit to restore to (required when audit_only=false; must be omitted + * when audit_only=true) + * - start_restore_time: the restore operation's own timeline timestamp (the start_restore_time + * value returned by a prior restore_to_instant call). Required when + * audit_only=true; must be omitted otherwise. + * - enable_metadata: whether the metadata table is enabled (default: true) + * - rollback_parallelism: Spark parallelism for rollback and audit operations (default: 4) + * - enable_consistency_guard: enable consistency guard for file existence checks (default: false) + * - audit_post_restore: after restoring, verify that all successfully-deleted files are absent (default: false) + * - audit_only: skip the restore and only audit a previously completed restore instant (default: false) + * + * Output columns: + * - restore_result: true if restore succeeded; null if audit_only=true + * - start_restore_time: the restore operation's own timeline timestamp; null if audit_only=true. + * Pass this value as start_restore_time to re-run the audit later. + * - time_taken_in_millis: restore duration; null if audit_only=true + * - instants_rolled_back: number of commits rolled back; null if audit_only=true + * - audit_result: one of "PASSED" / "FAILED" / "INCONCLUSIVE" when an audit ran; null otherwise. + * INCONCLUSIVE means at least one file existence check threw an IOException + * (e.g. transient cloud-storage timeout) — re-run audit_only=true to retry. + */ +class RestoreToInstantProcedure extends BaseProcedure with ProcedureBuilder with Logging { + + private val PARAMETERS = Array[ProcedureParameter]( + ProcedureParameter.optional(0, "table", DataTypes.StringType), + ProcedureParameter.optional(1, "instant_time", DataTypes.StringType), + ProcedureParameter.optional(2, "enable_metadata", DataTypes.BooleanType, true), + ProcedureParameter.optional(3, "rollback_parallelism", DataTypes.IntegerType, 4), + ProcedureParameter.optional(4, "enable_consistency_guard", DataTypes.BooleanType, false), + ProcedureParameter.optional(5, "audit_post_restore", DataTypes.BooleanType, false), + ProcedureParameter.optional(6, "audit_only", DataTypes.BooleanType, false), + ProcedureParameter.optional(7, "path", DataTypes.StringType), + ProcedureParameter.optional(8, "start_restore_time", DataTypes.StringType) + ) + + private val OUTPUT_TYPE = new StructType(Array[StructField]( + StructField("restore_result", DataTypes.BooleanType, nullable = true, Metadata.empty), + StructField("start_restore_time", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("time_taken_in_millis", DataTypes.LongType, nullable = true, Metadata.empty), + StructField("instants_rolled_back", DataTypes.LongType, nullable = true, Metadata.empty), + StructField("audit_result", DataTypes.StringType, nullable = true, Metadata.empty) + )) + + def parameters: Array[ProcedureParameter] = PARAMETERS + + def outputType: StructType = OUTPUT_TYPE + + override def call(args: ProcedureArgs): Seq[Row] = { + super.checkArgs(PARAMETERS, args) + + val tableName = getArgValueOrDefault(args, PARAMETERS(0)) + val instantTime = getArgValueOrDefault(args, PARAMETERS(1)) + val enableMetadata = getArgValueOrDefault(args, PARAMETERS(2)).get.asInstanceOf[Boolean] + val rollbackParallelism = getArgValueOrDefault(args, PARAMETERS(3)).get.asInstanceOf[Int] + val enableConsistencyGuard = getArgValueOrDefault(args, PARAMETERS(4)).get.asInstanceOf[Boolean] + val shouldAuditPostRestore = getArgValueOrDefault(args, PARAMETERS(5)).get.asInstanceOf[Boolean] + val auditOnly = getArgValueOrDefault(args, PARAMETERS(6)).get.asInstanceOf[Boolean] + val tablePath = getArgValueOrDefault(args, PARAMETERS(7)) + val startRestoreTimeArg = getArgValueOrDefault(args, PARAMETERS(8)) + + // Cross-validation: each of (instant_time, start_restore_time) has one unambiguous meaning. + if (!auditOnly && instantTime.isEmpty) { + throw new HoodieException("instant_time is required when audit_only=false.") + } + if (auditOnly && startRestoreTimeArg.isEmpty) { + throw new HoodieException( + "start_restore_time is required when audit_only=true. " + + "Pass the start_restore_time value from a prior restore_to_instant call.") + } + if (!auditOnly && startRestoreTimeArg.isDefined) { + throw new HoodieException("start_restore_time may only be specified when audit_only=true.") + } + if (auditOnly && instantTime.isDefined) { + throw new HoodieException( + "instant_time may only be specified when audit_only=false. " + + "Use start_restore_time to identify a previously executed restore.") + } + if (auditOnly && shouldAuditPostRestore) { + logWarning("Both audit_only and audit_post_restore are set. Only audit_only will be honored.") + } + + val basePath = getBasePath(tableName, tablePath) + + val confs = Map( + HoodieMetadataConfig.ENABLE.key() -> enableMetadata.toString, + HoodieWriteConfig.ROLLBACK_PARALLELISM_VALUE.key() -> rollbackParallelism.toString, + HoodieWriteConfig.ROLLBACK_USING_MARKERS_ENABLE.key() -> "false" + ) ++ (if (enableConsistencyGuard) Map(ConsistencyGuardConfig.ENABLE.key() -> "true") else Map.empty) + + val metaClient = createMetaClient(jsc, basePath) + + // Nullable boxed types so Row can hold null for audit_only runs + var restoreResult: java.lang.Boolean = null + var startRestoreTime: String = null + var timeTakenInMillis: java.lang.Long = null + var instantsRolledBack: java.lang.Long = null + + if (!auditOnly) { + val targetInstant = instantTime.get.asInstanceOf[String] + var client: SparkRDDWriteClient[_] = null + try { + client = HoodieCLIUtils.createHoodieWriteClient(sparkSession, basePath, confs, + tableName.asInstanceOf[Option[String]]) + // Pre-check: if the target instant is at or before the oldest MDT compaction or before the + // MDT timeline start, pre-emptively delete the MDT so restoreToInstant does not leave it + // inconsistent. deleteMdtIfNecessaryBeforeRestore returns true when the MDT was deleted + // (caller must not re-initialize it), false otherwise. + val mdtDeleted = if (enableMetadata) { + client.deleteMdtIfNecessaryBeforeRestore(targetInstant) + } else false + val restoreMetadata = client.restoreToInstant(targetInstant, !mdtDeleted && enableMetadata) + restoreResult = true + startRestoreTime = restoreMetadata.getStartRestoreTime + timeTakenInMillis = restoreMetadata.getTimeTakenInMillis + instantsRolledBack = restoreMetadata.getInstantsToRollback.size().toLong + } finally { + if (client != null) { + client.close() + } + } + if (tableName.isDefined) { + spark.catalog.refreshTable(tableName.get.asInstanceOf[String]) + } + // getActiveTimeline is lazily cached; reload so the new .restore instant is visible to the audit. + if (shouldAuditPostRestore) { + metaClient.reloadActiveTimeline() + } + } + + var auditResult: String = null + if (auditOnly || shouldAuditPostRestore) { + val restoreInstant: HoodieInstant = if (auditOnly) { + val ts = startRestoreTimeArg.get.asInstanceOf[String] + val instants = metaClient.getActiveTimeline.getRestoreTimeline.filterCompletedInstants + .getInstants.asScala + instants.find(_.requestedTime().equals(ts)).getOrElse( + throw new HoodieException(s"No completed restore instant found for $ts. " + + "Pass the start_restore_time from a prior restore_to_instant call as start_restore_time.") + ) + } else { + // Use startRestoreTime captured from the restore metadata — more deterministic than + // lastInstant() since another concurrent restore could otherwise land in between. + val ts = startRestoreTime + val instants = metaClient.getActiveTimeline.getRestoreTimeline.filterCompletedInstants + .getInstants.asScala + instants.find(_.requestedTime().equals(ts)).getOrElse( + throw new HoodieException(s"No completed restore instant found for $ts after restore.") + ) + } + auditResult = auditPostRestore(metaClient, basePath, restoreInstant, rollbackParallelism) + } + + Seq(Row(restoreResult, startRestoreTime, timeTakenInMillis, instantsRolledBack, auditResult)) + } + + /** + * Verifies that all files expected to have been deleted by a restore operation are actually + * absent from storage. Returns one of "PASSED", "FAILED", or "INCONCLUSIVE": + * - PASSED: every file expected to be absent is in fact absent. + * - FAILED: at least one file is still present after restore. + * - INCONCLUSIVE: no file was confirmed present, but at least one existence check threw + * an IOException (e.g. transient cloud-storage timeout). Re-run with + * audit_only=true to retry. + */ + private def auditPostRestore( + metaClient: HoodieTableMetaClient, + basePath: String, + restoreInstant: HoodieInstant, + rollbackParallelism: Int): String = { + try { + val restoreMetadata: HoodieRestoreMetadata = + metaClient.getActiveTimeline.readRestoreMetadata(restoreInstant) + + val filesToCheck = new java.util.ArrayList[String]() + restoreMetadata.getHoodieRestoreMetadata.values.asScala.foreach { rollbackList => + rollbackList.asScala.foreach { rollback => + rollback.getPartitionMetadata.asScala.foreach { case (partition, pm) => + val partitionPathStr = basePath + Path.SEPARATOR + partition + val partitionStoragePath = new StoragePath(partitionPathStr) + if (!metaClient.getStorage.exists(partitionStoragePath)) { + logInfo(s"Partition path $partitionStoragePath does not exist. Skipping audit for its files.") + } else { + // Use .toString() to preserve the full absolute path. Using .getName() would strip + // the path to just the filename, making the subsequent FS.exists() check vacuously pass. + // Only check getSuccessDeleteFiles: these are the files the restore intended to delete + // and whose absence we can meaningfully verify. Files in getFailedDeleteFiles already + // failed to be deleted and are expected to still be present. + pm.getSuccessDeleteFiles.asScala.foreach(f => + filesToCheck.add(new Path(partitionPathStr, f).toString)) + } + } + } + } + + if (filesToCheck.isEmpty) { + logInfo(s"No files to audit for restore instant ${restoreInstant.requestedTime()}") + "PASSED" + } else { + // HadoopFSUtils.getStorageConfWithCopy returns a Serializable StorageConfiguration; the + // closure captures only storageConf, so Spark's ClosureCleaner can serialize it cleanly. + // HadoopFSUtils.getFs internally calls prepareHadoopConf, preserving HOODIE_ENV_* (e.g. + // S3A) injection at cloud DCs. + val storageConf = HadoopFSUtils.getStorageConfWithCopy(jsc.hadoopConfiguration()) + val outcomes = jsc.parallelize(filesToCheck, Math.max(1, rollbackParallelism)) + .map { pathStr => + val p = new Path(pathStr) + try { + val exists = HadoopFSUtils.getFs(p, storageConf.unwrap()).exists(p) + val status: AuditFileStatus = if (exists) Present else Absent + (pathStr, status) + } catch { + case e: Exception => (pathStr, IndeterminateError(e.getMessage)) + } + } + .collect() + .asScala + .toArray + + val present = outcomes.collect { case (p, Present) => p } + val indeterminate = outcomes.collect { case (p, IndeterminateError(msg)) => (p, msg) } + if (present.nonEmpty) { + logError(s"Restore audit FAILED for instant ${restoreInstant.requestedTime()}: " + + s"${present.length} file(s) still present, e.g. ${present.take(5).mkString(", ")}") + "FAILED" + } else if (indeterminate.nonEmpty) { + logWarning(s"Restore audit INCONCLUSIVE for instant ${restoreInstant.requestedTime()}: " + + s"${indeterminate.length} file(s) had IO errors, e.g. ${indeterminate.take(5).mkString(", ")}") + "INCONCLUSIVE" + } else { + logInfo(s"Restore audit PASSED for instant ${restoreInstant.requestedTime()}: " + + s"${outcomes.length} file(s) confirmed absent.") + "PASSED" + } + } + } catch { + case e: Exception => + logError(s"Exception during restore audit for instant ${restoreInstant.requestedTime()}", e) + "INCONCLUSIVE" + } + } + + override def build: Procedure = new RestoreToInstantProcedure() +} + +object RestoreToInstantProcedure { + val NAME: String = "restore_to_instant" + + def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] { + override def get(): RestoreToInstantProcedure = new RestoreToInstantProcedure() + } + + // ADT for per-file audit outcomes. Defined at object level (NOT inside auditPostRestore) so the + // generated bytecode does not embed a synthetic outer-class reference; otherwise Spark's + // ClosureCleaner cannot serialize the closure that returns these values from executors. + private sealed trait AuditFileStatus extends Serializable + private case object Absent extends AuditFileStatus + private case object Present extends AuditFileStatus + private case class IndeterminateError(message: String) extends AuditFileStatus +} diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowInflightCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowInflightCommitsProcedure.scala new file mode 100644 index 0000000000000..3b501bdb25a17 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowInflightCommitsProcedure.scala @@ -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.spark.sql.hudi.command.procedures + +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator +import org.apache.hudi.hadoop.fs.HadoopFSUtils + +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, StructType} + +import java.time.Duration +import java.util.Date +import java.util.function.Supplier + +import scala.collection.JavaConverters._ + +/** + * Spark SQL stored procedure to list all pending inflight and requested instants on a Hudi table. + * + * Unlike cleanup_stale_inflight_commits (which targets the write timeline only), this procedure + * queries the full active timeline and therefore includes inflight rollback, clean, restore, and + * indexing instants in addition to write-action instants. A stale rollback or clean inflight + * visible here cannot be cleaned by cleanup_stale_inflight_commits. + * + * Parameters: + * - table: Required. Catalog name of the Hudi table. + * - min_age_minutes: Optional (default 0). When > 0, only instants older than this many minutes + * are returned. When 0 (default), all pending instants are returned. + * + * Output columns (one row per pending instant): + * - instant_time: The instant's requested timestamp. + * - action: The action type (commit, delta_commit, compaction, replace, rollback, clean, etc.). + * - state: The instant's state (REQUESTED or INFLIGHT). + * + * Example usage: + * {{{ + * -- Show all pending inflights + * CALL show_inflight_commits(table => 'my_table'); + * + * -- Show only inflights older than 2 hours + * CALL show_inflight_commits(table => 'my_table', min_age_minutes => 120); + * }}} + */ +class ShowInflightCommitsProcedure extends BaseProcedure with ProcedureBuilder { + + private val PARAMETERS = Array[ProcedureParameter]( + ProcedureParameter.required(0, "table", DataTypes.StringType), + ProcedureParameter.optional(1, "min_age_minutes", DataTypes.IntegerType, 0) + ) + + private val OUTPUT_TYPE = new StructType(Array[StructField]( + StructField("instant_time", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("action", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("state", DataTypes.StringType, nullable = true, Metadata.empty) + )) + + override def parameters: Array[ProcedureParameter] = PARAMETERS + + override def outputType: StructType = OUTPUT_TYPE + + override def build: Procedure = new ShowInflightCommitsProcedure() + + override def call(args: ProcedureArgs): Seq[Row] = { + super.checkArgs(PARAMETERS, args) + + val tableName = getArgValueOrDefault(args, PARAMETERS(0)) + val minAgeMinutes = getArgValueOrDefault(args, PARAMETERS(1)).get.asInstanceOf[Int] + + val basePath = getBasePath(tableName) + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(jsc.hadoopConfiguration)) + .setBasePath(basePath) + .build + + val baseTimeline = metaClient.reloadActiveTimeline().filterInflightsAndRequested() + + val timeline = if (minAgeMinutes > 0) { + val goBackMs = Duration.ofMinutes(minAgeMinutes).toMillis + val cutoff = HoodieInstantTimeGenerator.formatDate(new Date(System.currentTimeMillis() - goBackMs)) + baseTimeline.findInstantsBefore(cutoff) + } else { + baseTimeline + } + + timeline.getInstants.asScala.map { instant => + Row(instant.requestedTime, instant.getAction, instant.getState.name()) + }.toSeq + } +} + +object ShowInflightCommitsProcedure { + val NAME = "show_inflight_commits" + + def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] { + override def get() = new ShowInflightCommitsProcedure() + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/TestHoodieClientMultiWriter.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/TestHoodieClientMultiWriter.java index f07414f18af13..1b00e1376e4b8 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/TestHoodieClientMultiWriter.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/TestHoodieClientMultiWriter.java @@ -85,6 +85,7 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; +import java.io.Closeable; import java.io.File; import java.io.IOException; import java.util.ArrayList; @@ -137,8 +138,21 @@ @Tag("functional") public class TestHoodieClientMultiWriter extends HoodieClientTestBase { + // Pin the tolerable heartbeat misses used by the early-conflict-detection test so its + // heartbeat-expiry wait does not depend on the global default + // (hoodie.client.heartbeat.tolerable.misses), which changed from 2 to 10 in #18904. + private static final int EARLY_CONFLICT_HEARTBEAT_TOLERABLE_MISSES = 2; + + static { + // ZooKeeper's embedded admin server binds the fixed default port 8080 (Curator only + // randomizes the client port), so a concurrent or leaked TestingServer in a reused + // fork collides with "Address already in use". The admin server is unused here. + System.setProperty("zookeeper.admin.enableServer", "false"); + } + private Properties lockProperties = null; + private TestingServer zkTestingServer = null; /** * super is not thread safe!! @@ -175,6 +189,10 @@ public void setUpMORTestTable() throws IOException { @AfterEach public void clean() throws IOException { + if (zkTestingServer != null) { + zkTestingServer.close(); + zkTestingServer = null; + } cleanupResources(); } @@ -462,14 +480,14 @@ private void testHoodieClientBasicMultiWriterWithEarlyConflictDetection(String t int heartBeatIntervalForCommit4 = 3 * 1000; HoodieWriteConfig writeConfig; - TestingServer server = null; if (earlyConflictDetectionStrategy.equalsIgnoreCase(SimpleTransactionDirectMarkerBasedDetectionStrategy.class.getName())) { // need to setup zk related env there. Bcz SimpleTransactionDirectMarkerBasedDetectionStrategy is only support zk lock for now. - server = new TestingServer(); + // zkTestingServer is closed in @AfterEach so a failing assertion cannot leak it (and its port-8080 admin server). + zkTestingServer = new TestingServer(); Properties properties = new Properties(); properties.setProperty(ZK_BASE_PATH_PROP_KEY, basePath); - properties.setProperty(ZK_CONNECT_URL_PROP_KEY, server.getConnectString()); - properties.setProperty(ZK_BASE_PATH_PROP_KEY, server.getTempDirectory().getAbsolutePath()); + properties.setProperty(ZK_CONNECT_URL_PROP_KEY, zkTestingServer.getConnectString()); + properties.setProperty(ZK_BASE_PATH_PROP_KEY, zkTestingServer.getTempDirectory().getAbsolutePath()); properties.setProperty(ZK_SESSION_TIMEOUT_MS_PROP_KEY, "10000"); properties.setProperty(ZK_CONNECTION_TIMEOUT_MS_PROP_KEY, "10000"); properties.setProperty(ZK_LOCK_KEY_PROP_KEY, "key"); @@ -483,71 +501,83 @@ private void testHoodieClientBasicMultiWriterWithEarlyConflictDetection(String t writeConfig = buildWriteConfigForEarlyConflictDetect(markerType, properties, InProcessLockProvider.class, earlyConflictDetectionStrategy); } - final SparkRDDWriteClient client1 = getHoodieWriteClient(writeConfig); - - // Create the first commit - final String nextCommitTime1 = "001"; - createCommitWithInserts(writeConfig, client1, "000", nextCommitTime1, 200); - - final SparkRDDWriteClient client2 = getHoodieWriteClient(writeConfig); - final SparkRDDWriteClient client3 = getHoodieWriteClient(writeConfig); + SparkRDDWriteClient client1 = null; + SparkRDDWriteClient client2 = null; + SparkRDDWriteClient client3 = null; + SparkRDDWriteClient client4 = null; + try { + client1 = getHoodieWriteClient(writeConfig); - final String nextCommitTime2 = "002"; + // Create the first commit + final String nextCommitTime1 = "001"; + createCommitWithInserts(writeConfig, client1, "000", nextCommitTime1, 200); - // start to write commit 002 - final JavaRDD writeStatusList2 = startCommitForUpdate(writeConfig, client2, nextCommitTime2, 100); + client2 = getHoodieWriteClient(writeConfig); + client3 = getHoodieWriteClient(writeConfig); - // start to write commit 003 - // this commit 003 will fail quickly because early conflict detection before create marker. - final String nextCommitTime3 = "003"; - assertThrows(SparkException.class, () -> { - final JavaRDD writeStatusList3 = - startCommitForUpdate(writeConfig, client3, nextCommitTime3, 100); - client3.commit(nextCommitTime3, writeStatusList3); - }, "Early conflict detected but cannot resolve conflicts for overlapping writes"); + final String nextCommitTime2 = "002"; - // start to commit 002 and success - assertDoesNotThrow(() -> { - client2.commit(nextCommitTime2, writeStatusList2); - }); + // start to write commit 002 + final SparkRDDWriteClient finalClient2 = client2; + final JavaRDD writeStatusList2 = startCommitForUpdate(writeConfig, client2, nextCommitTime2, 100); - HoodieWriteConfig config4 = - HoodieWriteConfig.newBuilder().withProperties(writeConfig.getProps()) - .withHeartbeatIntervalInMs(heartBeatIntervalForCommit4).build(); - final SparkRDDWriteClient client4 = getHoodieWriteClient(config4); + // start to write commit 003 + // this commit 003 will fail quickly because early conflict detection before create marker. + final String nextCommitTime3 = "003"; + final SparkRDDWriteClient finalClient3 = client3; + assertThrows(SparkException.class, () -> { + final JavaRDD writeStatusList3 = + startCommitForUpdate(writeConfig, finalClient3, nextCommitTime3, 100); + finalClient3.commit(nextCommitTime3, writeStatusList3); + }, "Early conflict detected but cannot resolve conflicts for overlapping writes"); - StoragePath heartbeatFilePath = new StoragePath( - HoodieTableMetaClient.getHeartbeatFolderPath(basePath) + StoragePath.SEPARATOR + nextCommitTime3); - storage.create(heartbeatFilePath, true); + // start to commit 002 and success + assertDoesNotThrow(() -> { + finalClient2.commit(nextCommitTime2, writeStatusList2); + }); - // Wait for heart beat expired for failed commitTime3 "003" - // Otherwise commit4 still can see conflict between failed write 003. - Thread.sleep(heartBeatIntervalForCommit4 * 2); + HoodieWriteConfig config4 = + HoodieWriteConfig.newBuilder().withProperties(writeConfig.getProps()) + .withHeartbeatIntervalInMs(heartBeatIntervalForCommit4).build(); + client4 = getHoodieWriteClient(config4); - final String nextCommitTime4 = "004"; - assertDoesNotThrow(() -> { - final JavaRDD writeStatusList4 = - startCommitForUpdate(writeConfig, client4, nextCommitTime4, 100); - client4.commit(nextCommitTime4, writeStatusList4); - }); + StoragePath heartbeatFilePath = new StoragePath( + HoodieTableMetaClient.getHeartbeatFolderPath(basePath) + StoragePath.SEPARATOR + nextCommitTime3); + storage.create(heartbeatFilePath, true); - List completedInstant = metaClient.reloadActiveTimeline().getCommitsTimeline() - .filterCompletedInstants().getInstants().stream() - .map(HoodieInstant::requestedTime).collect(Collectors.toList()); + // Wait for heart beat expired for failed commitTime3 "003" + // Otherwise commit4 still can see conflict between failed write 003. The early-conflict + // check treats 003 as alive until its heartbeat is older than + // (tolerable misses * heartbeat interval); tolerable misses is pinned in + // buildWriteConfigForEarlyConflictDetect, so wait one interval past that window. + Thread.sleep(heartBeatIntervalForCommit4 * (EARLY_CONFLICT_HEARTBEAT_TOLERABLE_MISSES + 1)); - assertEquals(3, completedInstant.size()); - assertTrue(completedInstant.contains(nextCommitTime1)); - assertTrue(completedInstant.contains(nextCommitTime2)); - assertTrue(completedInstant.contains(nextCommitTime4)); + final String nextCommitTime4 = "004"; + final SparkRDDWriteClient finalClient4 = client4; + assertDoesNotThrow(() -> { + final JavaRDD writeStatusList4 = + startCommitForUpdate(writeConfig, finalClient4, nextCommitTime4, 100); + finalClient4.commit(nextCommitTime4, writeStatusList4); + }); - FileIOUtils.deleteDirectory(new File(basePath)); - if (server != null) { - server.close(); + List completedInstant = metaClient.reloadActiveTimeline().getCommitsTimeline() + .filterCompletedInstants().getInstants().stream() + .map(HoodieInstant::requestedTime).collect(Collectors.toList()); + + assertEquals(3, completedInstant.size()); + assertTrue(completedInstant.contains(nextCommitTime1)); + assertTrue(completedInstant.contains(nextCommitTime2)); + assertTrue(completedInstant.contains(nextCommitTime4)); + + FileIOUtils.deleteDirectory(new File(basePath)); + } finally { + // Close the write clients on every exit path, including failed assertions. + // The TestingServer itself is closed by @AfterEach (see zkTestingServer above). + FileIOUtils.closeQuietly(client1 == null ? null : (Closeable) client1::close); + FileIOUtils.closeQuietly(client2 == null ? null : (Closeable) client2::close); + FileIOUtils.closeQuietly(client3 == null ? null : (Closeable) client3::close); + FileIOUtils.closeQuietly(client4 == null ? null : (Closeable) client4::close); } - client1.close(); - client2.close(); - client3.close(); - client4.close(); } @Test @@ -1000,7 +1030,7 @@ public void testConcurrentCompactionExecutionOnSamePlan() throws Exception { writer1Succeeded.set(true); } catch (Exception e) { // Expected - one writer may fail due to concurrent execution - LOG.info("Writer 1 failed with exception: " + e.getMessage()); + LOG.info("Writer 1 failed with exception: {}", e.getMessage()); writer1Succeeded.set(false); } }); @@ -1015,7 +1045,7 @@ public void testConcurrentCompactionExecutionOnSamePlan() throws Exception { writer2Succeeded.set(true); } catch (Exception e) { // Expected - one writer may fail due to concurrent execution - LOG.info("Writer 2 failed with exception: " + e.getMessage()); + LOG.info("Writer 2 failed with exception: {}", e.getMessage()); writer2Succeeded.set(false); } }); @@ -1498,9 +1528,9 @@ private void runConcurrentAndAssert(JavaRDD writeRecords1, JavaRDD try { ingestBatch(writeFn, client1, newCommitTime1, writeRecords1, runCountDownLatch); } catch (IOException e) { - LOG.error("IOException thrown " + e.getMessage()); + LOG.error("IOException thrown {}", e.getMessage()); } catch (InterruptedException e) { - LOG.error("Interrupted Exception thrown " + e.getMessage()); + LOG.error("Interrupted Exception thrown {}", e.getMessage()); } catch (Exception e) { client1Succeeded.set(false); } @@ -1511,9 +1541,9 @@ private void runConcurrentAndAssert(JavaRDD writeRecords1, JavaRDD try { ingestBatch(writeFn, client2, newCommitTime2, writeRecords2, runCountDownLatch); } catch (IOException e) { - LOG.error("IOException thrown " + e.getMessage()); + LOG.error("IOException thrown {}", e.getMessage()); } catch (InterruptedException e) { - LOG.error("Interrupted Exception thrown " + e.getMessage()); + LOG.error("Interrupted Exception thrown {}", e.getMessage()); } catch (Exception e) { client2Succeeded.set(false); } @@ -1771,6 +1801,7 @@ private HoodieWriteConfig buildWriteConfigForEarlyConflictDetect(String markerTy if (markerType.equalsIgnoreCase(MarkerType.DIRECT.name())) { return getConfigBuilder() .withHeartbeatIntervalInMs(60 * 1000) + .withHeartbeatTolerableMisses(EARLY_CONFLICT_HEARTBEAT_TOLERABLE_MISSES) .withFileSystemViewConfig(FileSystemViewStorageConfig.newBuilder() .withStorageType(FileSystemViewStorageType.MEMORY) .withSecondaryStorageType(FileSystemViewStorageType.MEMORY).build()) @@ -1791,6 +1822,7 @@ private HoodieWriteConfig buildWriteConfigForEarlyConflictDetect(String markerTy return getConfigBuilder() .withStorageConfig(HoodieStorageConfig.newBuilder().parquetMaxFileSize(20 * 1024).build()) .withHeartbeatIntervalInMs(60 * 1000) + .withHeartbeatTolerableMisses(EARLY_CONFLICT_HEARTBEAT_TOLERABLE_MISSES) .withFileSystemViewConfig(FileSystemViewStorageConfig.newBuilder() .withStorageType(FileSystemViewStorageType.MEMORY) .withSecondaryStorageType(FileSystemViewStorageType.MEMORY).build()) diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestDataValidationCheckForLogCompactionActions.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestDataValidationCheckForLogCompactionActions.java index bb6c04a781f16..a0e5d6dce7734 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestDataValidationCheckForLogCompactionActions.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestDataValidationCheckForLogCompactionActions.java @@ -122,12 +122,12 @@ public void stressTestCompactionAndLogCompactionOperations(int seed) throws Exce // Total ingestion writes. int totalWrites = 15; - LOG.warn("Starting trial with seed " + seed); + LOG.warn("Starting trial with seed {}", seed); // Current ingestion commit. int curr = 1; while (curr < totalWrites) { - LOG.warn("Starting write No. " + curr); + LOG.warn("Starting write No. {}", curr); // Pick an action. It can be insert/update/delete and write data to main table. boolean status = writeOnMainTable(mainTable, curr); @@ -145,7 +145,7 @@ public void stressTestCompactionAndLogCompactionOperations(int seed) throws Exce // Verify the records in both the tables. verifyRecords(mainTable, experimentTable); - LOG.warn("For write No." + curr + ", verification passed. Last ingestion commit timestamp is " + mainTable.commitTimeOnMainTable); + LOG.warn("For write No.{}, verification passed. Last ingestion commit timestamp is {}", curr, mainTable.commitTimeOnMainTable); } curr++; } @@ -195,7 +195,7 @@ private boolean writeOnMainTable(TestTableContents mainTable, int curr) throws I result = deleteDataIntoMainTable(mainTable, commitTime); } } catch (IllegalArgumentException e) { - LOG.warn(e.getMessage() + " ignoring current command."); + LOG.warn("{} ignoring current command.", e.getMessage()); return false; } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnCopyOnWriteStorage.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnCopyOnWriteStorage.java index 2a4fa731e04bb..0d00c08bda29e 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnCopyOnWriteStorage.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestHoodieClientOnCopyOnWriteStorage.java @@ -18,6 +18,7 @@ package org.apache.hudi.client.functional; +import org.apache.hudi.avro.model.HoodieCleanMetadata; import org.apache.hudi.avro.model.HoodieClusteringPlan; import org.apache.hudi.avro.model.HoodieRequestedReplaceMetadata; import org.apache.hudi.client.BaseHoodieWriteClient; @@ -52,6 +53,7 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieRecordPayload; import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.model.TableServiceType; import org.apache.hudi.common.model.WriteConcurrencyMode; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableMetaClient; @@ -68,6 +70,7 @@ import org.apache.hudi.common.util.ClusteringUtils; import org.apache.hudi.common.util.FileFormatUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieArchivalConfig; import org.apache.hudi.config.HoodieCleanConfig; @@ -131,6 +134,7 @@ import static org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy.EAGER; import static org.apache.hudi.common.table.timeline.HoodieInstant.State.INFLIGHT; import static org.apache.hudi.common.table.timeline.HoodieInstant.State.REQUESTED; +import static org.apache.hudi.common.table.timeline.HoodieTimeline.CLEAN_ACTION; import static org.apache.hudi.common.table.timeline.HoodieTimeline.CLUSTERING_ACTION; import static org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION; import static org.apache.hudi.common.table.timeline.HoodieTimeline.REPLACE_COMMIT_ACTION; @@ -2066,6 +2070,145 @@ public void testRollingMetadataPreservedAcrossClusteringAfterArchival() throws E "TableSchemaResolver should find schema even with clustering-only timeline"); } + @Test + public void testRollingMetadataPreservedInCleanCommits() throws Exception { + String schemaKey = HoodieCommitMetadata.SCHEMA_KEY; + dataGen = new HoodieTestDataGenerator(new String[] {DEFAULT_FIRST_PARTITION_PATH}); + + HoodieWriteConfig config = getConfigBuilder(TRIP_EXAMPLE_SCHEMA) + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .compactionSmallFileSize(0).build()) + .withRollingMetadataKeys(schemaKey) + .withCleanConfig(HoodieCleanConfig.newBuilder() + .withAutoClean(false) + .withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY) + .retainCommits(1) + .build()) + .withArchivalConfig(HoodieArchivalConfig.newBuilder() + .archiveCommitsWith(10, 12).build()) + .build(); + + SparkRDDWriteClient client = getHoodieWriteClient(config); + + String firstCommit = client.startCommit(); + List records = dataGen.generateInserts(firstCommit, 100); + JavaRDD firstResult = client.insert(jsc.parallelize(records, 1), firstCommit); + client.commit(firstCommit, firstResult); + + // Upsert several times to create superseded file versions + for (int i = 0; i < 4; i++) { + String commitTime = client.startCommit(); + List updates = dataGen.generateUpdates(commitTime, records); + JavaRDD result = client.upsert(jsc.parallelize(updates, 1), commitTime); + client.commit(commitTime, result); + } + + // Run clean — retainCommits(1) means old file versions from earlier commits are eligible + HoodieCleanMetadata cleanResult = client.clean(); + assertTrue(cleanResult != null, "Clean should produce metadata (files to clean exist)"); + + HoodieTableMetaClient freshMeta = HoodieTableMetaClient.reload(metaClient); + HoodieTimeline cleanTimeline = freshMeta.getActiveTimeline() + .getCleanerTimeline().filterCompletedInstants(); + assertFalse(cleanTimeline.empty(), "Should have at least one clean instant"); + + HoodieInstant lastClean = cleanTimeline.lastInstant().get(); + HoodieCleanMetadata cleanMetadata = cleanTimeline.readCleanMetadata(lastClean); + + Map cleanExtraMetadata = cleanMetadata.getExtraMetadata(); + assertTrue(cleanExtraMetadata != null, "Clean metadata should have extraMetadata map"); + assertTrue(cleanExtraMetadata.containsKey(schemaKey), + "Clean's extraMetadata should contain rolled-over schema key"); + assertFalse(cleanExtraMetadata.get(schemaKey).isEmpty(), + "Rolled-over schema in clean should be non-empty"); + + // Now make one more commit with lookback=1. The rolling metadata walk should find + // the schema key in the clean instant (the most recent instant with the key). + HoodieWriteConfig lookback1Config = getConfigBuilder(TRIP_EXAMPLE_SCHEMA) + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .compactionSmallFileSize(0).build()) + .withRollingMetadataKeys(schemaKey) + .withRollingMetadataTimelineLookbackCommits(1) + .withCleanConfig(HoodieCleanConfig.newBuilder() + .withAutoClean(false) + .withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY) + .retainCommits(1) + .build()) + .withArchivalConfig(HoodieArchivalConfig.newBuilder() + .archiveCommitsWith(10, 12).build()) + .build(); + + SparkRDDWriteClient lookbackClient = getHoodieWriteClient(lookback1Config); + String nextCommit = lookbackClient.startCommit(); + List nextUpdates = dataGen.generateUpdates(nextCommit, records); + JavaRDD nextResult = lookbackClient.upsert(jsc.parallelize(nextUpdates, 1), nextCommit); + lookbackClient.commit(nextCommit, nextResult); + + freshMeta = HoodieTableMetaClient.reload(metaClient); + HoodieTimeline commitsTimeline = freshMeta.getActiveTimeline() + .getCommitsTimeline().filterCompletedInstants(); + HoodieInstant latestCommit = commitsTimeline.lastInstant().get(); + HoodieCommitMetadata latestMeta = commitsTimeline.readCommitMetadata(latestCommit); + String rolledSchema = latestMeta.getMetadata(schemaKey); + assertFalse(StringUtils.isNullOrEmpty(rolledSchema), + "Schema should be rolled over into new commit even with lookback=1"); + } + + @Test + public void testExecutingPendingCleanInstantsBeforeSchedulingNewCleanInstant() throws Exception { + Properties props = new Properties(); + props.setProperty("hoodie.clean.automatic", "false"); + HoodieWriteConfig cfg = getConfigBuilder().withProperties(props).build(); + SparkRDDWriteClient client = getHoodieWriteClient(cfg); + + // Bulk insert + String firstCommit = WriteClientTestUtils.createNewInstantTime(); + insertFirstBatch(cfg, client, firstCommit, "000", 100, SparkRDDWriteClient::bulkInsert, + false, false, 100, INSTANT_GENERATOR); + + // First upsert + String secondCommit = WriteClientTestUtils.createNewInstantTime(); + updateBatch(cfg, client, secondCommit, firstCommit, Option.empty(), "000", 100, + SparkRDDWriteClient::upsert, false, true, 100, 100, 2, INSTANT_GENERATOR); + + // Second upsert + String thirdCommit = WriteClientTestUtils.createNewInstantTime(); + updateBatch(cfg, client, thirdCommit, secondCommit, Option.empty(), "000", 100, + SparkRDDWriteClient::upsert, false, true, 100, 100, 3, INSTANT_GENERATOR); + + // Schedule clean operation + Properties cleanProps = new Properties(); + cleanProps.setProperty("hoodie.clean.automatic", "true"); + cleanProps.setProperty("hoodie.clean.commits.retained", "1"); + cleanProps.setProperty("hoodie.clean.multiple.enabled", "false"); + HoodieWriteConfig cleanCfg = getConfigBuilder().withProperties(cleanProps).build(); + SparkRDDWriteClient cleanClient = new SparkRDDWriteClient(context, cleanCfg); + cleanClient.scheduleTableService(Option.empty(), TableServiceType.CLEAN); + + // Verify whether clean operation is scheduled. + Option firstCleanInstant = metaClient.reloadActiveTimeline().lastInstant(); + assertTrue(firstCleanInstant.isPresent()); + assertEquals(CLEAN_ACTION, firstCleanInstant.get().getAction()); + + // Third upsert + String fourthCommit = WriteClientTestUtils.createNewInstantTime(); + updateBatch(cfg, client, fourthCommit, thirdCommit, Option.empty(), "000", 100, + SparkRDDWriteClient::upsert, false, true, 100, 100, 4, INSTANT_GENERATOR); + + // Execute clean operation. This should complete pending clean operation. + cleanClient.clean(); + + // Verify timeline + HoodieTimeline timeline = metaClient.reloadActiveTimeline(); + assertEquals(5, timeline.countInstants()); + assertEquals(0, timeline.filterInflights().countInstants()); + HoodieTimeline cleanTimeline = timeline.filter(instant -> instant.getAction().equals(CLEAN_ACTION)); + assertEquals(1, cleanTimeline.countInstants()); + HoodieInstant cleanInstant = cleanTimeline.getInstants().get(0); + assertTrue(cleanInstant.isCompleted()); + assertEquals(firstCleanInstant.get().requestedTime(), cleanInstant.requestedTime()); + } + /** * Disabling row writer here as clustering tests will throw the error below if it is used. * java.util.concurrent.CompletionException: java.lang.ClassNotFoundException diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestMetadataUtilRLIandSIRecordGeneration.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestMetadataUtilRLIandSIRecordGeneration.java index b35da0450cfdc..7dbd7e7aaf96d 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestMetadataUtilRLIandSIRecordGeneration.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/client/functional/TestMetadataUtilRLIandSIRecordGeneration.java @@ -712,7 +712,7 @@ Set getRecordKeys(String partition, String baseInstantTime, String fileI TypedProperties properties = new TypedProperties(); // configure un-merged log file reader HoodieReaderContext readerContext = context.getReaderContextFactory(metaClient).getContext(); - HoodieFileGroupReader reader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader reader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withDataSchema(writerSchemaOpt.get()) .withRequestedSchema(writerSchemaOpt.get()) diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java index b3beca5fa18c2..1110fce8c9ca8 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java @@ -40,6 +40,7 @@ import org.apache.hudi.common.fs.ConsistencyGuardConfig; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieAvroIndexedRecord; import org.apache.hudi.common.model.HoodieBaseFile; import org.apache.hudi.common.model.HoodieCleaningPolicy; import org.apache.hudi.common.model.HoodieCommitMetadata; @@ -162,6 +163,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; @@ -1664,7 +1666,7 @@ private void verifyMetadataMergedRecords(HoodieTableMetaClient metadataMetaClien String latestCommitTimestamp) { HoodieSchema schema = HoodieSchemaUtils.addMetadataFields(HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())); HoodieAvroReaderContext readerContext = new HoodieAvroReaderContext(metadataMetaClient.getStorageConf(), metadataMetaClient.getTableConfig(), Option.empty(), Option.empty()); - HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.newBuilder() + HoodieFileGroupReader fileGroupReader = HoodieFileGroupReader.builder() .withReaderContext(readerContext) .withHoodieTableMetaClient(metadataMetaClient) .withLogFiles(logFiles.stream()) @@ -1944,6 +1946,180 @@ public void testClusteringWithRecordIndex() throws Exception { testTableOperationsForMetaIndexImpl(writeConfig); } + /** + * Record index bootstrap over binary / non-ASCII record keys must succeed. The RI HFile orders + * keys by their raw UTF-8 bytes, so the bulk-insert partitioner must sort by UTF-8 bytes too; + * sorting by {@link String#compareTo(String)} (UTF-16) lays the HFile entries out of order + * relative to their UTF-8 bytes. + * + *

    The failure is read-side, not write-side: the native {@code HFileWriterImpl.append} does no + * key-order validation, so a mis-sorted HFile is still written successfully. The forward-only + * HFile reader ({@code HFileReaderImpl.seekTo}) then either throws {@code IllegalStateException} + * on a backward seek or silently misses keys, which the read-back count assertion at the end of + * this test catches. + * + *

    {@code riFileGroupCount == 1} covers the single-slice lookup path; {@code riFileGroupCount == 4} + * covers the multi-slice {@code mapGroupsByKey} lookup path in + * {@code HoodieBackedTableMetadata#lookupIndexRecords}, which repartitions keys in String/UTF-16 + * order before doing a forward-only HFile seek in UTF-8 order. + */ + @ParameterizedTest + @ValueSource(ints = {1, 4}) + public void testRecordIndexBootstrapWithBinaryRecordKeys(int riFileGroupCount) throws Exception { + init(COPY_ON_WRITE, true); + HoodieSparkEngineContext engineContext = new HoodieSparkEngineContext(jsc); + + // First commit with the record index disabled: write base files with binary record keys. + List records = generateRecordsWithBinaryKeys(WriteClientTestUtils.createNewInstantTime(), 0, 200); + HoodieWriteConfig firstConfig = getWriteConfigBuilder(true, true, false).build(); + String firstCommitTime = WriteClientTestUtils.createNewInstantTime(); + try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext, firstConfig)) { + WriteClientTestUtils.startCommitWithTime(client, firstCommitTime); + List writeStatuses = client.insert(jsc.parallelize(records, 1), firstCommitTime).collect(); + assertNoWriteErrors(writeStatuses); + client.commit(firstCommitTime, jsc.parallelize(writeStatuses)); + } + metaClient = HoodieTableMetaClient.reload(metaClient); + assertFalse(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX)); + + // Enable the record index. The next commit triggers the bootstrap, reading the binary keys from + // the base files above. One file group puts all keys in a single HFile; more than one file group + // exercises the multi-slice lookup path on read-back. + HoodieWriteConfig riConfig = getWriteConfigBuilder(false, true, false) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withEnableGlobalRecordLevelIndex(true) + .withRecordIndexFileGroupCount(riFileGroupCount, riFileGroupCount) + .build()) + .build(); + + String secondCommitTime = WriteClientTestUtils.createNewInstantTime(); + // Disjoint key range so the bootstrapped keys are not mutated. + List secondBatch = generateRecordsWithBinaryKeys(secondCommitTime, 1000, 20); + try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext, riConfig)) { + WriteClientTestUtils.startCommitWithTime(client, secondCommitTime); + // Without the fix the mis-sorted record-index HFile is still written; the failure surfaces on + // read-back below, so the write itself is expected to succeed here. + List writeStatuses = client.insert(jsc.parallelize(secondBatch, 1), secondCommitTime).collect(); + assertNoWriteErrors(writeStatuses); + client.commit(secondCommitTime, jsc.parallelize(writeStatuses)); + } + + // The record index partition should exist and resolve every key: the bootstrapped keys live in + // the record-index base HFiles, while the second-batch keys still sit in un-compacted metadata + // log files at this point, so the lookup covers the log-side seek path with binary keys too. + metaClient = HoodieTableMetaClient.reload(metaClient); + assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX)); + HoodieTableMetadata metadataReader = metaClient.getTableFormat().getMetadataFactory().create( + context, storage, riConfig.getMetadataConfig(), riConfig.getBasePath()); + List allKeys = Stream.concat(records.stream(), secondBatch.stream()) + .map(HoodieRecord::getRecordKey).collect(Collectors.toList()); + // With more than one file group, readRecordIndexLocationsWithKeys triggers the mapGroupsByKey + // multi-slice path. + HoodiePairData recordIndexData = metadataReader + .readRecordIndexLocationsWithKeys(HoodieListData.eager(allKeys)); + try { + Map result = HoodieDataUtils.dedupeAndCollectAsMap(recordIndexData); + assertEquals(allKeys.size(), result.size(), + "Record index should resolve every binary key, bootstrapped or still in a metadata log file."); + } finally { + recordIndexData.unpersistWithDependencies(); + } + } + + /** + * Generates {@code count} records with binary record keys interleaving U+E000 (BMP) and U+20000 + * (supplementary) prefixes, whose UTF-16 char order is the reverse of their UTF-8 byte order. + */ + private List generateRecordsWithBinaryKeys(String commitTime, int startIndex, int count) { + List baseRecords = dataGen.generateInserts(commitTime, count); + String[] binaryPrefixes = {new String(Character.toChars(0xE000)), new String(Character.toChars(0x20000))}; + List binaryRecords = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + HoodieRecord baseRecord = baseRecords.get(i); + int index = startIndex + i; + String binaryKey = binaryPrefixes[index % binaryPrefixes.length] + String.format("%08d", index); + binaryRecords.add(new HoodieAvroIndexedRecord( + new HoodieKey(binaryKey, baseRecord.getPartitionPath()), + (IndexedRecord) baseRecord.getData())); + } + return binaryRecords; + } + + /** + * Same as {@link #testRecordIndexBootstrapWithBinaryRecordKeys(int)} but forces an MDT compaction after + * bootstrap, exercising the {@code BaseCreateHandle} / {@code SortedKeyBasedFileGroupRecordBuffer} + * sort-order paths hit when the record-index base HFile is rewritten. + */ + @Test + public void testRecordIndexBootstrapWithBinaryRecordKeysAfterCompaction() throws Exception { + init(COPY_ON_WRITE, true); + HoodieSparkEngineContext engineContext = new HoodieSparkEngineContext(jsc); + + List records = generateRecordsWithBinaryKeys(WriteClientTestUtils.createNewInstantTime(), 0, 200); + HoodieWriteConfig firstConfig = getWriteConfigBuilder(true, true, false).build(); + String firstCommitTime = WriteClientTestUtils.createNewInstantTime(); + try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext, firstConfig)) { + WriteClientTestUtils.startCommitWithTime(client, firstCommitTime); + List writeStatuses = client.insert(jsc.parallelize(records, 1), firstCommitTime).collect(); + assertNoWriteErrors(writeStatuses); + client.commit(firstCommitTime, jsc.parallelize(writeStatuses)); + } + metaClient = HoodieTableMetaClient.reload(metaClient); + assertFalse(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX)); + + // A single delta commit is enough to trigger compaction on the very next delta commit. + HoodieWriteConfig riConfig = getWriteConfigBuilder(false, true, false) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withEnableGlobalRecordLevelIndex(true) + .withRecordIndexFileGroupCount(1, 1) + .withMaxNumDeltaCommitsBeforeCompaction(1) + .build()) + .build(); + + List allKeys = new ArrayList<>(records); + try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext, riConfig)) { + // Bootstrap: writes the initial record-index HFile from the binary keys above. + String secondCommitTime = WriteClientTestUtils.createNewInstantTime(); + List secondBatch = generateRecordsWithBinaryKeys(secondCommitTime, 1000, 20); + WriteClientTestUtils.startCommitWithTime(client, secondCommitTime); + // The mis-sorted bootstrap write succeeds; key ordering is validated by the read-back below. + List secondWriteStatuses = client.insert(jsc.parallelize(secondBatch, 1), secondCommitTime).collect(); + assertNoWriteErrors(secondWriteStatuses); + client.commit(secondCommitTime, jsc.parallelize(secondWriteStatuses)); + allKeys.addAll(secondBatch); + + // The next delta commit on the record index partition triggers compaction, rewriting the base + // HFile via BaseCreateHandle / SortedKeyBasedFileGroupRecordBuffer. + String thirdCommitTime = WriteClientTestUtils.createNewInstantTime(); + List thirdBatch = generateRecordsWithBinaryKeys(thirdCommitTime, 2000, 20); + WriteClientTestUtils.startCommitWithTime(client, thirdCommitTime); + // The compaction rewrite of the base HFile also succeeds; ordering is validated on read-back. + List thirdWriteStatuses = client.insert(jsc.parallelize(thirdBatch, 1), thirdCommitTime).collect(); + assertNoWriteErrors(thirdWriteStatuses); + client.commit(thirdCommitTime, jsc.parallelize(thirdWriteStatuses)); + allKeys.addAll(thirdBatch); + } + + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieTableMetadata metadataReader = metaClient.getTableFormat().getMetadataFactory().create( + context, storage, riConfig.getMetadataConfig(), riConfig.getBasePath()); + assertTrue(metadataReader.getLatestCompactionTime().isPresent(), + "Record index partition should have been compacted by now."); + + List allRecordKeys = allKeys.stream().map(HoodieRecord::getRecordKey).collect(Collectors.toList()); + HoodiePairData recordIndexData = metadataReader + .readRecordIndexLocationsWithKeys(HoodieListData.eager(allRecordKeys)); + try { + Map result = HoodieDataUtils.dedupeAndCollectAsMap(recordIndexData); + assertEquals(allRecordKeys.size(), result.size(), + "Record index should resolve every binary key after the record index partition has been compacted."); + } finally { + recordIndexData.unpersistWithDependencies(); + } + } + /** * First attempt at bootstrap failed but the file slices get created. The next bootstrap should continue successfully. */ diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkConsistentBucketClustering.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkConsistentBucketClustering.java index cd14ef61fb7fc..93e01b75ae282 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkConsistentBucketClustering.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkConsistentBucketClustering.java @@ -34,6 +34,7 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; +import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.testutils.HoodieTestUtils; @@ -49,6 +50,7 @@ import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.bucket.ConsistentBucketIndexUtils; import org.apache.hudi.keygen.constant.KeyGeneratorOptions; +import org.apache.hudi.keygen.constant.KeyGeneratorType; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.table.HoodieSparkTable; import org.apache.hudi.table.HoodieTable; @@ -102,6 +104,10 @@ public void setup(int maxFileSize, Map options) throws IOExcepti } public void setup(int maxFileSize, Map options, boolean singleJob) throws IOException { + setup(maxFileSize, options, singleJob, false); + } + + public void setup(int maxFileSize, Map options, boolean singleJob, boolean nonPartitioned) throws IOException { initPath(); initSparkContexts(); initTestDataGenerator(); @@ -109,6 +115,13 @@ public void setup(int maxFileSize, Map options, boolean singleJo Properties props = getPropertiesForKeyGen(true); props.putAll(options); props.setProperty(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key"); + if (nonPartitioned) { + // Non-partitioned tables produce records with an empty partition path and use the non-partition key generator. + dataGen = new HoodieTestDataGenerator(new String[] {HoodieTestDataGenerator.NO_PARTITION_PATH}); + props.setProperty(HoodieWriteConfig.KEYGENERATOR_TYPE.key(), KeyGeneratorType.NON_PARTITION.name()); + props.setProperty(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(), ""); + props.remove(HoodieTableConfig.PARTITION_FIELDS.key()); + } metaClient = HoodieTestUtils.init(storageConf, basePath, HoodieTableType.MERGE_ON_READ, props); config = getConfigBuilder().withProps(props) .withIndexConfig(HoodieIndexConfig.newBuilder().fromProperties(props) @@ -130,16 +143,21 @@ public void tearDown() throws IOException { } /** - * Test resizing with bucket number upper bound and lower bound + * Test resizing with bucket number upper bound and lower bound, on both partitioned and non-partitioned tables. + * + *

    Non-partitioned coverage guards GitHub issue #18161: consistent hashing clustering used to fail on + * non-partitioned tables because the empty partition path was rejected by the execution strategy. For a + * non-partitioned table {@code dataGen.getPartitionPaths()} returns the single empty partition path, so the + * assertions below cover both cases without branching. * * @throws IOException */ @ParameterizedTest - @MethodSource("configParams") - public void testResizing(boolean isSplit, boolean rowWriterEnable, boolean single) throws IOException { + @MethodSource("resizingConfigParams") + public void testResizing(boolean isSplit, boolean rowWriterEnable, boolean single, boolean nonPartitioned) throws IOException { final int maxFileSize = isSplit ? 5120 : 128 * 1024 * 1024; final int targetBucketNum = isSplit ? 14 : 4; - setup(maxFileSize, Collections.emptyMap(), single); + setup(maxFileSize, Collections.emptyMap(), single, nonPartitioned); config.setValue("hoodie.datasource.write.row.writer.enable", String.valueOf(rowWriterEnable)); config.setValue("hoodie.metadata.enable", "false"); writeData(2000, true); @@ -384,6 +402,16 @@ private static Stream configParams() { ); } + // configParams crossed with the partitioned / non-partitioned dimension (isSplit, rowWriterEnable, single, nonPartitioned). + private static Stream resizingConfigParams() { + return configParams().flatMap(args -> { + Object[] a = args.get(); + return Stream.of( + Arguments.of(a[0], a[1], a[2], false), + Arguments.of(a[0], a[1], a[2], true)); + }); + } + private static Stream configParamsForSorting() { return Stream.of( Arguments.of("begin_lat", true), diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java index 95a5d64cf0fb2..ee1c00323cfb2 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java @@ -58,6 +58,7 @@ import org.apache.hudi.common.util.HoodieRecordUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ParquetUtils; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.ClosableIterator; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; @@ -79,6 +80,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -203,6 +205,57 @@ public void testMergeHandleRLIAndSIStatsWithUpdatesAndDeletes(boolean useFileGro validateSecondaryIndexStatsContent(writeStatus, numUpdates, numDeletes); } + @Test + public void testSortedMergeHandleWritesBinaryKeysInUtf8Order() throws Exception { + // Drives HoodieSortedMergeHandle directly against a Parquet base file (requireSortedRecords() is + // false), validating comparator ordering only; does not cover the HoodieMergeHandleFactory + // selection path for HFILE base-format tables. + // delete and recreate + metaClient.getStorage().deleteDirectory(metaClient.getBasePath()); + Properties properties = new Properties(); + properties.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key"); + properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(), "partition_path"); + properties.put(HoodieWriteConfig.PRECOMBINE_FIELD_NAME.key(), ORDERING_FIELD); + initMetaClient(getTableType(), properties); + + HoodieWriteConfig config = getHoodieWriteConfigBuilder().build(); + HoodieSparkTable.create(config, new HoodieLocalEngineContext(storageConf), metaClient); + + String partitionPath = HoodieTestDataGenerator.DEFAULT_PARTITION_PATHS[0]; + HoodieTestDataGenerator dataGenerator = new HoodieTestDataGenerator(new String[] {partitionPath}); + + // These two keys sort in opposite order under UTF-16 (String#compareTo) vs UTF-8 bytes (HFile/MDT order). + String supplementaryKey = "😀_record"; + String bmpHighKey = new String(Character.toChars(0xFFFD)) + "_record"; + assertTrue(supplementaryKey.compareTo(bmpHighKey) < 0); + assertTrue(StringUtils.compareUtf8Bytes(bmpHighKey, supplementaryKey) < 0); + + // Base file has the UTF-8-smaller key. + List baseRecords = withRowKey(dataGenerator.generateInserts("000", 1), bmpHighKey, partitionPath); + SparkRDDWriteClient client = getHoodieWriteClient(config); + String instantTime = client.startCommit(); + JavaRDD statuses = client.upsert(jsc.parallelize(baseRecords, 1), instantTime); + client.commit(instantTime, statuses, Option.empty(), COMMIT_ACTION, Collections.emptyMap(), Option.empty()); + + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieSparkCopyOnWriteTable table = (HoodieSparkCopyOnWriteTable) HoodieSparkCopyOnWriteTable.create(config, context, metaClient); + HoodieFileGroup fileGroup = table.getFileSystemView().getAllFileGroups(partitionPath).collect(Collectors.toList()).get(0); + String fileId = fileGroup.getFileGroupId().getFileId(); + + // Merge the UTF-8-larger key in directly via HoodieSortedMergeHandle. + List newRecords = withRowKey(dataGenerator.generateInserts("001", 1), supplementaryKey, partitionPath); + HoodieSortedMergeHandle mergeHandle = new HoodieSortedMergeHandle( + config, "001", table, newRecords.iterator(), partitionPath, fileId, new LocalTaskContextSupplier(), Option.empty()); + mergeHandle.doMerge(); + WriteStatus writeStatus = (WriteStatus) mergeHandle.close().get(0); + + String fullPath = metaClient.getBasePath() + "/" + writeStatus.getStat().getPath(); + List actualRecords = new ParquetUtils().readAvroRecords(metaClient.getStorage(), new StoragePath(fullPath)); + List actualKeysInOrder = actualRecords.stream().map(r -> r.get("_row_key").toString()).collect(Collectors.toList()); + // bmpHighKey must come first when sorted by UTF-8 bytes. + assertEquals(Arrays.asList(bmpHighKey, supplementaryKey), actualKeysInOrder); + } + @Test void testWriteFailures() throws Exception { // delete and recreate @@ -600,6 +653,12 @@ private List getHoodieRecords(String payloadClass, List withRowKey(List records, String rowKey, String partitionPath) { + GenericRecord genericRecord = (GenericRecord) ((SerializableIndexedRecord) records.get(0).getData()).getData(); + genericRecord.put("_row_key", rowKey); + return getHoodieRecords(OverwriteWithLatestAvroPayload.class.getName(), Collections.singletonList(genericRecord), partitionPath, false); + } + private void setCurLocation(List records, String fileId, String instantTime) { records.forEach(record -> record.setCurrentLocation(new HoodieRecordLocation(instantTime, fileId))); } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/TestHoodieSparkLanceWriter.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/TestHoodieSparkLanceWriter.java index 722833a7756c0..122834c46870a 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/TestHoodieSparkLanceWriter.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/TestHoodieSparkLanceWriter.java @@ -28,6 +28,7 @@ import org.apache.hudi.common.schema.HoodieSchemaType; import org.apache.hudi.common.testutils.HoodieTestUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieNotSupportedException; import org.apache.hudi.io.memory.HoodieArrowAllocator; import org.apache.hudi.storage.HoodieStorage; @@ -47,6 +48,9 @@ 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.sql.types.Metadata; +import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; import org.apache.spark.unsafe.types.UTF8String; import org.junit.jupiter.api.AfterEach; @@ -639,4 +643,95 @@ public void testValidateNoVariantColumns_variantInNullableUnion_throws() { () -> HoodieSparkLanceWriter.validateNoVariantColumns(record)); assertTrue(ex.getMessage().contains("payload"), "Error should name the field: " + ex.getMessage()); } + + // ----- INLINE blob descriptor-leak guard tests ----- + + /** + * Builds a two-column schema (id INT + a canonical BLOB column) so the writer recognizes the + * second column as a blob via the {@code hudi_type=BLOB} field metadata. The struct layout is + * the canonical one produced by {@link org.apache.spark.sql.types.BlobType}: type, data, + * reference. + */ + private StructType createBlobSchema() { + StructType blobStruct = (StructType) org.apache.spark.sql.types.BlobType.dataType(); + Metadata blobMetadata = new MetadataBuilder() + .putString(HoodieSchema.TYPE_METADATA_FIELD, HoodieSchemaType.BLOB.name()) + .build(); + return new StructType() + .add(new StructField("id", DataTypes.IntegerType, false, Metadata.empty())) + .add(new StructField("payload", blobStruct, true, blobMetadata)); + } + + /** + * Builds the reference sub-struct {external_path, offset, length, managed}. + */ + private InternalRow blobReference(String externalPath, Long offset, Long length, boolean managed) { + return new GenericInternalRow(new Object[] { + externalPath == null ? null : UTF8String.fromString(externalPath), + offset, + length, + managed + }); + } + + /** + * Writing an INLINE blob with null {@code data} but a non-null {@code reference} is the + * descriptor-leak shape: it means an internal write-side read handed the writer a DESCRIPTOR row + * (reference populated, bytes dropped) instead of CONTENT. The writer must reject it with a + * {@link HoodieException} that points at {@code hoodie.read.blob.inline.mode}, rather than + * silently persisting a blob with no bytes (#19232). + */ + @Test + public void testWriteInlineBlobWithNullDataAndReference_throws() { + StructType schema = createBlobSchema(); + StoragePath path = new StoragePath(tempDir.getAbsolutePath() + "/test_blob_descriptor_leak.lance"); + + InternalRow reference = blobReference("s3://bucket/blob.bin", 0L, 1024L, false); + InternalRow blob = new GenericInternalRow(new Object[] { + UTF8String.fromString(HoodieSchema.Blob.INLINE), // type = INLINE + null, // data = null (leaked) + reference // reference = non-null (leaked) + }); + InternalRow row = new GenericInternalRow(new Object[] {1, blob}); + + HoodieException ex = assertThrows(HoodieException.class, () -> { + try (HoodieSparkLanceWriter writer = HoodieSparkLanceWriter.builder() + .file(path).sparkSchema(schema).instantTime(instantTime).taskContextSupplier(taskContextSupplier) + .storage(storage).bloomFilterOpt(Option.of(simpleBloomFilter)).build()) { + writer.writeRow("key1", row); + } + }); + assertTrue(ex.getMessage() != null && ex.getMessage().contains("hoodie.read.blob.inline.mode"), + "Descriptor-leak guard must reference hoodie.read.blob.inline.mode: " + ex.getMessage()); + } + + /** + * An INLINE blob with null {@code data} AND null {@code reference} is a legitimate null inline + * payload, not a descriptor leak. The writer must accept it and close cleanly. + */ + @Test + public void testWriteInlineBlobWithNullDataAndNullReference_succeeds() throws Exception { + StructType schema = createBlobSchema(); + StoragePath path = new StoragePath(tempDir.getAbsolutePath() + "/test_blob_null_inline.lance"); + + InternalRow blob = new GenericInternalRow(new Object[] { + UTF8String.fromString(HoodieSchema.Blob.INLINE), // type = INLINE + null, // data = null (legit null payload) + null // reference = null + }); + InternalRow row = new GenericInternalRow(new Object[] {1, blob}); + + try (HoodieSparkLanceWriter writer = HoodieSparkLanceWriter.builder() + .file(path).sparkSchema(schema).instantTime(instantTime).taskContextSupplier(taskContextSupplier) + .storage(storage).bloomFilterOpt(Option.of(simpleBloomFilter)).build()) { + writer.writeRow("key1", row); + } + + assertTrue(storage.exists(path), "Lance file with a null INLINE payload should be written"); + try (BufferAllocator allocator = HoodieArrowAllocator.newChildAllocator( + "testWriteInlineBlobWithNullDataAndNullReference", TEST_LANCE_DATA_ALLOCATOR_SIZE); + LanceFileReader reader = LanceFileReader.open(path.toString(), allocator)) { + assertEquals(1, reader.numRows(), "The single null-payload row should be written"); + } + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java index b2c9d24b1b961..1e40b8ea8d053 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java @@ -36,12 +36,16 @@ import org.apache.hadoop.conf.Configuration; import org.apache.parquet.hadoop.metadata.CompressionCodecName; import org.apache.parquet.hadoop.metadata.FileMetaData; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.StructType; 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.ValueSource; @@ -126,6 +130,38 @@ public void testProperWriting(boolean parquetWriteLegacyFormatEnabled) throws Ex }); } + @Test + void testDecimalFixedLenWidthFromAvroSchema() { + // The row-writer sizes decimal FIXED_LEN columns from the Avro schema: an Avro fixed(10) for + // decimal(20,2) keeps its declared width (10, wider than the precision-minimal 9), while a bytes + // decimal keeps the precision-minimal width (9). + assertEquals(10, decimalParquetTypeLength(decimalRecordSchema( + "{\"type\":\"fixed\",\"name\":\"dec_fixed\",\"size\":10,\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}")), + "Avro fixed(10) decimal must stay FIXED_LEN_BYTE_ARRAY(10)"); + assertEquals(9, decimalParquetTypeLength(decimalRecordSchema( + "{\"type\":\"bytes\",\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}")), + "bytes decimal keeps the precision-minimal FIXED_LEN width (9)"); + } + + private static String decimalRecordSchema(String decType) { + return "{\"type\":\"record\",\"name\":\"rec\",\"fields\":[{\"name\":\"dec\",\"type\":" + decType + "}]}"; + } + + private int decimalParquetTypeLength(String avroSchemaJson) { + StructType structType = new StructType().add("dec", DataTypes.createDecimalType(20, 2), false); + HoodieWriteConfig config = HoodieWriteConfig.newBuilder() + .withPath(basePath) + .withSchema(avroSchemaJson) + .build(); + HoodieRowParquetWriteSupport writeSupport = HoodieRowParquetWriteSupport.getHoodieRowParquetWriteSupport( + storageConf.unwrap(), structType, Option.empty(), config); + MessageType parquetSchema = writeSupport.init(writeSupport.getHadoopConf()).getSchema(); + PrimitiveType dec = parquetSchema.getType("dec").asPrimitiveType(); + assertEquals(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, dec.getPrimitiveTypeName(), + "decimal must be encoded as FIXED_LEN_BYTE_ARRAY"); + return dec.getTypeLength(); + } + private HoodieRowParquetWriteSupport getWriteSupport(HoodieWriteConfig.Builder writeConfigBuilder, Configuration hadoopConf, boolean parquetWriteLegacyFormatEnabled) { writeConfigBuilder.withStorageConfig(HoodieStorageConfig.newBuilder().parquetWriteLegacyFormat(String.valueOf(parquetWriteLegacyFormatEnabled)).build()); HoodieWriteConfig writeConfig = writeConfigBuilder.build(); diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/cluster/TestIncrementalClustering.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/cluster/TestIncrementalClustering.java index 8336a6f7e0052..800655065501d 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/cluster/TestIncrementalClustering.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/cluster/TestIncrementalClustering.java @@ -131,8 +131,8 @@ public void testPartitionsForIncrClusteringWithFilter(ClusteringPlanPartitionFil switch (mode) { case NONE: { - // For partitions filtered out by the regex expression, they will not be recorded in the missingPartitions - assertEquals(0, clusteringPlan.getMissingSchedulePartitions().size()); + assertEquals(1, clusteringPlan.getMissingSchedulePartitions().size()); + assertTrue(clusteringPlan.getMissingSchedulePartitions().contains(YESTERDAY)); break; } case SELECTED_PARTITIONS: { diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java index 7248078bbb889..467531e89a933 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java @@ -21,10 +21,12 @@ import org.apache.hudi.avro.model.HoodieRollbackMetadata; import org.apache.hudi.avro.model.HoodieRollbackPartitionMetadata; import org.apache.hudi.client.SparkRDDWriteClient; +import org.apache.hudi.client.WriteClientTestUtils; import org.apache.hudi.client.WriteStatus; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.fs.ConsistencyGuardConfig; +import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieFileGroup; @@ -32,6 +34,10 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.marker.MarkerType; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; @@ -52,6 +58,12 @@ import org.apache.hudi.testutils.Assertions; import org.apache.hudi.testutils.MetadataMergeWriteStatus; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.FileUtil; +import org.apache.hadoop.fs.LocatedFileStatus; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.fs.RemoteIterator; import org.apache.spark.api.java.JavaRDD; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -62,17 +74,21 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH; import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH; import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH; +import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_FILE_NAME_GENERATOR; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; public class TestMergeOnReadRollbackActionExecutor extends HoodieClientRollbackTestBase { @@ -487,4 +503,223 @@ public void testRollbackWhenFirstCommitFail() { client.rollback(newCommitTime); } } + + /** + * Tests that rollback operations generate unique write tokens for log files, preventing collisions + * during repeated rollback attempts. + * + *

    This test validates the fix for write token generation in metadata table rollbacks. Previously, + * rollback log files used the default UNKNOWN_WRITE_TOKEN ("1-0-1"), causing collisions when rollback + * was retried. Now, each rollback generates explicit write tokens based on Spark task context + * (format: {partitionId}-{stageId}-{attemptId}). + * + *

    Test flow: + *

      + *
    1. Create initial commit with inserts to establish base files
    2. + *
    3. Create second commit with updates to generate log files (MOR table)
    4. + *
    5. Backup commit timeline files and marker directory for repeated rollback simulation
    6. + *
    7. Execute first rollback and validate write tokens are NOT "1-0-1"
    8. + *
    9. Restore commit state (timeline files + markers) to simulate rollback retry scenario
    10. + *
    11. Execute second rollback and validate unique write tokens prevent collisions
    12. + *
    13. Verify exactly one new rollback log file per file group from second attempt
    14. + *
    + * + * @param enableMetadataTable runs the test both with and without metadata table enabled to + * ensure write-token generation is correct in both code paths + */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testRollbackWriteTokenGeneration(boolean enableMetadataTable) throws Exception { + // 1. Setup: Create a table-version-6 MOR table so the rollback exercises RollbackHelperV1 + // (which is what this test targets). On v8+ rollbacks delete files directly and don't + // produce rollback log files. + Properties props = new Properties(); + props.put(HoodieTableConfig.VERSION.key(), HoodieTableVersion.SIX.versionCode()); + tearDown(); + initPath(); + initSparkContexts(); + dataGen = new HoodieTestDataGenerator( + new String[] {DEFAULT_FIRST_PARTITION_PATH, DEFAULT_SECOND_PARTITION_PATH}); + initHoodieStorage(); + initMetaClient(HoodieTableType.MERGE_ON_READ, props); + + HoodieWriteConfig cfg = getConfigBuilder() + .withRollbackUsingMarkers(true) + .withMarkersType(MarkerType.DIRECT.name()) + .withWriteTableVersion(HoodieTableVersion.SIX.versionCode()) + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(enableMetadataTable).build()) + .withCompactionConfig(HoodieCompactionConfig.newBuilder().compactionSmallFileSize(0).build()) + .build(); + + HoodieTestDataGenerator.writePartitionMetadataDeprecated( + storage, new String[] {DEFAULT_FIRST_PARTITION_PATH}, basePath); + FileSystem fs = (FileSystem) storage.getFileSystem(); + SparkRDDWriteClient client = getHoodieWriteClient(cfg); + + // Write 1: Initial inserts + String commitTime1 = "001"; + WriteClientTestUtils.startCommitWithTime(client, commitTime1); + List records = dataGen.generateInsertsForPartition(commitTime1, 100, DEFAULT_FIRST_PARTITION_PATH); + JavaRDD writeRecords = jsc.parallelize(records, 1); + List statusList = client.upsert(writeRecords, commitTime1).collect(); + Assertions.assertNoWriteErrors(statusList); + client.commit(commitTime1, jsc.parallelize(statusList)); + + // Write 2: Updates to same partition to create log files. Use multiple Spark partitions to + // exercise multiple task contexts (so write tokens vary across tasks). + String commitTime2 = "002"; + WriteClientTestUtils.startCommitWithTime(client, commitTime2); + List updateRecords = dataGen.generateUpdates(commitTime2, records); + writeRecords = jsc.parallelize(updateRecords, 2); + statusList = client.upsert(writeRecords, commitTime2).collect(); + Assertions.assertNoWriteErrors(statusList); + // Intentionally leave commit 002 in inflight state so rollback exercises the inflight path. + + HoodieTable table = this.getHoodieTable(metaClient, cfg); + Map> logFileNames = collectLogFileNamesByFileId(fs, DEFAULT_FIRST_PARTITION_PATH); + assertFalse(logFileNames.isEmpty()); + + // Backup commit 002 timeline files + marker dir so the rollback retry below can replay the same input. + Path commit2RequestedPath = new Path(metaClient.getMetaPath().toString(), + commitTime2 + HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION); + Path commit2InflightPath = new Path(metaClient.getMetaPath().toString(), + commitTime2 + HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION); + Path commit2MarkerDir = new Path(metaClient.getMarkerFolderPath(commitTime2)); + Path backupDir = new Path(basePath, ".backup_test"); + Path backupMarkerDir = new Path(backupDir, commitTime2); + fs.mkdirs(backupDir); + + boolean requestedExists = fs.exists(commit2RequestedPath); + boolean inflightExists = fs.exists(commit2InflightPath); + boolean markerDirExists = fs.exists(commit2MarkerDir); + + if (requestedExists) { + FileUtil.copy(fs, commit2RequestedPath, fs, + new Path(backupDir, commitTime2 + HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION), + false, fs.getConf()); + } + if (inflightExists) { + FileUtil.copy(fs, commit2InflightPath, fs, + new Path(backupDir, commitTime2 + HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION), + false, fs.getConf()); + } + if (markerDirExists) { + FileUtil.copy(fs, commit2MarkerDir, fs, backupMarkerDir, false, fs.getConf()); + } + + // 3. Rollback commit 002 + String rollbackTime = "003"; + HoodieInstant rollBackInstant = INSTANT_GENERATOR.createNewInstant( + HoodieInstant.State.INFLIGHT, HoodieTimeline.DELTA_COMMIT_ACTION, commitTime2); + BaseRollbackPlanActionExecutor rollbackPlanExecutor = new BaseRollbackPlanActionExecutor( + context, cfg, table, rollbackTime, rollBackInstant, false, true, false); + rollbackPlanExecutor.execute().get(); + + MergeOnReadRollbackActionExecutor rollbackExecutor = new MergeOnReadRollbackActionExecutor( + context, cfg, table, rollbackTime, rollBackInstant, true, false); + Map rollbackMetadata = rollbackExecutor.execute().getPartitionMetadata(); + + assertEquals(1, rollbackMetadata.size()); + HoodieRollbackPartitionMetadata partitionMetadata = rollbackMetadata.get(DEFAULT_FIRST_PARTITION_PATH); + assertFalse(partitionMetadata.getRollbackLogFiles().isEmpty(), "Should have rollback log files"); + + metaClient = HoodieTableMetaClient.reload(metaClient); + table = this.getHoodieTable(metaClient, cfg); + + // Validate write tokens on the rollback log files are per-task generated (not UNKNOWN_WRITE_TOKEN "1-0-1"). + List rollbackFileSlices = table.getSliceView() + .getLatestFileSlices(DEFAULT_FIRST_PARTITION_PATH) + .collect(Collectors.toList()); + // FileSlice.getLogFiles() is sorted highest-version first (reverse comparator), + // so index 0 is the latest log file produced by the rollback. + List rollbackLogFiles = rollbackFileSlices.stream() + .flatMap(slice -> { + List logFiles = slice.getLogFiles().collect(Collectors.toList()); + return Collections.singleton(logFiles.get(0)).stream(); + }) + .collect(Collectors.toList()); + + assertFalse(rollbackLogFiles.isEmpty(), "Should have rollback log files with rollback instant time"); + for (HoodieLogFile logFile : rollbackLogFiles) { + String writeToken = logFile.getLogWriteToken(); + assertFalse(writeToken.isEmpty(), "Write token should not be empty"); + assertTrue(writeToken.matches("\\d+-\\d+-\\d+"), + String.format("Write token should match pattern partitionId-stageId-attemptId, but got: %s in file: %s", + writeToken, logFile.getFileName())); + assertNotEquals("1-0-1", writeToken); + } + + Map> logFileNamesPostRollback = collectLogFileNamesByFileId(fs, DEFAULT_FIRST_PARTITION_PATH); + + // Simulate rollback retry: remove rollback timeline files and restore commit 002 timeline + markers. + HoodieInstant lastRollbackInstant = metaClient.getActiveTimeline().getRollbackTimeline().lastInstant().get(); + String latestRollbackCompletedFileName = + INSTANT_FILE_NAME_GENERATOR.getFileName(lastRollbackInstant); + fs.delete(new Path(metaClient.getMetaPath().toString(), latestRollbackCompletedFileName), false); + fs.delete(new Path(metaClient.getMetaPath().toString(), + rollbackTime + HoodieTimeline.INFLIGHT_ROLLBACK_EXTENSION), false); + + if (requestedExists) { + FileUtil.copy(fs, new Path(backupDir, commitTime2 + HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION), + fs, commit2RequestedPath, false, fs.getConf()); + } + if (inflightExists) { + FileUtil.copy(fs, new Path(backupDir, commitTime2 + HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION), + fs, commit2InflightPath, false, fs.getConf()); + } + if (markerDirExists) { + FileUtil.copy(fs, backupMarkerDir, fs, commit2MarkerDir.getParent(), false, fs.getConf()); + } + fs.delete(backupDir, true); + + metaClient = HoodieTableMetaClient.reload(metaClient); + table = this.getHoodieTable(metaClient, cfg); + + // Trigger second rollback - should create additional rollback log files with different write tokens. + MergeOnReadRollbackActionExecutor rollbackExecutor2 = new MergeOnReadRollbackActionExecutor( + context, cfg, table, rollbackTime, rollBackInstant, true, false); + Map rollbackMetadata2 = rollbackExecutor2.execute().getPartitionMetadata(); + + assertEquals(1, rollbackMetadata2.size()); + HoodieRollbackPartitionMetadata partitionMetadata2 = rollbackMetadata2.get(DEFAULT_FIRST_PARTITION_PATH); + assertFalse(partitionMetadata2.getRollbackLogFiles().isEmpty(), "Should have rollback log files"); + + metaClient = HoodieTableMetaClient.reload(metaClient); + + Map> logFileNamesPost2ndRollback = collectLogFileNamesByFileId(fs, DEFAULT_FIRST_PARTITION_PATH); + Map filesFrom2ndRollback = new HashMap<>(); + logFileNamesPost2ndRollback.forEach((fileId, fileNames) -> { + List previousFiles = logFileNamesPostRollback.getOrDefault(fileId, Collections.emptyList()); + for (String fileName : fileNames) { + if (!previousFiles.contains(fileName)) { + filesFrom2ndRollback.merge(fileId, 1, Integer::sum); + assertNotEquals("1-0-1", new HoodieLogFile(fileName).getLogWriteToken()); + } + } + }); + + assertFalse(filesFrom2ndRollback.isEmpty(), + "Second rollback should produce at least one new log file (no collision with first rollback)"); + assertEquals(logFileNames.size(), filesFrom2ndRollback.size()); + filesFrom2ndRollback.forEach((k, v) -> assertEquals(1, v)); + client.close(); + } + + /** + * Lists all log files in the given partition and groups their file names by file ID. + */ + private Map> collectLogFileNamesByFileId(FileSystem fs, String partitionPath) throws IOException { + Map> logFilesByFileId = new HashMap<>(); + RemoteIterator itr = fs.listFiles( + new Path(metaClient.getBasePath().toString() + "/" + partitionPath), false); + while (itr.hasNext()) { + FileStatus fileStatus = itr.next(); + String fileName = fileStatus.getPath().getName(); + if (FSUtils.isLogFile(fileName)) { + String fileId = FSUtils.getFileId(fileName); + logFilesByFileId.computeIfAbsent(fileId, k -> new ArrayList<>()).add(fileName); + } + } + return logFilesByFileId; + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java index 960b98eb73e2e..2825ff5396801 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java @@ -29,19 +29,27 @@ import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.DefaultHoodieRecordPayload; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieAvroRecord; +import org.apache.hudi.common.model.HoodieBaseFile; import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy; import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; import org.apache.hudi.common.model.PartialUpdateAvroPayload; import org.apache.hudi.common.model.WriteConcurrencyMode; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.util.CompactionUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ParquetUtils; import org.apache.hudi.config.HoodieCleanConfig; +import org.apache.hudi.config.HoodieClusteringConfig; import org.apache.hudi.config.HoodieCompactionConfig; import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodieLayoutConfig; @@ -52,6 +60,8 @@ import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.storage.StoragePath; import org.apache.hudi.storage.StoragePathInfo; +import org.apache.hudi.table.HoodieSparkTable; +import org.apache.hudi.table.HoodieTable; import org.apache.hudi.table.action.HoodieWriteMetadata; import org.apache.hudi.table.action.commit.SparkBucketIndexPartitioner; import org.apache.hudi.table.action.rollback.RollbackUtils; @@ -59,17 +69,28 @@ import org.apache.hudi.testutils.HoodieMergeOnReadTestUtils; import org.apache.hudi.testutils.SparkClientFunctionalTestHarness; +import org.apache.avro.Conversions; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericFixed; +import org.apache.avro.generic.GenericRecord; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; import org.apache.spark.api.java.JavaRDD; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; +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.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; +import java.math.BigDecimal; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -81,6 +102,7 @@ import static org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA; import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -442,4 +464,137 @@ private void commitToTable(String instant, List writeStatuses) { client.commitStats(instant, writeStats, Option.empty(), metaClient.getCommitActionType()); assertTrue(committed); } + + // Avro fixed(10) decimal(20,2). 10 is wider than the precision-minimal width (9 for precision + // 20), so Spark's own DecimalType->Avro conversion would emit fixed(9); the declared 10 only + // survives if the write path honors the Avro fixed size. Do not derive this schema from a + // DataFrame, which would drop the fixed size. + private static final String FIXED10_DECIMAL_SCHEMA = + "{\"type\":\"record\",\"name\":\"decimalRec\",\"fields\":[" + + "{\"name\":\"_row_key\",\"type\":\"string\"}," + + "{\"name\":\"partition_path\",\"type\":\"string\"}," + + "{\"name\":\"ts\",\"type\":\"long\"}," + + "{\"name\":\"dec\",\"type\":{\"type\":\"fixed\",\"name\":\"decFixed\",\"size\":10," + + "\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}}]}"; + + private static final String DECIMAL_PARTITION = "p1"; + private static final int EXPECTED_DECIMAL_FIXED_LEN = 10; + + @Test + void testDecimalFixedWidthPreservedAfterCompactionAndClustering() throws Exception { + Properties props = getPropertiesForKeyGen(true); + Properties rowWriterProps = new Properties(); + rowWriterProps.put("hoodie.datasource.write.row.writer.enable", "true"); + HoodieWriteConfig config = HoodieWriteConfig.newBuilder() + .forTable("test-decimal-fixed") + .withPath(basePath()) + .withSchema(FIXED10_DECIMAL_SCHEMA) + .withParallelism(2, 2) + .withPreCombineField("ts") + .withProperties(rowWriterProps) + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .withMaxNumDeltaCommitsBeforeCompaction(1) + .compactionSmallFileSize(0) + .withInlineCompaction(false) + .build()) + .withClusteringConfig(HoodieClusteringConfig.newBuilder() + .withClusteringMaxNumGroups(10) + .withClusteringTargetPartitions(0) + .withInlineClustering(false) + .withInlineClusteringNumCommits(1) + .build()) + .build(); + props.putAll(config.getProps()); + + metaClient = getHoodieMetaClient(HoodieTableType.MERGE_ON_READ, props); + client = getHoodieWriteClient(config); + + // two insert commits (small-file size 0 forces a fresh file group each) create two base-file + // groups, both written via the Avro path at fixed(10) + String instant1 = WriteClientTestUtils.createNewInstantTime(); + writeData(instant1, buildDecimalRecords(0, 10, 1L, new BigDecimal("123456789.12")), true); + String instant2 = WriteClientTestUtils.createNewInstantTime(); + writeData(instant2, buildDecimalRecords(10, 10, 1L, new BigDecimal("223456789.34")), true); + // update every key so both groups accumulate log files for compaction to merge + String instant3 = WriteClientTestUtils.createNewInstantTime(); + writeData(instant3, buildDecimalRecords(0, 20, 2L, new BigDecimal("323456789.56")), true); + + // precondition: both file groups must carry log files, else compaction is a no-op and would not + // exercise the row-writer merge path + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieTable hoodieTable = HoodieSparkTable.create(config, context(), metaClient); + hoodieTable.getHoodieView().sync(); + List latestSlices = + hoodieTable.getHoodieView().getLatestFileSlices(DECIMAL_PARTITION).collect(Collectors.toList()); + assertEquals(2, latestSlices.size(), "expected two file groups before compaction"); + assertTrue(latestSlices.stream().allMatch(slice -> slice.getLogFiles().findAny().isPresent()), + "each file group must have log files for compaction to merge"); + HoodieSchema tableSchema = new TableSchemaResolver(metaClient).getTableSchema(false); + + // compaction rewrites both base files through the Spark record type + String compactionInstant = (String) client.scheduleCompaction(Option.empty()).get(); + HoodieWriteMetadata compactionResult = client.compact(compactionInstant); + client.commitCompaction(compactionInstant, compactionResult, Option.empty()); + assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants().containsInstant(compactionInstant)); + List compactedBaseFiles = latestBaseFilePaths(config, DECIMAL_PARTITION); + assertEquals(2, compactedBaseFiles.size(), "expected two compacted base files"); + for (StoragePath path : compactedBaseFiles) { + assertDecimalFixedLen(path, EXPECTED_DECIMAL_FIXED_LEN); + } + assertEquals(tableSchema, + new TableSchemaResolver(HoodieTableMetaClient.reload(metaClient)).getTableSchema(false), + "table schema in commit metadata must not change after compaction"); + + // clustering rewrites the two compacted groups through the same Spark row-writer path + String clusteringInstant = (String) client.scheduleClustering(Option.empty()).get(); + HoodieWriteMetadata> clusterMetadata = client.cluster(clusteringInstant, true); + List clusterStats = clusterMetadata.getWriteStats().get(); + assertFalse(clusterStats.isEmpty(), "clustering should write at least one base file"); + for (HoodieWriteStat stat : clusterStats) { + assertDecimalFixedLen(new StoragePath(metaClient.getBasePath(), stat.getPath()), + EXPECTED_DECIMAL_FIXED_LEN); + } + assertEquals(tableSchema, + new TableSchemaResolver(HoodieTableMetaClient.reload(metaClient)).getTableSchema(false), + "table schema in commit metadata must not change after clustering"); + } + + private List buildDecimalRecords(int startKey, int count, long ts, BigDecimal decValue) { + Schema schema = new Schema.Parser().parse(FIXED10_DECIMAL_SCHEMA); + Schema decSchema = schema.getField("dec").schema(); + Conversions.DecimalConversion decimalConversion = new Conversions.DecimalConversion(); + LogicalTypes.Decimal decimalType = LogicalTypes.decimal(20, 2); + BigDecimal scaledValue = decValue.setScale(2); + List records = new ArrayList<>(); + for (int i = 0; i < count; i++) { + String key = "key_" + (startKey + i); + GenericRecord rec = new GenericData.Record(schema); + rec.put("_row_key", key); + rec.put("partition_path", DECIMAL_PARTITION); + rec.put("ts", ts); + GenericFixed fixed = decimalConversion.toFixed(scaledValue, decSchema, decimalType); + rec.put("dec", fixed); + records.add(new HoodieAvroRecord<>(new HoodieKey(key, DECIMAL_PARTITION), + new OverwriteWithLatestAvroPayload(rec, ts))); + } + return records; + } + + private List latestBaseFilePaths(HoodieWriteConfig config, String partition) { + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieTable hoodieTable = HoodieSparkTable.create(config, context(), metaClient); + hoodieTable.getHoodieView().sync(); + return hoodieTable.getBaseFileOnlyView().getLatestBaseFiles(partition) + .map(HoodieBaseFile::getStoragePath).collect(Collectors.toList()); + } + + private void assertDecimalFixedLen(StoragePath baseFilePath, int expectedLen) { + MessageType parquetSchema = ParquetUtils.readMetadata(hoodieStorage(), baseFilePath) + .getFileMetaData().getSchema(); + PrimitiveType decType = parquetSchema.getType("dec").asPrimitiveType(); + assertEquals(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, decType.getPrimitiveTypeName(), + "decimal must be encoded as FIXED_LEN_BYTE_ARRAY"); + assertEquals(expectedLen, decType.getTypeLength(), + "Avro fixed(10) decimal(20,2) must stay FIXED_LEN_BYTE_ARRAY(10), not narrow to 9"); + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableInsertUpdateDelete.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableInsertUpdateDelete.java index d1f21ddb3c4dc..fcfbb2eb9832b 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableInsertUpdateDelete.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableInsertUpdateDelete.java @@ -37,6 +37,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.AppendResult; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.LogFileCreationCallback; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieDataBlock; @@ -396,11 +397,11 @@ public void testSimpleInsertsGeneratedIntoLogFiles() throws Exception { final WriteMarkers writeMarkers = WriteMarkersFactory.get(config.getMarkersType(), HoodieSparkTable.create(config, context()), newCommitTime); - HoodieLogFormat.Writer fakeLogWriter = HoodieLogFormat.newWriterBuilder() - .onParentPath( + HoodieLogFormat.Writer fakeLogWriter = HoodieLogFormatWriter.builder() + .withParentPath( FSUtils.constructAbsolutePath(config.getBasePath(), correctWriteStat.getPartitionPath())) - .withFileId(correctWriteStat.getFileId()) + .withLogFileId(correctWriteStat.getFileId()) .withInstantTime(newCommitTime) .withLogVersion(correctLogFile.getLogVersion()) .withFileSize(0L) diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestAvroConversionUtils.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestAvroConversionUtils.scala index 5f4ec78c13cf3..0430dd081237a 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestAvroConversionUtils.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestAvroConversionUtils.scala @@ -28,6 +28,7 @@ import org.apache.spark.sql.types.{ArrayType, BinaryType, DataType, DataTypes, M import org.scalatest.{FunSuite, Matchers} import java.nio.ByteBuffer +import java.time.LocalDate import java.util.Objects class TestAvroConversionUtils extends FunSuite with Matchers { @@ -456,4 +457,30 @@ class TestAvroConversionUtils extends FunSuite with Matchers { HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(struct, "SchemaName", "SchemaNS") } should have message "Duplicate field name in record SchemaNS.SchemaName: name type:UNION pos:2 and name type:UNION pos:1." } + + test("Logical type: date") { + val dateSchema = s""" + { + "namespace": "logical", + "type": "record", + "name": "test", + "fields": [ + {"name": "date", "type": {"type": "int", "logicalType": "date"}} + ] + } + """ + + val dateInputData = Seq(7, 365, 0) // one week, one year, epoch + val schema = HoodieSchema.parse(dateSchema) + val converter = AvroConversionUtils.createConverterToRow(schema, HoodieSchemaConversionUtils.convertHoodieSchemaToStructType(schema)) + + val dateOutputData = dateInputData.map(x => { + val record = new GenericData.Record(schema.toAvroSchema) {{ put("date", x) }} + converter(record).get(0) + }) + + assert(dateOutputData(0).toString === LocalDate.ofEpochDay(dateInputData(0)).toString) + assert(dateOutputData(1).toString === LocalDate.ofEpochDay(dateInputData(1)).toString) + assert(dateOutputData(2).toString === LocalDate.ofEpochDay(dateInputData(2)).toString) + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala new file mode 100644 index 0000000000000..74e800534c436 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala @@ -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.hudi + +import org.apache.hudi.common.config.RecordMergeMode +import org.apache.hudi.common.model.WriteOperationType +import org.apache.hudi.config.HoodieWriteConfig +import org.apache.hudi.keygen.constant.KeyGeneratorOptions + +import org.apache.spark.SparkException +import org.apache.spark.sql.{Row, SparkSession} +import org.apache.spark.sql.types._ +import org.junit.jupiter.api.{AfterAll, BeforeAll, Test} +import org.junit.jupiter.api.Assertions.{assertNotNull, assertTrue} + +/** + * Test cases for {@link HoodieCreateRecordUtils}. + */ +class TestHoodieCreateRecordUtils { + + private val SPARK_SCHEMA = StructType(Seq( + StructField("uuid", StringType, nullable = false), + StructField("name", StringType, nullable = false), + StructField("age", IntegerType, nullable = false), + StructField("ts", LongType, nullable = true), + StructField("partition", StringType, nullable = false) + )) + + // Common test constants + private val TEST_TABLE_NAME = "test_table" + private val RECORD_NAME = "TestRecord" + private val RECORD_NAMESPACE = "org.apache.hudi.test" + private val INSTANT_TIME = "20231031000000" + private val RECORD_KEY_FIELD = "uuid" + private val PARTITION_FIELD = "partition" + private val PRECOMBINE_FIELD = "ts" + + /** + * Helper method to create DataFrame from Row data + */ + private def createTestDataFrame(rows: Row*): org.apache.spark.sql.DataFrame = { + val spark = TestHoodieCreateRecordUtils.spark + spark.createDataFrame(spark.sparkContext.parallelize(rows), SPARK_SCHEMA) + } + + /** + * Helper method to get the root cause of an exception. + * Iterative implementation to avoid stack overflow and handle circular references. + * + * @param t The throwable to extract root cause from + * @return The root cause throwable + */ + private def getRootCause(t: Throwable): Throwable = { + var current = t + val visited = scala.collection.mutable.Set[Throwable]() + + while (current.getCause != null && !visited.contains(current)) { + visited += current + current = current.getCause + } + + current + } + + /** + * Helper method to create base parameters common to all tests. + * These are the mandatory properties required by SimpleKeyGenerator. + */ + private def createBaseParameters(): Map[String, String] = { + Map( + // KeyGeneratorOptions (used by some parts of the pipeline) + KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key() -> RECORD_KEY_FIELD, + KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key() -> PARTITION_FIELD, + // DataSourceWriteOptions (required by SimpleKeyGenerator) + DataSourceWriteOptions.RECORDKEY_FIELD.key() -> RECORD_KEY_FIELD, + DataSourceWriteOptions.PARTITIONPATH_FIELD.key() -> PARTITION_FIELD + ) + } + + /** + * Helper method to create common parameters for tests with precombine + */ + private def createParametersWithPrecombine(payloadClass: String = "org.apache.hudi.common.model.DefaultHoodieRecordPayload"): Map[String, String] = { + createBaseParameters() ++ Map( + DataSourceWriteOptions.PRECOMBINE_FIELD.key() -> PRECOMBINE_FIELD, + DataSourceWriteOptions.PAYLOAD_CLASS_NAME.key() -> payloadClass, + HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key() -> "true", + DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false" + ) + } + + /** + * Helper method to create parameters for tests without precombine + */ + private def createParametersWithoutPrecombine(): Map[String, String] = { + createBaseParameters() ++ Map( + DataSourceWriteOptions.PAYLOAD_CLASS_NAME.key() -> "org.apache.hudi.common.model.OverwriteWithLatestAvroPayload", + HoodieWriteConfig.COMBINE_BEFORE_INSERT.key() -> "false", + DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false" + ) + } + + @Test + def testNullPrecombineFieldThrowsClearError(): Unit = { + val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1")) + val parameters = createParametersWithPrecombine() + + val exception = try { + // Attempt to write which will trigger HoodieCreateRecordUtils + df.write + .format("hudi") + .options(parameters) + .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME) + .option("hoodie.table.name", TEST_TABLE_NAME) + .option("path", TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine") + .mode("overwrite") + .save() + null + } catch { + case e: SparkException => + getRootCause(e) match { + case iae: IllegalArgumentException => iae + case other => other + } + case e: IllegalArgumentException => e + case e: Exception => + getRootCause(e) match { + case iae: IllegalArgumentException => iae + case _ => throw e + } + } + + assertNotNull(exception, "Expected IllegalArgumentException for null precombine field") + assertTrue(exception.isInstanceOf[IllegalArgumentException], + s"Expected IllegalArgumentException but got ${exception.getClass.getName}") + assertTrue(exception.getMessage.contains("has null value for record key"), + s"Exception message should mention null value for record key. Actual: ${exception.getMessage}") + assertTrue(exception.getMessage.contains("Please ensure all records have non-null values for the ordering field"), + s"Exception message should provide guidance. Actual: ${exception.getMessage}") + assertTrue(exception.getMessage.contains("OverwriteWithLatestAvroPayload"), + s"Exception message should suggest alternative payload class. Actual: ${exception.getMessage}") + } + + @Test + def testValidPrecombineFieldSucceeds(): Unit = { + val df = createTestDataFrame(Row("id1", "Alice", 25, 1000L, "par1")) + val parameters = createParametersWithPrecombine() + + // Should not throw exception + df.write + .format("hudi") + .options(parameters) + .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME) + .option("hoodie.table.name", TEST_TABLE_NAME) + .option("path", TestHoodieCreateRecordUtils.tempDir + "/test_valid_precombine") + .mode("overwrite") + .save() + + // Verify data was written + val result = TestHoodieCreateRecordUtils.spark.read + .format("hudi") + .load(TestHoodieCreateRecordUtils.tempDir + "/test_valid_precombine") + assertTrue(result.count() > 0, "Data should have been written successfully") + } + + @Test + def testNullPrecombineFieldErrorContainsRecordKey(): Unit = { + val testRecordKey = "test_key_123" + val df = createTestDataFrame(Row(testRecordKey, "Bob", 30, null, "par2")) + val parameters = createParametersWithPrecombine() + + val exception = try { + df.write + .format("hudi") + .options(parameters) + .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME) + .option("hoodie.table.name", TEST_TABLE_NAME) + .option("path", TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine_key") + .mode("overwrite") + .save() + null + } catch { + case e: Exception => + getRootCause(e) match { + case iae: IllegalArgumentException => iae + case _ => throw e + } + } + + assertNotNull(exception) + assertTrue(exception.getMessage.contains(testRecordKey), + s"Exception message should contain the record key '$testRecordKey' to help identify the problematic record. Actual: ${exception.getMessage}") + } + + @Test + def testNullPrecombineFieldWithOverwritePayloadSucceeds(): Unit = { + // OverwriteWithLatestAvroPayload should allow null precombine values + val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1")) + val parameters = createParametersWithPrecombine( + payloadClass = "org.apache.hudi.common.model.OverwriteWithLatestAvroPayload") + + // Should not throw exception - OverwriteWithLatestAvroPayload doesn't require ordering values + df.write + .format("hudi") + .options(parameters) + .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME) + .option("hoodie.table.name", TEST_TABLE_NAME) + .option("path", TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine_overwrite") + .mode("overwrite") + .save() + + // Verify data was written + val result = TestHoodieCreateRecordUtils.spark.read + .format("hudi") + .load(TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine_overwrite") + assertTrue(result.count() > 0, "Data should have been written successfully with null precombine using OverwriteWithLatestAvroPayload") + } + + @Test + def testNullPrecombineFieldWithCommitTimeOrderingSucceeds(): Unit = { + // COMMIT_TIME_ORDERING merge mode should allow null precombine values + val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1")) + val parameters = createBaseParameters() ++ Map( + DataSourceWriteOptions.PRECOMBINE_FIELD.key() -> PRECOMBINE_FIELD, + HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key() -> "true", + DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false", + DataSourceWriteOptions.RECORD_MERGE_MODE.key() -> RecordMergeMode.COMMIT_TIME_ORDERING.name() + ) + + // Should not throw exception - COMMIT_TIME_ORDERING doesn't require ordering values + df.write + .format("hudi") + .options(parameters) + .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME) + .option("hoodie.table.name", TEST_TABLE_NAME) + .option("path", TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine_commit_time") + .mode("overwrite") + .save() + + // Verify data was written + val result = TestHoodieCreateRecordUtils.spark.read + .format("hudi") + .load(TestHoodieCreateRecordUtils.tempDir + "/test_null_precombine_commit_time") + assertTrue(result.count() > 0, "Data should have been written successfully with null precombine using COMMIT_TIME_ORDERING") + } +} + +object TestHoodieCreateRecordUtils { + var spark: SparkSession = _ + var tempDir: String = _ + + @BeforeAll + def setupSpark(): Unit = { + tempDir = java.nio.file.Files.createTempDirectory("hudi_test_").toFile.getAbsolutePath + spark = SparkSession.builder() + .appName("TestHoodieCreateRecordUtils") + .master("local[2]") + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.sql.shuffle.partitions", "1") + .config("spark.sql.extensions", "org.apache.spark.sql.hudi.HoodieSparkSessionExtension") + .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.hudi.catalog.HoodieCatalog") + .getOrCreate() + } + + @AfterAll + def teardownSpark(): Unit = { + if (spark != null) { + spark.stop() + } + // Clean up temp directory + if (tempDir != null) { + org.apache.commons.io.FileUtils.deleteQuietly(new java.io.File(tempDir)) + } + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala index 15f0b5d571b12..8968caaf348f0 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala @@ -23,7 +23,7 @@ import org.apache.hudi.common.config.{HoodieConfig, HoodieMetadataConfig, Record import org.apache.hudi.common.model.{DefaultHoodieRecordPayload, HoodieFileFormat, HoodieRecord, HoodieRecordPayload, HoodieReplaceCommitMetadata, HoodieTableType, WriteOperationType} import org.apache.hudi.common.schema.HoodieSchema import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient, TableSchemaResolver} -import org.apache.hudi.common.table.timeline.TimelineUtils +import org.apache.hudi.common.table.timeline.{HoodieTimeline, TimelineUtils} import org.apache.hudi.common.testutils.HoodieTestDataGenerator import org.apache.hudi.config.{HoodieBootstrapConfig, HoodieIndexConfig, HoodieWriteConfig} import org.apache.hudi.exception.{HoodieException, SchemaCompatibilityException} @@ -389,6 +389,39 @@ def testBulkInsertForDropPartitionColumn(): Unit = { } } + /** + * Regression test for MOR row-writer bulk_insert commit action. + * + * The writer receives table type through the datasource option + * hoodie.datasource.write.table.type. mergeParamsAndGetHoodieConfig must also + * propagate it to hoodie.table.type, because HoodieWriteConfig#getTableType + * reads the table-config key when row-writer bulk_insert chooses the commit action. + */ + @Test + def testMorRowWriterBulkInsertUsesDeltaCommitAction(): Unit = { + val fooTableModifier = commonTableModifier + .updated("hoodie.bulkinsert.shuffle.parallelism", "4") + .updated(DataSourceWriteOptions.TABLE_TYPE.key, DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL) + .updated(DataSourceWriteOptions.OPERATION.key, DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL) + .updated(DataSourceWriteOptions.ENABLE_ROW_WRITER.key, "true") + // Keep the timeline focused on the write instant; otherwise compaction instants can obscure + // the commit action selected by the row-writer bulk_insert path. + .updated(DataSourceWriteOptions.ASYNC_COMPACT_ENABLE.key, "true") + + val schema = DataSourceTestUtils.getStructTypeExampleSchema + val structType = HoodieSchemaConversionUtils.convertHoodieSchemaToStructType(schema) + val records = DataSourceTestUtils.generateRandomRows(100) + val recordsSeq = convertRowListToSeq(records) + val df = spark.createDataFrame(sc.parallelize(recordsSeq), structType) + + HoodieSparkSqlWriter.write(sqlContext, SaveMode.Append, fooTableModifier, df) + + val metaClient = createMetaClient(spark, tempBasePath) + assertEquals(HoodieTableType.MERGE_ON_READ, metaClient.getTableConfig.getTableType) + val lastCompletedWrite = metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().lastInstant().get() + assertEquals(HoodieTimeline.DELTA_COMMIT_ACTION, lastCompletedWrite.getAction) + } + /** * Test case for insert dataset without ordering fields. */ diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestSparkFilterHelper.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestSparkFilterHelper.scala index 775704241de2b..6e26b642bb8c3 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestSparkFilterHelper.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestSparkFilterHelper.scala @@ -28,12 +28,22 @@ import org.apache.spark.sql.catalyst.expressions._ import org.apache.spark.sql.functions._ import org.apache.spark.sql.types._ import org.apache.spark.unsafe.types.UTF8String -import org.junit.jupiter.api.{Assertions, Test} +import org.junit.jupiter.api.{AfterEach, Assertions, BeforeEach, Test} import scala.collection.JavaConverters._ class TestSparkFilterHelper extends HoodieSparkClientTestHarness with SparkAdapterSupport { + @BeforeEach + def setUp(): Unit = { + initSparkContexts() + } + + @AfterEach + def tearDown(): Unit = { + cleanupSparkContexts() + } + @Test def testConvertInExpression(): Unit = { val filterExpr = sparkAdapter.translateFilter( diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala index d4eae9e79d74d..6098f4719598e 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala @@ -231,7 +231,8 @@ class TestHoodieFileGroupReaderOnSpark extends TestHoodieFileGroupReaderBase[Int def testCustomDelete(useFgReader: String, tableType: String, positionUsed: String, - mergeMode: String): Unit = { + mergeMode: String, + markerFromTableConfigOnly: String): Unit = { val payloadClass = "org.apache.hudi.common.table.read.CustomPayloadForTesting" val fgReaderOpts: Map[String, String] = Map( HoodieWriteConfig.MERGE_SMALL_FILE_GROUP_CANDIDATES_LIMIT.key -> "0", @@ -241,13 +242,17 @@ class TestHoodieFileGroupReaderOnSpark extends TestHoodieFileGroupReaderBase[Int ) val deleteOpts: Map[String, String] = Map( DELETE_KEY -> "op", DELETE_MARKER -> "d") - val readOpts = if (mergeMode.equals("CUSTOM")) { - fgReaderOpts ++ deleteOpts ++ Map( - HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key -> payloadClass) + val payloadOpts = if (mergeMode.equals("CUSTOM")) { + Map(HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key -> payloadClass) } else { - fgReaderOpts ++ deleteOpts + Map.empty[String, String] } - val opts = readOpts + val opts = fgReaderOpts ++ deleteOpts ++ payloadOpts + // The write persists the marker on the table under the record-merge property prefix. When the query does + // not restate the delete options, the table config is the only place the reader can learn about them - + // which is what a query that just loads the path looks like. + val tableConfigOnly = markerFromTableConfigOnly.equals("true") + val readOpts = if (tableConfigOnly) fgReaderOpts ++ payloadOpts else opts val columns = Seq("ts", "key", "rider", "driver", "fare", "op") val data = Seq( @@ -269,6 +274,11 @@ class TestHoodieFileGroupReaderOnSpark extends TestHoodieFileGroupReaderBase[Int val metaClient = HoodieTableMetaClient .builder().setConf(getStorageConf).setBasePath(getBasePath).build assertEquals((1, 0), getFileCount(metaClient, getBasePath)) + if (tableConfigOnly) { + assertTrue(metaClient.getTableConfig.getProps.containsKey( + HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX + DELETE_KEY), + "the delete marker must be persisted on the table, otherwise the read side has nothing to pick up") + } // Delete using delete markers. val updateData = Seq( @@ -456,12 +466,15 @@ class TestHoodieFileGroupReaderOnSpark extends TestHoodieFileGroupReaderBase[Int object TestHoodieFileGroupReaderOnSpark { def customDeleteTestParams(): java.util.List[Arguments] = { java.util.Arrays.asList( - Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING"), - Arguments.of("true", "MERGE_ON_READ", "true", "EVENT_TIME_ORDERING"), - Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING"), - Arguments.of("true", "MERGE_ON_READ", "true", "COMMIT_TIME_ORDERING"), - Arguments.of("true", "MERGE_ON_READ", "false", "CUSTOM"), - Arguments.of("true", "MERGE_ON_READ", "true", "CUSTOM")) + Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING", "false"), + Arguments.of("true", "MERGE_ON_READ", "true", "EVENT_TIME_ORDERING", "false"), + Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING", "false"), + Arguments.of("true", "MERGE_ON_READ", "true", "COMMIT_TIME_ORDERING", "false"), + Arguments.of("true", "MERGE_ON_READ", "false", "CUSTOM", "false"), + Arguments.of("true", "MERGE_ON_READ", "true", "CUSTOM", "false"), + // The query does not restate the delete options, so the table config is the reader's only source. + Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING", "true"), + Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING", "true")) } def getFileCount(metaClient: HoodieTableMetaClient, basePath: String): (Long, Long) = { diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala index 106c1f09a7ae2..599a105e6d001 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala @@ -2215,6 +2215,40 @@ class TestCOWDataSource extends HoodieSparkClientTestBase with ScalaAssertionSup assertEquals(count, 0) } + @Test + def testReadOfAnEmptyTableWithUserSuppliedSchema(): Unit = { + val (writeOpts, _) = getWriterReaderOpts(HoodieRecordType.AVRO) + + // Insert + then delete the only completed commit so the table has no resolvable schema. + val records = recordsToStrings(dataGen.generateInserts("000", 100)).asScala.toList + val inputDF = spark.read.json(spark.sparkContext.parallelize(records, 2)) + inputDF.write.format("hudi") + .options(writeOpts) + .option(DataSourceWriteOptions.OPERATION.key, DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Overwrite) + .save(basePath) + + val fileStatuses = storage.listDirectEntries( + new StoragePath(basePath + StoragePath.SEPARATOR + HoodieTableMetaClient.METAFOLDER_NAME + + StoragePath.SEPARATOR + HoodieTableMetaClient.TIMELINEFOLDER_NAME), + new StoragePathFilter { + override def accept(path: StoragePath): Boolean = { + path.getName.endsWith(HoodieTimeline.COMMIT_ACTION) + } + }) + storage.deleteFile(fileStatuses.get(0).getPath) + + // spark.read.schema(...) triggers Spark's SchemaRelationProvider path which calls the + // 3-arg DefaultSource.createRelation overload directly. Without the catch on that + // overload, this would fail with HoodieSchemaNotFoundException. + val userSchema = inputDF.schema + val df = spark.read.schema(userSchema).format("hudi").load(basePath) + assertEquals(0, df.count()) + // The caller-supplied schema must be preserved on the EmptyRelation so subsequent query + // analysis (e.g. column resolution) sees the user-known columns. + assertEquals(userSchema, df.schema) + } + /** * Test incremental queries and time travel queries with event time ordering. * diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala index abce02568633b..b281b83bf6c54 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala @@ -991,7 +991,7 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { // read_blob() on INLINE rows under DESCRIPTOR mode is unsupported by design: DESCRIPTOR // is metadata-only and the synthesized reference is an internal pointer into the .lance // file's storage layout, not user-facing metadata. BatchedBlobReader must throw with a - // message that names both INLINE and DESCRIPTOR so the failure is actionable. + // message that names INLINE, DESCRIPTOR and the CONTENT fix so the failure is actionable val viewName = s"${tableName}_view" spark.read.format("hudi") .option(modeKey, "DESCRIPTOR") @@ -1004,8 +1004,8 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { }) val msgChain = Iterator.iterate[Throwable](ex)(_.getCause).takeWhile(_ != null) .flatMap(t => Option(t.getMessage)).mkString(" | ") - assertTrue(msgChain.contains("INLINE") && msgChain.contains("DESCRIPTOR"), - s"error must mention INLINE and DESCRIPTOR; got: $msgChain") + assertTrue(Seq("INLINE", "DESCRIPTOR", "CONTENT").forall(msgChain.contains), + s"error must name INLINE, DESCRIPTOR and the CONTENT fix; got: $msgChain") } /** @@ -1303,12 +1303,22 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { /** * Compaction must preserve INLINE blob bytes under the DESCRIPTOR default. MOR compaction reads - * the base file via {@link HoodieSparkLanceReader}, which hard-pins CONTENT regardless of the - * user-facing {@code hoodie.read.blob.inline.mode}. If that pin were to honor the default - * (DESCRIPTOR), compaction would read null {@code data} and rewrite a base file without bytes, - * silently corrupting untouched rows. This test inserts INLINE blobs, upserts a subset to force - * compaction, and asserts that touched rows carry the new bytes while untouched rows retain the - * originals. + * the base file through the internal write-side reader stack + * SparkReaderContextFactory -> SparkFileFormatInternalRowReaderContext -> SparkLanceReaderBase, + * not through HoodieSparkLanceReader (that reader serves LanceUtils stats/key reads, + * bloom-index key lookups, and legacy HoodieWriteMergeHandle merges, with its own CONTENT pin). + * SparkLanceReaderBase honors {@code hoodie.read.blob.inline.mode}, whose DESCRIPTOR default + * would read null {@code data} and rewrite a base file without bytes, silently corrupting + * untouched rows. Correctness now relies on SparkReaderContextFactory pinning + * {@code hoodie.read.blob.inline.mode=CONTENT} in the broadcast conf used by all internal reads. + * User-facing queries are unaffected because they build their own conf. + * + * The test forces all rows into a single file group (coalesce(1) plus bulk-insert/insert + * shuffle parallelism 1) so the untouched rows genuinely go through the compaction rewrite. + * Without that, untouched rows land in log-free file groups that compaction never rewrites, + * which is how the original bug (#19232) stayed masked. This test inserts INLINE blobs, + * upserts a subset to force compaction, and asserts that touched rows carry the new bytes while + * untouched rows retain the originals. */ @Test def testBlobInlineCompactionRoundTrip(): Unit = { @@ -1332,7 +1342,7 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { def asInlineDf(idToBytes: Seq[(Int, Array[Byte])]): DataFrame = { val rawDf = idToBytes.toDF("id", "bytes") .select($"id", BlobTestHelpers.inlineBlobStructCol("payload", $"bytes")) - spark.createDataFrame(rawDf.rdd, canonicalSchema) + spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1) } // First commit: bulk_insert ids 0..5 with the initial pattern. Lands in a base file. @@ -1340,7 +1350,9 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { asInlineDf(initialPayloads.zipWithIndex.map { case (b, i) => (i, b) }), saveMode = SaveMode.Overwrite, operation = Some("bulk_insert"), - extraOptions = Map(PRECOMBINE_FIELD.key() -> "id")) + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id", + "hoodie.bulkinsert.shuffle.parallelism" -> "1", + "hoodie.insert.shuffle.parallelism" -> "1")) assertLanceBlobEncoding(tablePath) @@ -1366,9 +1378,9 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { .getInstants.asScala val deltaCommits = completedInstants.filter(_.getAction == "deltacommit") assertTrue(deltaCommits.nonEmpty, - "Upsert must have written a deltacommit on MOR — without log files the compaction " + + "Upsert must have written a deltacommit on MOR -- without log files the compaction " + "round-trip below would be a no-op and the test would silently pass even if the " + - "CONTENT-pin in HoodieSparkLanceReader were broken.") + "CONTENT-pin in SparkReaderContextFactory were broken.") val compactionCommits = completedInstants.filter(_.getAction == "commit") assertTrue(compactionCommits.nonEmpty, "Compaction commit should be present after upsert") @@ -1386,6 +1398,13 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { val fsView = viewManager.getFileSystemView(metaClient) try { fsView.loadAllPartitions() + // Pin the single-file-group invariant the whole test rests on. If rows ever spread across + // multiple file groups, the untouched ids could sit in log-free groups that compaction never + // rewrites, and the round-trip below would pass without exercising the regression (#19232). + assertEquals(1L, fsView.getAllFileGroups("").count(), + s"All rows must land in exactly one file group (coalesce(1) + shuffle parallelism 1) " + + s"at $tablePath; otherwise untouched ids may sit in log-free file groups that " + + s"compaction never rewrites and the regression is not exercised") val anyHadLogs = fsView.getAllFileGroups("").iterator().asScala.exists { fg => fg.getAllFileSlices.iterator().asScala.exists(_.hasLogFiles) } @@ -1422,11 +1441,34 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { s"DESCRIPTOR default should populate reference on plain read (id=$id)") } - // read_blob() under CONTENT mode is what we use to verify the post-compaction bytes - // because read_blob() on INLINE rows throws under the DESCRIPTOR default. The bytes can - // only come back if HoodieSparkLanceReader's CONTENT pin held during the compactor's - // base-file read — otherwise untouched ids 3..5 would have been rewritten with null - // `data` and CONTENT-mode read would surface that. + // Byte check via a plain projection under CONTENT. A broken rewrite could produce two shapes: + // - {INLINE, null data, populated reference}: a DESCRIPTOR-mode read leaked into the write + // path. HoodieSparkLanceWriter.validateBlobRow rejects this shape and fails the compaction + // itself, so it can never reach the base file. + // - {INLINE, null data, null reference}: legitimate for an empty inline blob, so the writer + // guard lets it through. If a rewrite dropped the bytes this way, only the null-data + // assertion below would catch it. + // The CONTENT pin on internal reads is unit-tested in TestSparkReaderContextFactory. + val contentRows = spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .select($"id", $"payload") + .orderBy($"id") + .collect() + assertEquals(numRows, contentRows.length) + contentRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val payload = row.getStruct(row.fieldIndex("payload")) + assertFalse(payload.isNullAt(payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"null data under CONTENT: the compaction rewrite dropped the bytes (id=$id)") + assertArrayEquals(expected(id), + payload.getAs[Array[Byte]](payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"INLINE data bytes must survive the compaction rewrite (id=$id)") + } + + // read_blob() under CONTENT verifies the same bytes through the SQL expression path + // (read_blob() on INLINE rows throws under the DESCRIPTOR default, so CONTENT is + // required here). val viewName = s"${tableName}_view" spark.read.format("hudi") .option("hoodie.read.blob.inline.mode", "CONTENT") @@ -1443,6 +1485,313 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { } } + /** + * Clustering must preserve INLINE blob bytes under the DESCRIPTOR default. Clustering rewrites + * ALL rows through MultipleSparkJobExecutionStrategy.readRecordsForGroupAsRow, which reads base + * files through the same internal write-side reader context as compaction + * (SparkReaderContextFactory -> SparkFileFormatInternalRowReaderContext -> SparkLanceReaderBase). + * Before SparkReaderContextFactory pinned {@code hoodie.read.blob.inline.mode=CONTENT} for + * internal reads, the first clustering of a Lance table with INLINE blobs read null {@code data} + * (the DESCRIPTOR default) and rewrote every row's blob with null bytes, silently losing all + * blob content (#19232). This test bulk-inserts INLINE blobs, triggers inline clustering, + * asserts a replacecommit actually completed, and verifies every row's bytes survived. + */ + @Test + def testBlobInlineClusteringRoundTrip(): Unit = { + val tableType = HoodieTableType.COPY_ON_WRITE + val tableName = "test_lance_blob_inline_cluster_cow" + val tablePath = s"$basePath/$tableName" + + val payloadLen = 1024 + val numRows = 5 + val expectedPayloads: Seq[Array[Byte]] = (0 until numRows).map { i => + (0 until payloadLen).map(j => ((i + j) % 256).toByte).toArray + } + val sparkSess = spark + import sparkSess.implicits._ + + val canonicalSchema = StructType(Seq( + StructField("id", IntegerType, nullable = false), + StructField("payload", BlobType().asInstanceOf[StructType], nullable = true, + BlobTestHelpers.blobMetadata) + )) + def asInlineDf(idToBytes: Seq[(Int, Array[Byte])]): DataFrame = { + val rawDf = idToBytes.toDF("id", "bytes") + .select($"id", BlobTestHelpers.inlineBlobStructCol("payload", $"bytes")) + spark.createDataFrame(rawDf.rdd, canonicalSchema) + } + + // First commit: bulk_insert ids 0..4 with the initial pattern into a base file. + writeDataframe(tableType, tableName, tablePath, + asInlineDf(expectedPayloads.zipWithIndex.map { case (b, i) => (i, b) }), + saveMode = SaveMode.Overwrite, + operation = Some("bulk_insert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id")) + + assertLanceBlobEncoding(tablePath) + + // Snapshot the first commit's base file(s); the disjointness check below uses them to prove + // clustering rewrote (not skipped) them, else the byte checks pass on stale bytes (#19232). + val metaClientAfterFirst = HoodieTableMetaClient.builder() + .setConf(HoodieTestUtils.getDefaultStorageConf) + .setBasePath(tablePath) + .build() + val preClusterBaseFiles = latestBaseFileNames(metaClientAfterFirst, tablePath) + assertFalse(preClusterBaseFiles.isEmpty, "First commit should have written at least one base file") + + // Second commit: a small bulk_insert that trips inline clustering (max.commits=1). Clustering + // rewrites the existing base file's rows through readRecordsForGroupAsRow, which must read the + // INLINE bytes as CONTENT, not the DESCRIPTOR default, or every rewritten row loses its bytes. + val extraPayloads = (numRows until numRows + 2).map { i => + (i, (0 until payloadLen).map(j => ((i + j) % 256).toByte).toArray) + } + writeDataframe(tableType, tableName, tablePath, + asInlineDf(extraPayloads), + operation = Some("bulk_insert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id", + "hoodie.clustering.inline" -> "true", + "hoodie.clustering.inline.max.commits" -> "1")) + + // Require a COMPLETED replacecommit. getLastClusteringInstant filters by action only, so a + // REQUESTED/INFLIGHT instant satisfies isPresent; isCompleted confirms the rewrite finished. + val metaClient = HoodieTableMetaClient.builder() + .setConf(HoodieTestUtils.getDefaultStorageConf) + .setBasePath(tablePath) + .build() + val lastClustering = metaClient.getActiveTimeline.getLastClusteringInstant + assertTrue(lastClustering.isPresent && lastClustering.get.isCompleted, + "A COMPLETED clustering (replacecommit) instant must exist after inline clustering; without a " + + "completed rewrite the blob-loss regression below could not be exercised") + + // ...and that it rewrote the base file(s) into new ones. Disjoint sets prove the rewrite ran + // instead of the byte checks reading untouched originals (#19232). + val postClusterBaseFiles = latestBaseFileNames(metaClient, tablePath) + assertTrue(preClusterBaseFiles.intersect(postClusterBaseFiles).isEmpty, + s"Post-clustering base files must be disjoint from the pre-clustering base file(s), proving the " + + s"rewrite ran (pre=$preClusterBaseFiles, post=$postClusterBaseFiles)") + + // Read back in CONTENT mode and assert every row's bytes survived the clustering rewrite. + val allExpected: Map[Int, Array[Byte]] = + (expectedPayloads.zipWithIndex.map { case (b, i) => i -> b } ++ extraPayloads).toMap + + // Byte check via a plain projection under CONTENT. As in the compaction test: a DESCRIPTOR + // leak already fails the rewrite in HoodieSparkLanceWriter.validateBlobRow, so what this + // catches is the guard-allowed empty-inline shape {INLINE, null data, null reference}, + // where dropped bytes would persist silently. + val contentRows = spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .select($"id", $"payload") + .orderBy($"id") + .collect() + assertEquals(allExpected.size, contentRows.length) + contentRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val payload = row.getStruct(row.fieldIndex("payload")) + assertFalse(payload.isNullAt(payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"null data under CONTENT: the clustering rewrite dropped the bytes (id=$id)") + assertArrayEquals(allExpected(id), + payload.getAs[Array[Byte]](payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"INLINE data bytes must survive the clustering rewrite (id=$id)") + } + + val viewName = s"${tableName}_view" + spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .createOrReplaceTempView(viewName) + val materialized = spark.sql( + s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY id").collect() + assertEquals(allExpected.size, materialized.length) + materialized.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val bytes = row.getAs[Array[Byte]]("bytes") + assertArrayEquals(allExpected(id), bytes, + s"read_blob() must return correct bytes post-clustering (id=$id)") + } + } + + /** + * A CoW upsert merge must preserve INLINE blob bytes under the DESCRIPTOR default. + * + * Compaction and clustering (covered above) obtain their reader through + * {@code HoodieEngineContext.getReaderContextFactory}. A CoW upsert takes a different path: it + * rewrites the base file through FileGroupReaderBasedMergeHandle, which resolves its reader + * through {@code getReaderContextFactoryForWrite}. That method branches on the record merger + * type: AvroReaderContextFactory for AVRO, SparkReaderContextFactory for SPARK (the datasource + * default, used here). A DESCRIPTOR leak on this branch would rewrite untouched rows with null + * {@code data}, the same silent loss as #19232. + * + * The test bulk-inserts INLINE blobs into a single file group, upserts a subset, proves the + * merge rewrote the base file, and verifies touched rows carry the new bytes while untouched + * rows keep the originals. + */ + @Test + def testBlobInlineCowUpsertMergeRoundTrip(): Unit = { + val tableType = HoodieTableType.COPY_ON_WRITE + val tableName = "test_lance_blob_inline_upsert_merge_cow" + val tablePath = s"$basePath/$tableName" + + val payloadLen = 1024 + val numRows = 6 + val initialPayloads: Seq[Array[Byte]] = (0 until numRows).map { i => + (0 until payloadLen).map(j => ((i + j) % 256).toByte).toArray + } + val sparkSess = spark + import sparkSess.implicits._ + + val canonicalSchema = StructType(Seq( + StructField("id", IntegerType, nullable = false), + StructField("payload", BlobType().asInstanceOf[StructType], nullable = true, + BlobTestHelpers.blobMetadata) + )) + def asInlineDf(idToBytes: Seq[(Int, Array[Byte])]): DataFrame = { + val rawDf = idToBytes.toDF("id", "bytes") + .select($"id", BlobTestHelpers.inlineBlobStructCol("payload", $"bytes")) + spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1) + } + + // First commit: bulk_insert ids 0..5 into a single base file. A single file group is required + // so the untouched ids 3..5 genuinely pass through the merge rewrite; in their own group the + // upsert would never touch them and the byte checks below would pass vacuously. + writeDataframe(tableType, tableName, tablePath, + asInlineDf(initialPayloads.zipWithIndex.map { case (b, i) => (i, b) }), + saveMode = SaveMode.Overwrite, + operation = Some("bulk_insert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id", + "hoodie.bulkinsert.shuffle.parallelism" -> "1", + "hoodie.insert.shuffle.parallelism" -> "1")) + + assertLanceBlobEncoding(tablePath) + + val metaClientAfterFirst = HoodieTableMetaClient.builder() + .setConf(HoodieTestUtils.getDefaultStorageConf) + .setBasePath(tablePath) + .build() + val preUpsertBaseFiles = latestBaseFileNames(metaClientAfterFirst, tablePath) + assertEquals(1, preUpsertBaseFiles.size, + s"All rows must land in exactly one base file (coalesce(1) + shuffle parallelism 1) at " + + s"$tablePath, got $preUpsertBaseFiles; otherwise untouched ids sit in file groups the " + + s"upsert never rewrites and the merge path is not exercised") + + // Second commit: upsert ids 0..2 with all-0xEE payloads. On CoW this routes every existing + // file-group record through the merge handle's CONTENT-pinned base-file read and rewrite. + val updatedPayloadByte: Byte = 0xEE.toByte + val updatedIds = 0 until 3 + val updatedPayloads = updatedIds.map(i => (i, Array.fill[Byte](payloadLen)(updatedPayloadByte))) + writeDataframe(tableType, tableName, tablePath, + asInlineDf(updatedPayloads), + operation = Some("upsert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id")) + + // The upsert must have stayed on the CoW commit path: two completed commits, no deltacommits + // (a deltacommit would mean an append path that never rewrites the base file). + val metaClient = HoodieTableMetaClient.builder() + .setConf(HoodieTestUtils.getDefaultStorageConf) + .setBasePath(tablePath) + .build() + val completedInstants = metaClient.reloadActiveTimeline().filterCompletedInstants() + .getInstants.asScala + assertEquals(2, completedInstants.count(_.getAction == "commit"), + "Expected exactly two completed commits (bulk_insert + upsert) on CoW") + assertTrue(completedInstants.forall(_.getAction != "deltacommit"), + "CoW upsert must not write deltacommits; the merge rewrite would not be exercised") + + // The merge must also have replaced the base file: a single new name, disjoint from the + // pre-upsert one. If the old name were still the latest, the untouched ids were never + // merged and the byte checks below would read stale bytes. + val postUpsertBaseFiles = latestBaseFileNames(metaClient, tablePath) + assertEquals(1, postUpsertBaseFiles.size, + s"Upsert must keep all rows in one file group, got $postUpsertBaseFiles") + assertTrue(preUpsertBaseFiles.intersect(postUpsertBaseFiles).isEmpty, + s"Post-upsert base file must differ from the pre-upsert one, proving the merge rewrote it " + + s"(pre=$preUpsertBaseFiles, post=$postUpsertBaseFiles)") + + val expected: Map[Int, Array[Byte]] = ( + updatedIds.map(i => i -> Array.fill[Byte](payloadLen)(updatedPayloadByte)) ++ + (updatedIds.length until numRows).map(i => i -> initialPayloads(i)) + ).toMap + + // Plain read yields the DESCRIPTOR shape, confirming the user-facing default end-to-end. + val readRows = spark.read.format("hudi") + .load(tablePath) + .select($"id", $"payload") + .orderBy($"id") + .collect() + assertEquals(numRows, readRows.length) + readRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val payload = row.getStruct(row.fieldIndex("payload")) + assertEquals(HoodieSchema.Blob.INLINE, + payload.getString(payload.fieldIndex(HoodieSchema.Blob.TYPE)), + s"Type must remain INLINE post-merge (id=$id)") + assertTrue(payload.isNullAt(payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"DESCRIPTOR default should null `data` on plain read (id=$id)") + assertNotNull(payload.getStruct(payload.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE)), + s"DESCRIPTOR default should populate reference on plain read (id=$id)") + } + + // Byte check via a plain projection under CONTENT. As in the compaction test: a DESCRIPTOR + // leak already fails the merge in HoodieSparkLanceWriter.validateBlobRow, so what this + // catches is the guard-allowed empty-inline shape {INLINE, null data, null reference}, + // where dropped bytes would persist silently. + val contentRows = spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .select($"id", $"payload") + .orderBy($"id") + .collect() + assertEquals(numRows, contentRows.length) + contentRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val payload = row.getStruct(row.fieldIndex("payload")) + assertFalse(payload.isNullAt(payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"null data under CONTENT: the merge rewrite dropped the bytes (id=$id)") + assertArrayEquals(expected(id), + payload.getAs[Array[Byte]](payload.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)), + s"INLINE data bytes must survive the merge rewrite (id=$id)") + } + + // read_blob() under CONTENT verifies the same bytes through the SQL expression path. + val viewName = s"${tableName}_view" + spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .createOrReplaceTempView(viewName) + val materializedRows = spark.sql( + s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY id").collect() + assertEquals(numRows, materializedRows.length) + materializedRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val bytes = row.getAs[Array[Byte]]("bytes") + assertArrayEquals(expected(id), bytes, + s"read_blob() must return correct bytes post-merge (id=$id)") + } + } + + /** + * Latest base file name per file group under {@code tablePath}. Snapshots taken before and + * after a rewriting table service (clustering, CoW upsert merge) are compared for disjointness + * to prove the rewrite actually replaced the base file(s). + */ + private def latestBaseFileNames(mc: HoodieTableMetaClient, tablePath: String): Set[String] = { + val engineContext = new HoodieLocalEngineContext(mc.getStorageConf) + val metadataConfig = HoodieMetadataConfig.newBuilder.build + val viewManager = FileSystemViewManager.createViewManager( + engineContext, metadataConfig, FileSystemViewStorageConfig.newBuilder.build, + HoodieCommonConfig.newBuilder.build, + (m: HoodieTableMetaClient) => mc.getTableFormat + .getMetadataFactory.create(engineContext, m.getStorage, metadataConfig, tablePath)) + val fsView = viewManager.getFileSystemView(mc) + try { + fsView.getLatestBaseFiles("") + .collect(Collectors.toList[org.apache.hudi.common.model.HoodieBaseFile]) + .asScala.map(_.getFileName).toSet + } finally { + fsView.close() + } + } + /** * Shared implementation for OUT_OF_LINE blob tests. Writes rows with external references, * reads them back (optionally with a specific read mode), and asserts the reference survives @@ -1981,6 +2330,253 @@ class TestLanceDataSource extends HoodieSparkClientTestBase { writer.mode(saveMode).save(tablePath) } + + // The small-n blob tests above all read fewer than 512 rows, so they never cross Lance's + // internal BLOB page boundary (512 rows). BLOB reads regress specifically once a single base + // file holds more than 512 rows: a single Lance readAll stream then exports a second BLOB page + // through the Arrow C FFI, which panics in lance-core 4.0.0. These tests size the base file + // above 512 rows (single coalesced file) to exercise the chunked-read work-around. + private val BATCH_SCALE_ROWS = 1000 + private val BATCH_SCALE_PARALLELISM_OPTS = Map( + "hoodie.bulkinsert.shuffle.parallelism" -> "1", + "hoodie.insert.shuffle.parallelism" -> "1") + + /** + * Batch-scale INLINE BLOB read regression (HUDI-UNSTRUCTURED-001). Writes + * {@code BATCH_SCALE_ROWS} inline-blob rows into a single Lance base file and reads them back + * under CONTENT mode, materializing each via {@code read_blob()}. Reproduces the native Lance + * BLOB decoder failure that only surfaces once the read crosses the 512-row batch boundary. + */ + @ParameterizedTest + @EnumSource(value = classOf[HoodieTableType]) + def testBlobInlineContentBatchScale(tableType: HoodieTableType): Unit = { + val tableName = s"test_lance_blob_inline_batch_${tableType.name().toLowerCase}" + val tablePath = s"$basePath/$tableName" + + val payloadLen = 256 + val n = BATCH_SCALE_ROWS + val sparkSess = spark + import sparkSess.implicits._ + // Deterministic per-row payload: row i -> bytes (i + j) % 256, so a row/byte misalignment + // across the batch boundary surfaces as a byte mismatch rather than silently passing. + def payloadFor(i: Int): Array[Byte] = + (0 until payloadLen).map(j => ((i + j) % 256).toByte).toArray + val baseDf = (0 until n).map(i => (i, payloadFor(i))) + .toDF("id", "bytes") + .coalesce(1) + val rawDf = baseDf.select($"id", BlobTestHelpers.inlineBlobStructCol("payload", $"bytes")) + val canonicalSchema = StructType(Seq( + StructField("id", IntegerType, nullable = false), + StructField("payload", BlobType().asInstanceOf[StructType], nullable = true, + BlobTestHelpers.blobMetadata) + )) + val df = spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1) + + writeDataframe(tableType, tableName, tablePath, df, saveMode = SaveMode.Overwrite, + operation = Some("bulk_insert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id") ++ BATCH_SCALE_PARALLELISM_OPTS) + + assertLanceBlobEncoding(tablePath) + assertSingleLanceBaseFileSpansMultipleBatches(tablePath) + + val viewName = s"${tableName}_view" + spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .createOrReplaceTempView(viewName) + val materialized = spark.sql( + s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY id").collect() + assertEquals(n, materialized.length, "row count mismatch after read_blob at batch scale") + materialized.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val bytes = row.getAs[Array[Byte]]("bytes") + assertArrayEquals(payloadFor(id), bytes, s"inline read_blob() bytes mismatch for id=$id") + } + } + + /** + * Comprehensive batch-scale VECTOR + OUT_OF_LINE BLOB regression (HUDI-UNSTRUCTURED-002 and 003). + * Parameterized over table type and {@code n} in {100, 1000}; n=1000 crosses the 512-row BLOB page + * boundary that trips the lance-core FFI panic without the chunked-read fix. Writes one Lance base + * file with a VECTOR(32) and an OUT_OF_LINE BLOB column, then validates across DataFrame and SQL + * reads: row count; exact vector values (incl. rows straddling the boundary); top-k IDs via + * {@code hudi_vector_search}; column projection and id-range filtering; {@code read_blob()} bytes + + * SHA-256 (full and filtered); and DESCRIPTOR-mode reference pass-through. Default Lance read + * allocator (256MB) suffices — no non-default settings required. + */ + @ParameterizedTest + @MethodSource(Array("vectorBlobBatchParams")) + def testVectorAndBlobBatchScale(tableType: HoodieTableType, n: Int): Unit = { + val tableName = s"test_lance_vec_blob_batch_${n}_${tableType.name().toLowerCase}" + val tablePath = s"$basePath/$tableName" + + val dim = 32 + val payloadLen = 512 + val externalDir = Files.createDirectories( + Paths.get(s"$basePath/_vec_blob_ext_${n}_${tableType.name().toLowerCase}")) + val extPath = BlobTestHelpers.createTestFile(externalDir, "vec_blob.bin", n * payloadLen) + + val sparkSess = spark + import sparkSess.implicits._ + // Deterministic, strictly monotonic-by-id vector: row i -> [(i*dim+j)/1000f]. Monotonicity + // makes nearest-neighbor ordering predictable; per-row distinctness makes a row/value + // misalignment across the batch boundary surface as a value (or top-k) mismatch. + def vectorFor(i: Int): Array[Float] = (0 until dim).map(j => (i * dim + j) / 1000.0f).toArray + // Deterministic blob bytes for row i: (i*payloadLen + k) % 256, matching assertBytesContent. + def expectedBlob(i: Int): Array[Byte] = + (0 until payloadLen).map(k => ((i * payloadLen + k) % 256).toByte).toArray + def sha256(bytes: Array[Byte]): Seq[Byte] = + java.security.MessageDigest.getInstance("SHA-256").digest(bytes).toSeq + + val baseDf = (0 until n) + .map(i => (i, vectorFor(i), extPath, (i.toLong * payloadLen), payloadLen.toLong)) + .toDF("id", "embedding", "path", "offset", "length") + .coalesce(1) + val rawDf = baseDf.select($"id", $"embedding", + BlobTestHelpers.blobStructCol("payload", $"path", $"offset", $"length")) + val vectorMeta = new MetadataBuilder() + .putString(HoodieSchema.TYPE_METADATA_FIELD, s"VECTOR($dim)").build() + val canonicalSchema = StructType(Seq( + StructField("id", IntegerType, nullable = false), + StructField("embedding", ArrayType(FloatType, containsNull = false), nullable = false, + vectorMeta), + StructField("payload", BlobType().asInstanceOf[StructType], nullable = true, + BlobTestHelpers.blobMetadata) + )) + val df = spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1) + + writeDataframe(tableType, tableName, tablePath, df, saveMode = SaveMode.Overwrite, + operation = Some("bulk_insert"), + extraOptions = Map(PRECOMBINE_FIELD.key() -> "id") ++ BATCH_SCALE_PARALLELISM_OPTS) + + if (n > 512) { + assertSingleLanceBaseFileSpansMultipleBatches(tablePath) + } + + // --- Vectors (DataFrame path): row count + exact values on a sample straddling the boundary. + val vecRows = spark.read.format("hudi").load(tablePath) + .select($"id", $"embedding").orderBy($"id").collect() + assertEquals(n, vecRows.length, "vector row count mismatch at batch scale") + val sampleIds = (Seq(0, 1, n / 2, n - 1) ++ (if (n > 512) Seq(511, 512, 513) else Seq.empty)) + .filter(i => i >= 0 && i < n).distinct + val vecById = vecRows.map(r => r.getInt(r.fieldIndex("id")) -> r).toMap + sampleIds.foreach { id => + val emb = vecById(id).getSeq[Float](vecById(id).fieldIndex("embedding")).toArray + assertEquals(dim, emb.length, s"vector dim mismatch for id=$id") + val expected = vectorFor(id) + (0 until dim).foreach { j => + assertEquals(expected(j), emb(j), 1e-6f, s"vector value mismatch id=$id j=$j") + } + } + + // --- Top-k vector search IDs: query near row q nudged toward higher ids by a small delta so + // the L2 ordering is strict (q, q+1, q-1). hudi_vector_search must return those exact IDs. + val tkView = s"${tableName}_tk" + spark.read.format("hudi").load(tablePath) + .select("id", "embedding").createOrReplaceTempView(tkView) + val q = n / 2 + val delta = 0.005f + val queryLiteral = vectorFor(q).map(v => (v + delta).toDouble).mkString(", ") + val topk = spark.sql( + s"""SELECT id, _hudi_distance + |FROM hudi_vector_search('$tkView', 'embedding', ARRAY($queryLiteral), 3, 'l2') + |ORDER BY _hudi_distance""".stripMargin).collect() + val topkIds = topk.map(_.getInt(0)).toSeq + assertEquals(Seq(q, q + 1, q - 1), topkIds, + s"top-3 vector-search IDs mismatch for query near id=$q") + + // --- Projection (id only) and predicate filter (id range) correctness. + val idOnly = spark.read.format("hudi").load(tablePath).select("id") + assertEquals(1, idOnly.schema.fields.length, "projection should yield a single column") + // collect() (not count()) so the id column is actually projected/read; a count() would push an + // empty required schema down the scan, a separate code path not under test here. + val idOnlyVals = idOnly.collect().map(_.getInt(0)).toSet + assertEquals((0 until n).toSet, idOnlyVals, "projected id set mismatch") + // For n=1000 choose a range that straddles the 512-row batch boundary (400..620). + val lo = if (n > 512) 400 else n / 4 + val hi = math.min(n, lo + (if (n > 512) 220 else 30)) + val filteredIds = spark.read.format("hudi").load(tablePath) + .where(s"id >= $lo AND id < $hi").select("id").orderBy("id") + .collect().map(_.getInt(0)).toSeq + assertEquals((lo until hi).toSeq, filteredIds, "filtered id range mismatch") + + // --- Blobs (SQL path, CONTENT): full-scan read_blob byte content + SHA-256 round-trip. + val viewName = s"${tableName}_view" + spark.read.format("hudi") + .option("hoodie.read.blob.inline.mode", "CONTENT") + .load(tablePath) + .createOrReplaceTempView(viewName) + val blobRows = spark.sql( + s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY id").collect() + assertEquals(n, blobRows.length, "blob row count mismatch at batch scale") + blobRows.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + val bytes = row.getAs[Array[Byte]]("bytes") + assertEquals(payloadLen, bytes.length, s"blob length mismatch for id=$id") + BlobTestHelpers.assertBytesContent(bytes, expectedOffset = id * payloadLen) + } + // SHA-256 round-trip on a sample (faithful to the AC's SHA256 requirement). + val blobById = blobRows.map(r => r.getInt(r.fieldIndex("id")) -> r.getAs[Array[Byte]]("bytes")).toMap + sampleIds.foreach { id => + assertEquals(sha256(expectedBlob(id)), sha256(blobById(id)), + s"blob SHA-256 mismatch for id=$id") + } + + // --- Blobs under a filter (SQL path, CONTENT): read_blob restricted to an id range. + val filteredBlobs = spark.sql( + s"SELECT id, read_blob(payload) AS bytes FROM $viewName WHERE id >= $lo AND id < $hi ORDER BY id") + .collect() + assertEquals(hi - lo, filteredBlobs.length, "filtered read_blob count mismatch") + filteredBlobs.foreach { row => + val id = row.getInt(row.fieldIndex("id")) + assertEquals(sha256(expectedBlob(id)), sha256(row.getAs[Array[Byte]]("bytes")), + s"filtered blob SHA-256 mismatch for id=$id") + } + + // --- DESCRIPTOR-mode read at scale: exercises the chunked reader WITH the blob transform + // (re-initialized per chunk). OUT_OF_LINE references must pass through intact on both sides of + // the batch boundary. + val descRows = spark.read.format("hudi").option("hoodie.read.blob.inline.mode", "DESCRIPTOR") + .load(tablePath).select($"id", $"payload").orderBy($"id").collect() + val descById = descRows.map(r => r.getInt(r.fieldIndex("id")) -> r).toMap + sampleIds.foreach { id => + val payload = descById(id).getStruct(descById(id).fieldIndex("payload")) + assertEquals(HoodieSchema.Blob.OUT_OF_LINE, + payload.getString(payload.fieldIndex(HoodieSchema.Blob.TYPE)), s"type mismatch id=$id") + val ref = payload.getStruct(payload.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE)) + assertTrue(ref.getString(ref.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH)) + .endsWith(".bin"), s"external_path mismatch id=$id") + assertEquals(id.toLong * payloadLen, + ref.getLong(ref.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET)), + s"reference offset mismatch id=$id") + } + } + + /** + * Guards the batch-scale tests' core assumption: exactly one Lance base file holds all rows and + * that file has more rows than a single Arrow read batch (512). If a future change splits the + * write across files or shrinks the row count below the batch size, the cross-batch drain path + * would no longer be exercised and the regression would silently stop reproducing. + */ + private def assertSingleLanceBaseFileSpansMultipleBatches(tablePath: String): Unit = { + val lanceFiles = Files.walk(Paths.get(tablePath)) + .filter(p => p.toString.endsWith(".lance")) + .collect(Collectors.toList[java.nio.file.Path]).asScala + assertEquals(1, lanceFiles.length, + s"expected exactly one Lance base file, found: ${lanceFiles.mkString(", ")}") + val allocator = new RootAllocator(64L * 1024 * 1024) + try { + val reader = LanceFileReader.open(lanceFiles.head.toString, allocator) + try { + assertTrue(reader.numRows() > 512, + s"base file must span >512 rows to cross a batch boundary, got ${reader.numRows()}") + } finally { + reader.close() + } + } finally { + allocator.close() + } + } } object TestLanceDataSource { @@ -1992,4 +2588,16 @@ object TestLanceDataSource { } yield Arguments.of(tableType, readMode: java.lang.String) java.util.stream.Stream.of(params: _*) } + + /** + * Cross-product of table types and row counts for the VECTOR+BLOB batch-scale suite. n=100 stays + * within one Arrow batch; n=1000 crosses the 512-row Lance BLOB page boundary. + */ + def vectorBlobBatchParams(): java.util.stream.Stream[Arguments] = { + val params = for { + tableType <- HoodieTableType.values() + n <- Array(100, 1000) + } yield Arguments.of(tableType, n: java.lang.Integer) + java.util.stream.Stream.of(params: _*) + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala new file mode 100644 index 0000000000000..fac97a0cbecba --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala @@ -0,0 +1,473 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.functional + +import org.apache.hudi.{BaseFileOnlyRelation, DataSourceReadOptions, DataSourceWriteOptions, IncrementalRelationV1, IncrementalRelationV2, ScalaAssertionSupport} +import org.apache.hudi.common.config.HoodieReaderConfig +import org.apache.hudi.common.table.HoodieTableConfig +import org.apache.hudi.common.table.log.InstantRange.RangeType +import org.apache.hudi.config.HoodieWriteConfig +import org.apache.hudi.testutils.HoodieSparkClientTestBase + +import org.apache.spark.sql.{DataFrame, Row, SaveMode, SparkSession} +import org.apache.spark.sql.functions.{col, lit, struct} +import org.apache.spark.sql.types.{IntegerType, LongType} +import org.junit.jupiter.api.{AfterEach, BeforeEach, Test} +import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue} + +/** Row shape written by these tests. A nested struct and an array are included so the legacy + * parquet read path is exercised on complex types -- the historically fragile vectorized + * nested-column branch (e.g. HUDI-7190), not just flat scalar columns. */ +private case class LegacyNested(a: Int, b: String) + +private case class LegacyTestRow(id: String, + ts: Long, + value: Long, + partition: String, + nested: LegacyNested, + tags: Seq[Int]) + +/** + * Functional tests for the legacy (pre-file-group-reader) Spark read path: + * [[BaseFileOnlyRelation]], [[IncrementalRelationV1]], [[IncrementalRelationV2]] and the + * per-Spark-version legacy Hudi parquet file format created via + * `sparkAdapter.createLegacyHoodieParquetFileFormat`. + * + * In the batch datasource, `DefaultSource` routes normal reads to the file-group-reader-based + * relations regardless of `hoodie.file.group.reader.enabled`; the legacy relations still run in + * production for metadata-table reads and for streaming reads with the flag disabled. To exercise + * them functionally here, the legacy relations are constructed directly (with the flag set to + * false in their options, matching how the streaming sources invoke them) and their results are + * compared row-by-row against the file-group-reader-enabled reads of the same table. + * + * `DefaultSource#resolveBaseFileOnlyRelation` returns [[BaseFileOnlyRelation]] itself only under + * schema-on-read and converts it to a `HadoopFsRelation` otherwise, so the relation's own + * `buildScan` ships only in the schema-on-read shape; both scan shapes are exercised below. + */ +class TestLegacyParquetReadPath extends HoodieSparkClientTestBase with ScalaAssertionSupport { + + var spark: SparkSession = _ + + private val writeOpts = Map( + "hoodie.insert.shuffle.parallelism" -> "2", + "hoodie.upsert.shuffle.parallelism" -> "2", + DataSourceWriteOptions.RECORDKEY_FIELD.key -> "id", + DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition", + DataSourceWriteOptions.TABLE_TYPE.key -> DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL, + DataSourceWriteOptions.HIVE_STYLE_PARTITIONING.key -> "true", + HoodieTableConfig.ORDERING_FIELDS.key -> "ts", + HoodieWriteConfig.TBL_NAME.key -> "legacy_read_path_tbl" + ) + + // Columns compared across the read paths; meta fields are persisted in the base files, so the + // record key and commit time must match exactly between the legacy and new readers. `nested` and + // `tags` force the legacy parquet reader through its complex-type (struct / array) branch. + private val comparedCols = + Seq("_hoodie_commit_time", "_hoodie_record_key", "id", "ts", "value", "partition", "nested", "tags") + + @BeforeEach override def setUp(): Unit = { + setTableName("legacy_read_path_tbl") + initPath() + initSparkContexts() + spark = sqlContext.sparkSession + // The test schema carries a nested struct and an array. On the legacy parquet read path, batch + // (vectorized) support for such complex types is additionally gated on nested-column + // vectorization, which defaults off on spark3.3 and on only from spark3.4. Enable it so the + // vectorized nested-column branch -- the one HUDI-7190 fixed -- is exercised on every Spark + // profile instead of silently falling back to parquet-mr on 3.3 (which would make the vectorized + // and non-vectorized cases below collapse onto the same row-based path there). + spark.conf.set("spark.sql.parquet.enableNestedColumnVectorizedReader", "true") + initHoodieStorage() + } + + @AfterEach override def tearDown(): Unit = { + cleanupResources() + spark = null + } + + private def makeRows(ids: Seq[Int], ts: Long, valueFn: Int => Long): Seq[LegacyTestRow] = + ids.map(i => LegacyTestRow(i.toString, ts, valueFn(i), "p" + (i % 3), + LegacyNested(i, "v" + valueFn(i)), Seq(i, ts.toInt))) + + private def writeBatch(rows: Seq[LegacyTestRow], operation: String, + extraWriteOpts: Map[String, String] = Map.empty): Unit = { + spark.createDataFrame(rows) + .write.format("hudi") + .options(writeOpts ++ extraWriteOpts) + .option(DataSourceWriteOptions.OPERATION.key, operation) + .mode(SaveMode.Append) + .save(basePath) + } + + /** Commit 1: insert 30 rows; commit 2: upsert rows 1-10 with new values. */ + private def writeTwoCommits(): Unit = { + writeBatch(makeRows(1 to 30, ts = 1L, i => i * 10L), DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL) + writeBatch(makeRows(1 to 10, ts = 2L, i => i * 100L), DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL) + } + + // > Int.MaxValue, so a widened value is representable only as a long + private val widenedBase = 10000000000L + + /** + * Commit 1: insert ids 1..30 with `value` written as INT32; commit 2: upsert only the p0 rows + * (id % 3 == 0) with a LONG `value` too large for an int, promoting the table schema to long. + * In COW, commit 2 rewrites just the p0 file group, so the p1/p2 base files keep the narrower + * physical int while the table schema is now long -- reading them exercises the legacy format's + * type-change reconciliation. Updating a single partition is deliberate: upserting across all + * partitions would rewrite every file group and leave no narrow base files behind. + */ + private def writeIntToLongCommits(extraWriteOpts: Map[String, String] = Map.empty): Unit = { + spark.createDataFrame(makeRows(1 to 30, ts = 1L, i => i.toLong)) + .withColumn("value", col("value").cast(IntegerType)) + .write.format("hudi") + .options(writeOpts ++ extraWriteOpts) + .option(DataSourceWriteOptions.OPERATION.key, DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Append) + .save(basePath) + writeBatch(makeRows((1 to 30).filter(_ % 3 == 0), ts = 2L, i => widenedBase + i), + DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL, extraWriteOpts) + } + + /** + * Asserts the promoted `value` column of a [[writeIntToLongCommits]] table: p1/p2 ids come from + * int base files widened on read, p0 ids carry the large long values written in commit 2. + */ + private def assertWidenedValues(df: DataFrame): Unit = { + assertEquals(LongType, df.schema("value").dataType, + "Reading the promoted table must surface `value` as long") + val actual = df.select("id", "value").collect() + .map(r => (r.getString(0), r.getLong(1))).toSeq.sortBy(_._1.toInt) + val expected = (1 to 30).map(i => (i.toString, if (i % 3 == 0) widenedBase + i else i.toLong)) + assertEquals(expected, actual) + } + + private def fgReaderDf(extraOpts: Map[String, String] = Map.empty): DataFrame = + spark.read.format("hudi") + .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "true") + .options(extraOpts) + .load(basePath) + + private def legacyReadOpts(extraOpts: Map[String, String]): Map[String, String] = + Map( + "path" -> basePath, + DataSourceReadOptions.QUERY_TYPE.key -> DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL, + HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key -> "false" + ) ++ extraOpts + + /** + * Legacy scan through [[BaseFileOnlyRelation]]'s own `PrunedFilteredScan` implementation + * (`HoodieBaseRelation.buildScan` -> base-file readers built on the legacy parquet format). + */ + private def legacyRelationDf(extraOpts: Map[String, String] = Map.empty): DataFrame = { + val metaClient = createMetaClient(spark, basePath) + spark.baseRelationToDataFrame( + BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(extraOpts), None)) + } + + /** + * Legacy scan through the `HadoopFsRelation` conversion that + * `DefaultSource#resolveBaseFileOnlyRelation` applies, executing the per-Spark-version + * legacy Hudi parquet file format inside a regular file-source scan. + */ + private def legacyFileFormatDf(extraOpts: Map[String, String] = Map.empty): DataFrame = { + val metaClient = createMetaClient(spark, basePath) + val hadoopFsRelation = + BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(extraOpts), None).toHadoopFsRelation + assertTrue(hadoopFsRelation.fileFormat.getClass.getSimpleName.contains("LegacyHoodieParquetFileFormat"), + s"Expected the legacy parquet file format but got ${hadoopFsRelation.fileFormat.getClass.getName}") + spark.baseRelationToDataFrame(hadoopFsRelation) + } + + /** + * Whether the legacy parquet format engages its vectorized (batch) reader for the table's + * schema. Because the schema carries a nested struct and an array, batch support additionally + * requires nested-column vectorization; asserting on this pins which branch of the reader a test + * exercises so the vectorized and row-based cases cannot silently collapse onto one path (e.g. if + * a Spark default change dropped nested-column vectorization on some profile). + */ + private def legacyFormatSupportsBatch: Boolean = { + val metaClient = createMetaClient(spark, basePath) + val hadoopFsRelation = + BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(Map.empty), None).toHadoopFsRelation + hadoopFsRelation.fileFormat.supportBatch(spark, hadoopFsRelation.schema) + } + + private def collectSorted(df: DataFrame): Seq[Row] = + df.select(comparedCols.map(col): _*).collect().toSeq.sortBy(_.getAs[String]("id").toInt) + + private def assertSameRows(expected: DataFrame, actual: DataFrame): Unit = { + val expectedRows = collectSorted(expected) + assertTrue(expectedRows.nonEmpty, "Comparison must cover a non-empty result") + assertEquals(expectedRows, collectSorted(actual)) + } + + @Test + def testCowSnapshotReadEqualsFileGroupReader(): Unit = { + writeTwoCommits() + + // With nested-column vectorization enabled (see setUp), the legacy format must take its + // vectorized branch on every profile; otherwise this would degenerate to the same row-based + // path as testCowSnapshotReadWithoutVectorizedReader. + assertTrue(legacyFormatSupportsBatch, + "Legacy parquet format must engage the vectorized reader on the nested-column schema") + + val newReaderDf = fgReaderDf() + assertEquals(30, newReaderDf.count()) + + Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf => + assertSameRows(newReaderDf, legacyDf) + // The upserts from the second commit must be visible through the legacy path. + val updatedValues = legacyDf.filter(col("ts") === 2L) + .collect().map(_.getAs[Long]("value")).sorted.toSeq + assertEquals((1 to 10).map(_ * 100L), updatedValues) + } + } + + @Test + def testCowSnapshotReadWithoutVectorizedReader(): Unit = { + writeTwoCommits() + + val vectorizedKey = "spark.sql.parquet.enableVectorizedReader" + val previous = spark.conf.get(vectorizedKey, "true") + spark.conf.set(vectorizedKey, "false") + try { + // Row-based (non-batch) branch of the legacy parquet file format. Pin that the disabled + // vectorized reader really forces the fallback path, so this case stays distinct from the + // vectorized one above on every profile. + assertFalse(legacyFormatSupportsBatch, + "Disabling the vectorized reader must force the legacy parquet format onto the row-based path") + val newReaderDf = fgReaderDf() + assertSameRows(newReaderDf, legacyFileFormatDf()) + assertSameRows(newReaderDf, legacyRelationDf()) + } finally { + spark.conf.set(vectorizedKey, previous) + } + } + + @Test + def testCowSnapshotReadWithPartitionValuesExtractedFromPath(): Unit = { + writeTwoCommits() + + // BaseFileOnlyRelation always appends partition values parsed from the (hive-style) + // partition path; enabling the same extraction on the new reader must yield equal rows. + val extractOpts = Map(DataSourceReadOptions.EXTRACT_PARTITION_VALUES_FROM_PARTITION_PATH.key -> "true") + val newReaderDf = fgReaderDf(extractOpts) + assertSameRows(newReaderDf, legacyFileFormatDf(extractOpts)) + assertSameRows(newReaderDf, legacyRelationDf(extractOpts)) + + val partitions = legacyFileFormatDf(extractOpts) + .select("partition").distinct().collect().map(_.getString(0)).sorted.toSeq + assertEquals(Seq("p0", "p1", "p2"), partitions) + } + + @Test + def testPartitionAndDataFilterPushdown(): Unit = { + writeTwoCommits() + + def applyFilters(df: DataFrame): DataFrame = + df.filter(col("partition") === "p1" && col("value") > 100L) + + // Partition p1 holds ids with id % 3 == 1: updated ids {4, 7, 10} have value > 100 + // (id 1 has value exactly 100) and untouched ids {13, 16, 19, 22, 25, 28} do as well. + val newReaderDf = applyFilters(fgReaderDf()) + assertEquals(9, newReaderDf.count()) + assertSameRows(newReaderDf, applyFilters(legacyFileFormatDf())) + assertSameRows(newReaderDf, applyFilters(legacyRelationDf())) + } + + @Test + def testCowIncrementalReadEqualsFileGroupReader(): Unit = { + writeTwoCommits() + writeBatch(makeRows(11 to 15, ts = 3L, i => i * 1000L), DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL) + + val metaClient = createMetaClient(spark, basePath) + val firstInstant = metaClient.getCommitsTimeline.filterCompletedInstants.firstInstant.get + + // New-reader incremental query; on current table versions the start bound is a completion + // time and the range is start-exclusive, so this returns rows written by commits 2 and 3. + val newReaderDf = spark.read.format("hudi") + .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "true") + .option(DataSourceReadOptions.QUERY_TYPE.key, DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL) + .option(DataSourceReadOptions.START_COMMIT.key, firstInstant.getCompletionTime) + .load(basePath) + + // V1 slices the timeline by instant time, V2 by completion time; both are start-exclusive. + val incOptsV1 = Map( + DataSourceReadOptions.QUERY_TYPE.key -> DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL, + DataSourceReadOptions.START_COMMIT.key -> firstInstant.requestedTime, + HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key -> "false") + val incOptsV2 = incOptsV1.updated(DataSourceReadOptions.START_COMMIT.key, firstInstant.getCompletionTime) + + val legacyV1Df = spark.baseRelationToDataFrame( + new IncrementalRelationV1(sqlContext, incOptsV1, None, metaClient)) + val legacyV2Df = spark.baseRelationToDataFrame( + new IncrementalRelationV2(sqlContext, incOptsV2, None, metaClient, RangeType.OPEN_CLOSED)) + + // COW upserts preserve the original commit time of untouched rows in rewritten files, so the + // incremental result is exactly the rows written by commits 2 and 3. + val expected = ((1 to 10).map(i => (i.toString, i * 100L)) ++ (11 to 15).map(i => (i.toString, i * 1000L))) + .sortBy(_._1.toInt) + Seq(newReaderDf, legacyV1Df, legacyV2Df).foreach { df => + val actual = df.select("id", "value").collect() + .map(r => (r.getString(0), r.getLong(1))).toSeq.sortBy(_._1.toInt) + assertEquals(expected, actual) + } + + assertSameRows(newReaderDf, legacyV1Df) + assertSameRows(newReaderDf, legacyV2Df) + } + + @Test + def testCowSnapshotReadWithImplicitTypeChange(): Unit = { + // The Hudi-specific reason these per-version parquet formats exist (vs stock ParquetFileFormat) + // is on-read type reconciliation: when a base file's physical column type is narrower than the + // table schema, HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo records the change + // and the vectorized read runs through Hudi's HoodieVectorizedParquetRecordReader (which widens + // the column vector) instead of Spark's stock VectorizedParquetRecordReader. + writeIntToLongCommits() + + // Vectorization must be on so the read takes the HoodieVectorizedParquetRecordReader branch + // rather than the row-based parquet-mr fallback (which reconciles types via a different path). + assertTrue(legacyFormatSupportsBatch, + "Vectorized reader must be engaged so the implicit type change runs through " + + "HoodieVectorizedParquetRecordReader") + + val newReaderDf = fgReaderDf() + Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf => + assertWidenedValues(legacyDf) + // The legacy path must still agree with the file-group reader on the promoted column. + assertSameRows(newReaderDf, legacyDf) + } + } + + @Test + def testCowSnapshotReadWithImplicitTypeChangeWithoutVectorizedReader(): Unit = { + writeIntToLongCommits() + + val vectorizedKey = "spark.sql.parquet.enableVectorizedReader" + val previous = spark.conf.get(vectorizedKey, "true") + spark.conf.set(vectorizedKey, "false") + try { + // With the vectorized reader off, the legacy format reconciles the type change on its + // row-based branch instead: a Cast from the file's narrower type compiled into a + // GenerateUnsafeProjection -- an implementation separate from + // HoodieVectorizedParquetRecordReader, so it needs its own coverage. + assertFalse(legacyFormatSupportsBatch, + "Disabling the vectorized reader must force the legacy parquet format onto the row-based path") + val newReaderDf = fgReaderDf() + Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf => + assertWidenedValues(legacyDf) + assertSameRows(newReaderDf, legacyDf) + } + } finally { + spark.conf.set(vectorizedKey, previous) + } + } + + @Test + def testCowSnapshotReadWithNestedTypeChange(): Unit = { + // Same int->long promotion, but inside the `nested` struct. The changed top-level column is + // then non-atomic, which the legacy format cannot reconcile in vectorized mode: it must fail + // fast with the documented IllegalArgumentException instead of returning corrupt columns, and + // the workaround the exception advertises (disabling the vectorized reader) must actually read + // the promoted struct correctly through the row-based Cast branch. + writeBatch(makeRows(1 to 30, ts = 1L, i => i * 10L), DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL) + spark.createDataFrame(makeRows((1 to 30).filter(_ % 3 == 0), ts = 2L, i => i * 100L)) + .withColumn("nested", + struct((col("nested.a") + lit(widenedBase)).as("a"), col("nested.b").as("b"))) + .write.format("hudi") + .options(writeOpts) + .option(DataSourceWriteOptions.OPERATION.key, DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Append) + .save(basePath) + + assertTrue(legacyFormatSupportsBatch, + "Vectorized reader must be engaged so the non-atomic type change hits the legacy format's rejection") + Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf => + val thrown = assertThrows(classOf[Throwable]) { + legacyDf.collect() + } + val causes = Iterator.iterate(thrown: Throwable)(_.getCause).takeWhile(_ != null).take(10).toSeq + assertTrue(causes.exists(c => c.isInstanceOf[IllegalArgumentException] + && String.valueOf(c.getMessage).contains("cannot be read in vectorized mode")), + s"Expected the non-atomic type-change rejection but got: $thrown") + } + + val vectorizedKey = "spark.sql.parquet.enableVectorizedReader" + val previous = spark.conf.get(vectorizedKey, "true") + spark.conf.set(vectorizedKey, "false") + try { + assertFalse(legacyFormatSupportsBatch, + "Disabling the vectorized reader must force the legacy parquet format onto the row-based path") + val newReaderDf = fgReaderDf() + Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf => + val actual = legacyDf.select("id", "nested").collect() + .map(r => (r.getString(0), r.getStruct(1).getLong(0))).toSeq.sortBy(_._1.toInt) + val expected = (1 to 30).map(i => (i.toString, if (i % 3 == 0) widenedBase + i else i.toLong)) + assertEquals(expected, actual) + assertSameRows(newReaderDf, legacyDf) + } + } finally { + spark.conf.set(vectorizedKey, previous) + } + } + + @Test + def testCowSnapshotReadWithSchemaOnRead(): Unit = { + // Schema-on-read is the one production shape in which DefaultSource#resolveBaseFileOnlyRelation + // returns BaseFileOnlyRelation itself instead of converting it to a HadoopFsRelation, making + // the relation's own buildScan the shipped scan path. It also flips + // BaseFileOnlyRelation.shouldExtractPartitionValuesFromPartitionPath (defined as + // internalSchemaOpt.isEmpty) to false -- the only way the legacy parquet format is constructed + // with shouldAppendPartitionValues = false -- and drives the format's explicit internal-schema + // branch (InternalSchemaCache lookup + InternalSchemaMerger) instead of the implicit + // footer-based reconciliation. + // + // No ALTER TABLE is needed to get an InternalSchema into commit metadata: with + // hoodie.schema.on.read.enable plus hoodie.datasource.write.reconcile.schema on the writes, + // HoodieSparkSqlWriter seeds the internal schema on the first commit and evolves it with the + // int->long promotion on the second. + writeIntToLongCommits(Map( + DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key -> "true", + DataSourceWriteOptions.RECONCILE_SCHEMA.key -> "true")) + + val readOpts = Map(DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key -> "true") + val metaClient = createMetaClient(spark, basePath) + val schemaOnReadRelation = BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(readOpts), None) + assertTrue(schemaOnReadRelation.hasSchemaOnRead, + "The writes must have recorded an InternalSchema in commit metadata for schema-on-read to engage") + // Pin the flipped partition-values branch: under schema-on-read the relation reads partition + // columns from the data files (empty partition schema) instead of re-appending them from the + // partition path, unlike every other case in this suite. + assertTrue(schemaOnReadRelation.toHadoopFsRelation.partitionSchema.isEmpty, + "Schema-on-read must flip shouldExtractPartitionValuesFromPartitionPath off") + assertTrue(BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(Map.empty), None) + .toHadoopFsRelation.partitionSchema.nonEmpty, + "Without schema-on-read the converted relation appends partition values from the path") + + assertTrue(legacyFormatSupportsBatch, + "Vectorized reader must be engaged so the explicit type change runs through " + + "HoodieVectorizedParquetRecordReader") + + val newReaderDf = fgReaderDf(readOpts) + Seq(legacyRelationDf(readOpts), legacyFileFormatDf(readOpts)).foreach { legacyDf => + assertWidenedValues(legacyDf) + assertSameRows(newReaderDf, legacyDf) + } + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala new file mode 100644 index 0000000000000..9b8f9cb5bed3f --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala @@ -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.hudi.functional + +import org.apache.hudi.DataSourceWriteOptions._ +import org.apache.hudi.client.SparkRDDWriteClient +import org.apache.hudi.client.common.HoodieSparkEngineContext +import org.apache.hudi.client.transaction.lock.InProcessLockProvider +import org.apache.hudi.common.config.{HoodieMetadataConfig, TypedProperties} +import org.apache.hudi.common.model.HoodieTableType +import org.apache.hudi.common.table.{HoodieTableMetaClient, HoodieTableVersion, TableSchemaResolver} +import org.apache.hudi.common.testutils.HoodieTestUtils +import org.apache.hudi.config.{HoodieLockConfig, HoodieWriteConfig} +import org.apache.hudi.metadata.HoodieMetadataPayload.SECONDARY_INDEX_RECORD_KEY_SEPARATOR +import org.apache.hudi.metadata.MetadataPartitionType +import org.apache.hudi.testutils.SparkClientFunctionalTestHarness.getSparkSqlConf +import org.apache.hudi.testutils.SparkClientFunctionalTestHarnessScala + +import org.apache.spark.SparkConf +import org.junit.jupiter.api.{BeforeEach, Tag, Test} +import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue} + +import scala.collection.JavaConverters.mapAsJavaMapConverter + +/** + * Exercises {@code SparkHoodieBackedTableMetadataWriter} and its table-version-six + * implementation through real Spark writes. + */ +@Tag("functional-c") +class TestMetadataTableWithSparkSQL extends SparkClientFunctionalTestHarnessScala { + + override def conf: SparkConf = conf(getSparkSqlConf) + + @BeforeEach + override def runBeforeEach(): Unit = { + super.runBeforeEach() + spark.sql(s"set ${HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key()} = ${classOf[InProcessLockProvider].getName}") + } + + @Test + def testAllMetadataIndexesAcrossUpsertAndRollback(): Unit = { + // Validate the full MDT lifecycle against a current-version COW table. + val tableName = "metadata_writer_all_indexes" + val tablePath = s"$basePath/$tableName" + val writeOptions = metadataWriteOptions(tableName, streamingWrites = true) + + // Bootstrap all supported MDT partitions through real writes. + createTable(tableName, tablePath, tableVersion = None) + spark.sql( + s"""insert into $tableName values + | (1, 'row1', 'alpha', 'p1'), + | (2, 'row2', 'beta', 'p1'), + | (3, 'row3', 'gamma', 'p2') + |""".stripMargin) + spark.sql(s"create index idx_rider on $tableName (rider)") + + var metaClient = createMetaClient(tablePath) + assertMetadataPartitions(metaClient, includePartitionStats = true, includeSecondaryIndex = true) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, includePartitionStats = true) + // Metadata payload type 7 stores secondary-index mappings. + checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")( + Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"), + Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"), + Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3") + ) + + // Rebuild the secondary index and verify its records are removed and restored. + spark.sql(s"drop index idx_rider on $tableName") + metaClient = HoodieTableMetaClient.reload(metaClient) + assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains("secondary_index_idx_rider")) + assertEquals(0L, spark.sql(s"select key from hudi_metadata('$tablePath') where type=7").count()) + + spark.sql(s"create index idx_rider on $tableName (rider)") + metaClient = HoodieTableMetaClient.reload(metaClient) + assertMetadataPartitions(metaClient, includePartitionStats = true, includeSecondaryIndex = true) + checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")( + Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"), + Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"), + Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3") + ) + + // Upsert changes both data and the secondary-index key. + spark.sql(s"update $tableName set rider = 'delta', ts = 4 where id = 'row1'") + metaClient = HoodieTableMetaClient.reload(metaClient) + val upsertInstant = metaClient.getActiveTimeline.getCommitsTimeline + .filterCompletedInstants.lastInstant().get().requestedTime() + + checkAnswer(s"select id, rider, part from $tableName order by id")( + Seq("row1", "delta", "p1"), + Seq("row2", "beta", "p1"), + Seq("row3", "gamma", "p2") + ) + checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")( + Seq(s"delta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"), + Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"), + Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3") + ) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, includePartitionStats = true) + + // Rollback must restore data and all MDT indexes. + rollback(metaClient, writeOptions, upsertInstant) + spark.catalog.refreshTable(tableName) + metaClient = HoodieTableMetaClient.reload(metaClient) + + checkAnswer(s"select id, rider, part from $tableName order by id")( + Seq("row1", "alpha", "p1"), + Seq("row2", "beta", "p1"), + Seq("row3", "gamma", "p2") + ) + assertMetadataPartitions(metaClient, includePartitionStats = true, includeSecondaryIndex = true) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, includePartitionStats = true) + checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")( + Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"), + Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"), + Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3") + ) + } + + @Test + def testTableVersionSixMetadataWritesAndRollback(): Unit = { + // Validate the legacy writer with the indexes supported by table version 6. + val tableName = "metadata_writer_v6" + val tablePath = s"$basePath/$tableName" + val writeOptions = metadataWriteOptions(tableName, streamingWrites = false) + + (HoodieWriteConfig.WRITE_TABLE_VERSION.key() -> HoodieTableVersion.SIX.versionCode().toString) + + // Version 6 uses the legacy writer and supports a smaller index set. + createTable(tableName, tablePath, tableVersion = Some(HoodieTableVersion.SIX.versionCode())) + spark.sql( + s"""insert into $tableName values + | (1, 'row1', 'alpha', 'p1'), + | (2, 'row2', 'beta', 'p2') + |""".stripMargin) + + var metaClient = createMetaClient(tablePath) + val metadataMetaClient = createMetaClient(metaClient.getMetaPath + "/metadata") + assertEquals(HoodieTableVersion.SIX, metaClient.getTableConfig.getTableVersion) + assertEquals(HoodieTableVersion.SIX, metadataMetaClient.getTableConfig.getTableVersion) + assertMetadataPartitions(metaClient, includePartitionStats = false, includeSecondaryIndex = false) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, includePartitionStats = false) + + spark.sql(s"update $tableName set rider = 'updated', ts = 3 where id = 'row1'") + metaClient = HoodieTableMetaClient.reload(metaClient) + val upsertInstant = metaClient.getActiveTimeline.getCommitsTimeline + .filterCompletedInstants.lastInstant().get().requestedTime() + checkAnswer(s"select id, rider from $tableName order by id")( + Seq("row1", "updated"), + Seq("row2", "beta") + ) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, includePartitionStats = false) + + rollback(metaClient, writeOptions, upsertInstant) + spark.catalog.refreshTable(tableName) + metaClient = HoodieTableMetaClient.reload(metaClient) + + checkAnswer(s"select id, rider from $tableName order by id")( + Seq("row1", "alpha"), + Seq("row2", "beta") + ) + assertEquals(HoodieTableVersion.SIX, metaClient.getTableConfig.getTableVersion) + assertMetadataPartitions(metaClient, includePartitionStats = false, includeSecondaryIndex = false) + assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, includePartitionStats = false) + + // Verify record-index deletion and bootstrap on the legacy table. + spark.sql(s"drop index record_index on $tableName") + metaClient = HoodieTableMetaClient.reload(metaClient) + assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath)) + assertEquals(0L, spark.sql(s"select key from hudi_metadata('$tablePath') where type=5").count()) + + spark.sql(s"create index record_index on $tableName (id)") + metaClient = HoodieTableMetaClient.reload(metaClient) + assertTrue(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath)) + assertEquals(2L, spark.sql(s"select key from hudi_metadata('$tablePath') where type=5").count()) + } + + private def createTable( + tableName: String, + tablePath: String, + tableVersion: Option[Int]): Unit = { + val tableVersionOption = tableVersion + .map(version => s"hoodie.write.table.version = '$version',") + .getOrElse("") + val streamingWrites = tableVersion.isEmpty + spark.sql( + s""" + |create table $tableName ( + | ts bigint, + | id string, + | rider string, + | part string + |) using hudi + | options ( + | primaryKey = 'id', + | orderingFields = 'ts', + | type = 'cow', + | $tableVersionOption + | hoodie.metadata.enable = 'true', + | hoodie.metadata.index.column.stats.enable = 'true', + | hoodie.metadata.index.partition.stats.enable = 'true', + | hoodie.metadata.record.index.enable = 'true', + | hoodie.metadata.streaming.write.enabled = '$streamingWrites', + | hoodie.metadata.record.preparation.parallelism = '1', + | hoodie.metrics.on = 'true', + | hoodie.metrics.reporter.type = 'CONSOLE', + | hoodie.metrics.executor.enable = 'true', + | hoodie.datasource.write.recordkey.field = 'id', + | hoodie.datasource.write.partitionpath.field = 'part', + | hoodie.datasource.write.payload.class = + | 'org.apache.hudi.common.model.OverwriteWithLatestAvroPayload' + | ) + | partitioned by (part) + | location '$tablePath' + |""".stripMargin) + } + + private def metadataWriteOptions(tableName: String, streamingWrites: Boolean): Map[String, String] = Map( + HoodieWriteConfig.TBL_NAME.key() -> tableName, + TABLE_TYPE.key() -> HoodieTableType.COPY_ON_WRITE.name(), + RECORDKEY_FIELD.key() -> "id", + PARTITIONPATH_FIELD.key() -> "part", + PRECOMBINE_FIELD.key() -> "ts", + HoodieMetadataConfig.ENABLE.key() -> "true", + HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key() -> "true", + // On release-1.2.1 this key is a deprecated String constant, not a ConfigProperty; + // partition stats are driven by the column stats config above. + HoodieMetadataConfig.ENABLE_METADATA_INDEX_PARTITION_STATS -> "true", + HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "true", + HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key() -> streamingWrites.toString, + HoodieMetadataConfig.RECORD_PREPARATION_PARALLELISM.key() -> "1", + "hoodie.metrics.on" -> "true", + "hoodie.metrics.reporter.type" -> "CONSOLE", + "hoodie.metrics.executor.enable" -> "true", + HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key() -> classOf[InProcessLockProvider].getName + ) + + private def createMetaClient(tablePath: String): HoodieTableMetaClient = + HoodieTableMetaClient.builder() + .setBasePath(tablePath) + .setConf(HoodieTestUtils.getDefaultStorageConf) + .build() + + private def rollback( + metaClient: HoodieTableMetaClient, + writeOptions: Map[String, String], + instantTime: String): Unit = { + val props = TypedProperties.fromMap(writeOptions.asJava) + val writeConfig = HoodieWriteConfig.newBuilder() + .withPath(metaClient.getBasePath) + .withSchema(new TableSchemaResolver(metaClient).getTableSchema(false).toString) + .withProps(props) + .withEmbeddedTimelineServerEnabled(false) + .build() + val writeClient = new SparkRDDWriteClient(new HoodieSparkEngineContext(jsc), writeConfig) + try { + assertTrue(writeClient.rollback(instantTime)) + } finally { + writeClient.close() + } + } + + private def assertMetadataPartitions( + metaClient: HoodieTableMetaClient, + includePartitionStats: Boolean, + includeSecondaryIndex: Boolean): Unit = { + val partitions = metaClient.getTableConfig.getMetadataPartitions + assertTrue(partitions.contains(MetadataPartitionType.FILES.getPartitionPath)) + assertTrue(partitions.contains(MetadataPartitionType.COLUMN_STATS.getPartitionPath)) + assertTrue(partitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath)) + if (includePartitionStats) { + assertTrue(partitions.contains(MetadataPartitionType.PARTITION_STATS.getPartitionPath)) + } + if (includeSecondaryIndex) { + assertTrue(partitions.contains("secondary_index_idx_rider")) + } + } + + private def assertCommonMetadataRecords( + tablePath: String, + expectedRecordIndexCount: Long, + includePartitionStats: Boolean): Unit = { + // Payload types 3, 6, and 5 represent column stats, partition stats, and record index. + assertTrue(spark.sql(s"select key from hudi_metadata('$tablePath') where type=3").count() > 0) + if (includePartitionStats) { + assertTrue(spark.sql(s"select key from hudi_metadata('$tablePath') where type=6").count() > 0) + } + assertEquals( + expectedRecordIndexCount, + spark.sql(s"select key from hudi_metadata('$tablePath') where type=5").count()) + } + + private def checkAnswer(query: String)(expected: Seq[Any]*): Unit = { + val expectedRows = expected.map(_.mkString("|")).sorted.toList + val actualRows = spark.sql(query).collect().map(_.toSeq.mkString("|")).sorted.toList + assertEquals(expectedRows, actualRows) + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPayloadDeprecationFlow.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPayloadDeprecationFlow.scala index 76e23f886f537..58fab2afcb9a0 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPayloadDeprecationFlow.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPayloadDeprecationFlow.scala @@ -35,7 +35,7 @@ import org.apache.hudi.testutils.SparkClientFunctionalTestHarness import org.apache.spark.sql.SaveMode import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue} import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.{Arguments, MethodSource} +import org.junit.jupiter.params.provider.{Arguments, MethodSource, ValueSource} import org.scalatest.Assertions.assertThrows import scala.jdk.CollectionConverters._ @@ -494,6 +494,76 @@ class TestPayloadDeprecationFlow extends SparkClientFunctionalTestHarness { .build() } + /** + * COW pre-v9 Postgres-Debezium table whose payload class is set only via the write config + * (hoodie.datasource.write.payload.class), not the table properties. A Debezium delete + * (_change_operation_type='d') for a key absent from the base file must be classified as a delete + * and become a no-op, rather than failing when the reader derives the delete markers. + * + * stripPayloadClass=true removes the payload class from the table properties (the failing case + * before the fix); stripPayloadClass=false keeps it and acts as the control. + */ + @ParameterizedTest + @ValueSource(strings = Array("true", "false")) + def testDebeziumDeleteForAbsentKeyWithPayloadClassNotInTableConfig(stripPayloadClassStr: String): Unit = { + val stripPayloadClass = stripPayloadClassStr.toBoolean + val payloadClazz = classOf[PostgresDebeziumAvroPayload].getName + // Payload class supplied only as a write option, not as a table property. + val opts: Map[String, String] = Map( + HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key() -> payloadClazz, + HoodieMetadataConfig.ENABLE.key() -> "false") + // Single-bucket index so the delete key hashes to the existing file group and is merged against + // its base file, rather than creating a fresh insert file group. + val indexOpts: Map[String, String] = Map( + "hoodie.index.type" -> "BUCKET", + "hoodie.index.bucket.engine" -> "SIMPLE", + "hoodie.bucket.index.num.buckets" -> "1", + "hoodie.bucket.index.hash.field" -> "_event_lsn") + + val columns = Seq("ts", "_event_lsn", "rider", "driver", "fare", "Op", "_event_seq", + DebeziumConstants.FLATTENED_FILE_COL_NAME, DebeziumConstants.FLATTENED_POS_COL_NAME, DebeziumConstants.FLATTENED_OP_COL_NAME) + + // 1. Insert base rows (lsn 1,2,3) into a COW table at table version 8. + val data = Seq( + (10, 1L, "rider-A", "driver-A", 19.10, "i", "10.1", 10, 1, "i"), + (10, 2L, "rider-B", "driver-B", 27.70, "i", "10.1", 10, 1, "i"), + (10, 3L, "rider-C", "driver-C", 33.90, "i", "10.1", 10, 1, "i")) + spark.createDataFrame(data).toDF(columns: _*).write.format("hudi") + .option(RECORDKEY_FIELD.key(), "_event_lsn") + .option(HoodieTableConfig.ORDERING_FIELDS.key(), "_event_lsn") + .option(TABLE_TYPE.key(), HoodieTableType.COPY_ON_WRITE.name()) + .option(DataSourceWriteOptions.TABLE_NAME.key(), "test_table") + .option(OPERATION.key(), DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL) + .option(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), "8") + .options(indexOpts) + .options(opts) + .mode(SaveMode.Overwrite) + .save(basePath) + + var metaClient = HoodieTableMetaClient.builder().setBasePath(basePath).setConf(storageConf()).build() + assertEquals(8, metaClient.getTableConfig.getTableVersion.versionCode()) + + if (stripPayloadClass) { + // Remove any persisted payload-class keys so the payload class lives only in the write config. + val payloadKeys = metaClient.getTableConfig.getProps.asScala.keys + .filter(k => k.toString.contains("payload.class")).map(_.toString).toSet + HoodieTableConfig.delete(metaClient.getStorage, metaClient.getMetaPath, payloadKeys.asJava) + metaClient = HoodieTableMetaClient.builder().setBasePath(basePath).setConf(storageConf()).build() + assertFalse(metaClient.getTableConfig.getPayloadClassIfPresent.isPresent) + } + + // 2. Upsert a Debezium delete ('d') for a key ABSENT from the base file (lsn 99). + val deleteData = Seq( + (12, 99L, "rider-Z", "driver-Z", 20.10, "D", "12.1", 12, 1, "d")) + performUpsert(deleteData, columns, indexOpts, opts, basePath, + tableVersion = Some("8"), orderingFields = Some("_event_lsn")) + + // 3. Base rows intact and the absent-key delete is a no-op. + val df = spark.read.format("hudi").load(basePath) + assertEquals(3, df.count()) + assertEquals(0, df.filter("_event_lsn = 99").count()) + } + /** * Helper method to perform upsert operations with configurable options */ diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndex.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndex.scala index cf63ea9156307..b215c4e53dd16 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndex.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRecordLevelIndex.scala @@ -146,7 +146,8 @@ class TestRecordLevelIndex extends RecordLevelIndexTestBase with SparkDatasetMix "Metadata files partition count should be lower than data table file count after rebootstrap") } - def testRecordLevelIndex(tableType: HoodieTableType, streamingWriteEnabled: Boolean, holder: testRecordLevelIndexHolder): Unit = { + def testRecordLevelIndex(tableType: HoodieTableType, streamingWriteEnabled: Boolean, holder: testRecordLevelIndexHolder, + rliInitDeferred: Boolean = false): Unit = { val dataGen = new HoodieTestDataGenerator(); val inserts = dataGen.generateInserts("001", 5) val latestBatchDf = toDataset(spark, inserts) @@ -156,9 +157,10 @@ class TestRecordLevelIndex extends RecordLevelIndexTestBase with SparkDatasetMix RECORDKEY_FIELD.key -> "_row_key", PARTITIONPATH_FIELD.key -> "data_partition_path", HoodieTableConfig.ORDERING_FIELDS.key -> "timestamp", - HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key()-> "false", + HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "false", HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "true", HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key() -> streamingWriteEnabled.toString, + HoodieMetadataConfig.DEFER_RLI_INIT_FOR_FRESH_TABLE.key() -> rliInitDeferred.toString, HoodieCompactionConfig.INLINE_COMPACT.key() -> "false", HoodieIndexConfig.INDEX_TYPE.key() -> RECORD_LEVEL_INDEX.name()) holder.options = options @@ -167,11 +169,19 @@ class TestRecordLevelIndex extends RecordLevelIndexTestBase with SparkDatasetMix .mode(SaveMode.Overwrite) .save(basePath) assertEquals(10, spark.read.format("hudi").load(basePath).count()) + if (rliInitDeferred) { + // With defer enabled, the first commit should NOT have initialized the RLI partition. + metaClient = HoodieTableMetaClient.reload(metaClient) + assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath), + "RLI partition should not be initialized after the first commit when defer is enabled") + } val props = TypedProperties.fromMap(JavaConverters.mapAsJavaMapConverter(options).asJava) val writeConfig = HoodieWriteConfig.newBuilder() .withProps(props) .withPath(basePath) .build() + // Constructing the metadata writer here will initialize RLI (lazily, on this second metadata-writer entry) + // when defer is enabled, since there is now 1 completed commit on the data table. var metadata = metadataWriter(writeConfig).getTableMetadata val recordKeys = inserts.asScala.map(i => i.getRecordKey).asJava.stream().collect(Collectors.toList()) holder.recordKeys = recordKeys @@ -301,6 +311,108 @@ class TestRecordLevelIndex extends RecordLevelIndexTestBase with SparkDatasetMix } } + @ParameterizedTest + @ValueSource(booleans = Array(true, false)) + def testPartitionedRecordLevelIndexDefer(streamingWriteEnabled: Boolean): Unit = { + val holder = new testRecordLevelIndexHolder + testRecordLevelIndex(HoodieTableType.MERGE_ON_READ, streamingWriteEnabled, holder, true) + assertEquals("deltacommit", metaClient.getActiveTimeline.lastInstant().get().getAction) + val writeConfig = getWriteConfig(holder.options) + var metadata = metadataWriter(writeConfig).getTableMetadata + doAllAssertions(holder, metadata) + val writeClient = new SparkRDDWriteClient(new HoodieSparkEngineContext(jsc), writeConfig) + val timeOpt = writeClient.scheduleCompaction(HOption.empty()) + assertTrue(timeOpt.isPresent) + writeClient.compact(timeOpt.get()) + metaClient.reloadActiveTimeline() + assertEquals("compaction", metaClient.getActiveTimeline.lastInstant().get().getAction) + metadata = metadataWriter(writeConfig).getTableMetadata + doAllAssertions(holder, metadata) + writeClient.close() + } + + @ParameterizedTest + @ValueSource(booleans = Array(true, false)) + def testPartitionedRecordLevelIndexDeferWithBulkInsert(streamingWriteEnabled: Boolean): Unit = { + val tableType = HoodieTableType.MERGE_ON_READ + val dataGen = new HoodieTestDataGenerator() + val inserts1 = dataGen.generateInserts("001", 5) + val batch1Df = toDataset(spark, inserts1) + val insertDf1 = batch1Df.withColumn("data_partition_path", lit("partition1")) + .union(batch1Df.withColumn("data_partition_path", lit("partition2"))) + + val options = Map( + HoodieWriteConfig.TBL_NAME.key -> "hoodie_test", + DataSourceWriteOptions.TABLE_TYPE.key -> tableType.name(), + RECORDKEY_FIELD.key -> "_row_key", + PARTITIONPATH_FIELD.key -> "data_partition_path", + HoodieTableConfig.ORDERING_FIELDS.key -> "timestamp", + HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "false", + HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "true", + HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key() -> streamingWriteEnabled.toString, + HoodieMetadataConfig.DEFER_RLI_INIT_FOR_FRESH_TABLE.key() -> "true", + HoodieCompactionConfig.INLINE_COMPACT.key() -> "false", + HoodieIndexConfig.INDEX_TYPE.key() -> RECORD_LEVEL_INDEX.name()) + + // Commit #1: bulk_insert on a fresh table with defer enabled. + insertDf1.write.format("hudi") + .options(options) + .option(DataSourceWriteOptions.OPERATION.key(), DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Overwrite) + .save(basePath) + assertEquals(10, spark.read.format("hudi").load(basePath).count()) + + // Defer should have kicked in: RLI partition is not initialized after the first bulk_insert. + metaClient = HoodieTableMetaClient.reload(metaClient) + assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath), + "RLI partition should not be initialized after the first bulk_insert when defer is enabled") + + // Commit #2: another bulk_insert into a new partition. New rows must use distinct record keys. + val inserts2 = dataGen.generateInserts("002", 5) + val batch2Df = toDataset(spark, inserts2) + val insertDf2 = batch2Df.withColumn("data_partition_path", lit("partition3")) + + insertDf2.write.format("hudi") + .options(options) + .option(DataSourceWriteOptions.OPERATION.key(), DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Append) + .save(basePath) + assertEquals(15, spark.read.format("hudi").load(basePath).count()) + + // Build metadata writer/reader; this entry will initialize RLI now that there is a completed commit. + val writeConfig = getWriteConfig(options) + val metadata = metadataWriter(writeConfig).getTableMetadata + + // RLI partition should now be present in the metadata table. + metaClient = HoodieTableMetaClient.reload(metaClient) + assertTrue(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath), + "RLI partition should be initialized once a completed commit exists on the data table") + assertTrue(HoodieRecordIndex.isPartitioned( + metaClient.getIndexMetadata.get().getIndexDefinitions.get(HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX)), + "RLI should be initialized as partitioned RLI") + + // Validate record key -> location mapping for both batches against the data. + val tableRows = spark.read.format("hudi").load(basePath).collect() + + val batch1Keys = inserts1.asScala.map(_.getRecordKey).asJava.stream().collect(Collectors.toList()) + val partition1Locations = readRecordIndex(metadata, batch1Keys, HOption.of("partition1")) + assertEquals(5, partition1Locations.size) + validateDFWithLocations(tableRows, partition1Locations, "partition1") + val partition2Locations = readRecordIndex(metadata, batch1Keys, HOption.of("partition2")) + assertEquals(5, partition2Locations.size) + validateDFWithLocations(tableRows, partition2Locations, "partition2") + + val batch2Keys = inserts2.asScala.map(_.getRecordKey).asJava.stream().collect(Collectors.toList()) + val partition3Locations = readRecordIndex(metadata, batch2Keys, HOption.of("partition3")) + assertEquals(5, partition3Locations.size) + validateDFWithLocations(tableRows, partition3Locations, "partition3") + + // Cross-partition lookups for batch1 keys against partition3 (and vice versa) should be empty. + assertEquals(0, readRecordIndex(metadata, batch1Keys, HOption.of("partition3")).size) + assertEquals(0, readRecordIndex(metadata, batch2Keys, HOption.of("partition1")).size) + assertEquals(0, readRecordIndex(metadata, batch2Keys, HOption.of("partition2")).size) + } + @ParameterizedTest @ValueSource(booleans = Array(true, false)) def testPartitionedRecordLevelIndexCompact(streamingWriteEnabled: Boolean): Unit = { @@ -664,6 +776,108 @@ class TestRecordLevelIndex extends RecordLevelIndexTestBase with SparkDatasetMix } } + /** + * Tests that when a zero-size base file is skipped during MDT bootstrap, a subsequent upsert + * still succeeds and produces consistent data. Because the skipped file group is absent from MDT, + * the upsert treats those records as new inserts and writes them to a new file group. The test + * verifies the final record count and that every RLI entry points to a real, readable file. + */ + @Test + def testUpsertAfterSkippingZeroSizeFileOnInitialize(): Unit = { + // Use a single-partition data generator so all inserts land in one parquet file. + val singlePartitionDataGen = HoodieTestDataGenerator.createTestGeneratorFirstPartition() + val singlePartition = HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH + val insertedRecords = 10 + val inserts = singlePartitionDataGen.generateInserts("001", insertedRecords) + val insertDf = toDataset(spark, inserts) + + val baseOptions = Map( + HoodieWriteConfig.TBL_NAME.key -> "hoodie_test", + DataSourceWriteOptions.TABLE_TYPE.key -> HoodieTableType.COPY_ON_WRITE.name(), + RECORDKEY_FIELD.key -> "_row_key", + PARTITIONPATH_FIELD.key -> "partition_path", + HoodieTableConfig.ORDERING_FIELDS.key -> "timestamp", + HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "false", + HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "false", + HoodieCompactionConfig.INLINE_COMPACT.key() -> "false") + + insertDf.write.format("hudi") + .options(baseOptions) + .mode(SaveMode.Overwrite) + .save(basePath) + assertEquals(insertedRecords, spark.read.format("hudi").load(basePath).count()) + + // Confirm there is exactly one parquet base file in the single partition. + val partitionPath = new StoragePath(basePath, singlePartition) + val baseFilesBeforeCorruption = storage.listDirectEntries(partitionPath).asScala + .filter(_.getPath.getName.endsWith(".parquet")) + .toSeq + assertEquals(1, baseFilesBeforeCorruption.size, "Expected exactly one parquet file in the single partition") + val zeroSizeFileId = FSUtils.getFileId(baseFilesBeforeCorruption.head.getPath.getName) + + // Replace the only base file with an empty (zero-size) file. + replaceOneBaseFileWithEmpty(Seq(singlePartition)) + + // Delete MDT to force a full rebootstrap. + metaClient = HoodieTableMetaClient.reload(metaClient) + HoodieTableMetadataUtil.deleteMetadataTable(metaClient, context, false) + assertFalse(storage.exists(new StoragePath(HoodieTableMetadata.getMetadataTableBasePath(basePath))), + "Metadata table should be absent before rebootstrap") + + // The upsert triggers MDT rebootstrap (since MDT was deleted). Skip config is set so + // the zero-size file is skipped during bootstrap. Because RLI has no entries for these + // records (they were in the skipped file), the upsert treats them as new inserts. + // Use global RLI so that the MDT bootstraps at least minFileGroupCount (10) file groups + // for record_index even when all data files were skipped as zero-size. + metaClient.reloadActiveTimeline() + val latestSchema = new TableSchemaResolver(metaClient).getTableSchemaFromLatestCommit(false).get().toString + val optionsWithSkip = baseOptions ++ Map( + HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "true", + HoodieIndexConfig.INDEX_TYPE.key() -> "GLOBAL_RECORD_LEVEL_INDEX", + HoodieMetadataConfig.SKIP_ZERO_SIZE_FILES_ON_INITIALIZE.key() -> "true", + HoodieWriteConfig.AVRO_SCHEMA_STRING.key() -> latestSchema) + + // Reuse the same 10 records for the upsert. Since RLI has no entries for these record keys + // (the original file was zero-size and skipped during bootstrap), the upsert treats them as + // new inserts and writes them to a brand-new file group — NOT the zero-size one. + val upsertDf = toDataset(spark, inserts) + // Upsert should succeed — any exception here fails the test. + upsertDf.write.format("hudi") + .options(optionsWithSkip) + .option(DataSourceWriteOptions.OPERATION.key(), UPSERT_OPERATION_OPT_VAL) + .mode(SaveMode.Append) + .save(basePath) + + // Data consistency check: all 10 records must be readable in the new file group. + // (The zero-size base file contributes 0 readable records; the new file group has 10.) + val readDf = spark.read.format("hudi").load(basePath) + assertEquals(insertedRecords, readDf.count(), + "All records should be readable after upsert into a new file group") + + // RLI consistency check: every live record must be indexed at the location where it actually lives. + metaClient = HoodieTableMetaClient.reload(metaClient) + val writeConfig = getWriteConfig(optionsWithSkip) + val postUpsertMetadata = metadataWriter(writeConfig).getTableMetadata.asInstanceOf[HoodieBackedTableMetadata] + + // MDT files partition must not track the zero-size file group. + val allMdtFiles = getFilesInAllPartitions(postUpsertMetadata) + assertFalse(allMdtFiles.exists(_.getPath.getName.contains(zeroSizeFileId)), + s"MDT should not contain the zero-size file group $zeroSizeFileId after bootstrap with skip enabled") + + // Global RLI: look up all record keys without a partition hint. + val recordKeys = inserts.asScala.map(_.getRecordKey).asJava.stream().collect(Collectors.toList()) + val postUpsertLocations = readRecordIndex(postUpsertMetadata, recordKeys, HOption.empty()) + assertEquals(insertedRecords, postUpsertLocations.size, + "All upserted records should have an RLI entry after upsert") + val df = readDf.collect() + validateDFWithLocations(df, postUpsertLocations, singlePartition) + + // The zero-size file group must not have been selected as the write target — its file ID + // should not appear in any of the post-upsert RLI locations. + assertFalse(postUpsertLocations.values.exists(_.getFileId == zeroSizeFileId), + "The zero-size file group should not have been picked as a write target during upsert") + } + private def replaceOneBaseFileWithEmpty(partitionPaths: Seq[String]): String = { val candidateBaseFile = partitionPaths.view.flatMap { partition => storage.listDirectEntries(new StoragePath(basePath, partition)).asScala diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala index 2f91b6e98b393..fb86709baa89f 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala @@ -230,6 +230,78 @@ class TestSecondaryIndexPruning extends SparkClientFunctionalTestHarnessScala { } } + @Test + def testSecondaryIndexWithNonAsciiSecondaryKeyValues(): Unit = { + var hudiOpts = commonOpts + hudiOpts = hudiOpts ++ Map( + DataSourceWriteOptions.TABLE_TYPE.key -> COW_TABLE_TYPE_OPT_VAL, + DataSourceReadOptions.ENABLE_DATA_SKIPPING.key -> "true") + tableName += "test_secondary_index_non_ascii_partitioned_cow" + + // These two secondary values have UTF-16 order reversed vs their raw UTF-8 byte order: + // U+E000 encodes to bytes EE 80 80 while U+20000 encodes to F0 A0 80 80, so U+E000 sorts + // before U+20000 by UTF-8 bytes; but in UTF-16 the U+20000 surrogate pair (D840 DC00) + // sorts before the single U+E000 code unit. The fix orders metadata keys by UTF-8 bytes, + // matching HFile, so both lookups must still resolve to the correct row. + val bmpVal = new String(Character.toChars(0xE000)) + "acme" + val astralVal = new String(Character.toChars(0x20000)) + "acme" + val asciiVal = "acme" + + spark.sql( + s""" + |create table $tableName ( + | ts bigint, + | record_key_col string, + | not_record_key_col string, + | partition_key_col string + |) using hudi + | options ( + | primaryKey ='record_key_col', + | type = 'cow', + | hoodie.metadata.enable = 'true', + | hoodie.metadata.record.index.enable = 'true', + | hoodie.datasource.write.recordkey.field = 'record_key_col', + | hoodie.enable.data.skipping = 'true', + | hoodie.datasource.write.payload.class = "org.apache.hudi.common.model.OverwriteWithLatestAvroPayload" + | ) + | partitioned by(partition_key_col) + | location '$basePath' + """.stripMargin) + // small file limit 0 so each insert lands in its own file, giving data skipping something to prune + withSQLConf("hoodie.parquet.small.file.limit" -> "0") { + spark.sql(s"insert into $tableName values(1, 'row1', '$bmpVal', 'p1')") + spark.sql(s"insert into $tableName values(2, 'row2', '$astralVal', 'p2')") + spark.sql(s"insert into $tableName values(3, 'row3', '$asciiVal', 'p3')") + // create secondary index on the column holding the non-ascii values + spark.sql(s"create index idx_not_record_key_col on $tableName (not_record_key_col)") + metaClient = HoodieTableMetaClient.builder() + .setBasePath(basePath) + .setConf(HoodieTestUtils.getDefaultStorageConf) + .build() + assert(metaClient.getTableConfig.getMetadataPartitions.contains("secondary_index_idx_not_record_key_col")) + // non-ascii secondary values must be preserved verbatim in the secondary index records + checkAnswer(s"select key from hudi_metadata('$basePath') where type=7")( + Seq(bmpVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row1"), + Seq(astralVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row2"), + Seq(asciiVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row3") + ) + withSQLConf("hoodie.metadata.enable" -> "true", + "hoodie.enable.data.skipping" -> "true", + "hoodie.fileIndex.dataSkippingFailureMode" -> "strict") { + // each non-ascii equality predicate must resolve to exactly its own row via the SI prefix lookup + checkAnswer(s"select ts, record_key_col, not_record_key_col, partition_key_col from $tableName where not_record_key_col = '$bmpVal'")( + Seq(1, "row1", bmpVal, "p1") + ) + checkAnswer(s"select ts, record_key_col, not_record_key_col, partition_key_col from $tableName where not_record_key_col = '$astralVal'")( + Seq(2, "row2", astralVal, "p2") + ) + // data skipping must prune files using the non-ascii secondary keys + verifyFilePruning(hudiOpts, EqualTo(attribute("not_record_key_col"), Literal(bmpVal))) + verifyFilePruning(hudiOpts, EqualTo(attribute("not_record_key_col"), Literal(astralVal))) + } + } + } + @Test def testCreateAndDropSecondaryIndex(): Unit = { var hudiOpts = commonOpts diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala index a35d49993bd58..ffaae686eb4d1 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala @@ -20,6 +20,7 @@ package org.apache.hudi.functional import org.apache.hudi.DataSourceReadOptions import org.apache.hudi.DataSourceReadOptions.{START_OFFSET, STREAMING_READ_TABLE_VERSION} import org.apache.hudi.DataSourceWriteOptions.{ORDERING_FIELDS, RECORDKEY_FIELD} +import org.apache.hudi.common.config.HoodieReaderConfig import org.apache.hudi.common.model.HoodieTableType import org.apache.hudi.common.model.HoodieTableType.{COPY_ON_WRITE, MERGE_ON_READ} import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient, HoodieTableVersion} @@ -294,6 +295,66 @@ class TestStreamingSource extends StreamTest { }) } + /** + * Exercises the legacy incremental streaming path in [[HoodieStreamSourceV1]], which is taken + * when the streaming read table version is below EIGHT and the file group reader is disabled. + * This drives the [[IncrementalRelationV1]] (COW) / [[MergeOnReadIncrementalRelationV1]] (MOR) + * branches of `getBatch` rather than the newer HadoopFsRelation factory path. + */ + private def testLegacyIncrementalStreamSource(tableType: HoodieTableType): Unit = { + withTempDir { inputDir => + val tablePath = s"${inputDir.getCanonicalPath}/test_${tableType.name}_legacy_stream" + HoodieTableMetaClient.newTableBuilder() + .setTableType(tableType) + .setTableName(getTableName(tablePath)) + .setTableVersion(HoodieTableVersion.SIX) + .setRecordKeyFields("id") + .setOrderingFields("ts") + .initTable(HadoopFSUtils.getStorageConf(spark.sessionState.newHadoopConf()), tablePath) + + addData(tablePath, Seq(("1", "a1", "10", "000")), tableVersion = HoodieTableVersion.SIX) + val df = spark.readStream + .format("org.apache.hudi") + .option(WRITE_TABLE_VERSION.key, HoodieTableVersion.SIX.versionCode().toString) + .option(STREAMING_READ_TABLE_VERSION.key, HoodieTableVersion.SIX.versionCode().toString) + // force the legacy (non file-group-reader) incremental relation path + .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "false") + .load(tablePath) + .select("id", "name", "price", "ts") + + testStream(df)( + AssertOnQuery { q => q.processAllAvailable(); true }, + CheckAnswerRows(Seq(Row("1", "a1", "10", "000")), lastOnly = true, isSorted = false), + StopStream, + + addDataToQuery(tablePath, + Seq(("2", "a2", "12", "000"), + ("3", "a3", "12", "000")), + tableVersion = HoodieTableVersion.SIX), + StartStream(), + AssertOnQuery { q => q.processAllAvailable(); true }, + CheckAnswerRows( + Seq(Row("2", "a2", "12", "000"), + Row("3", "a3", "12", "000")), + lastOnly = true, isSorted = false), + StopStream, + + addDataToQuery(tablePath, Seq(("4", "a4", "13", "000")), tableVersion = HoodieTableVersion.SIX), + StartStream(), + AssertOnQuery { q => q.processAllAvailable(); true }, + CheckAnswerRows(Seq(Row("4", "a4", "13", "000")), lastOnly = true, isSorted = false) + ) + } + } + + test("test cow stream source with legacy file group reader disabled") { + testLegacyIncrementalStreamSource(COPY_ON_WRITE) + } + + test("test mor stream source with legacy file group reader disabled") { + testLegacyIncrementalStreamSource(MERGE_ON_READ) + } + private def testCheckpointTranslation(tableName: String, tableType: HoodieTableType, writeTableVersion: HoodieTableVersion, @@ -413,9 +474,10 @@ class TestStreamingSource extends StreamTest { } private def addDataToQuery(inputPath: String, - rows: Seq[(String, String, String, String)]): AssertOnQuery = { + rows: Seq[(String, String, String, String)], + tableVersion: HoodieTableVersion = HoodieTableVersion.current): AssertOnQuery = { AssertOnQuery { _=> - addData(inputPath, rows) + addData(inputPath, rows, tableVersion = tableVersion) true } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala index 2ae305a8ccc08..b9a672bb08826 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala @@ -508,6 +508,52 @@ class TestStructuredStreaming extends HoodieSparkClientTestBase { assertEquals(25, metaClient.getActiveTimeline.countInstants()) } + /** + * Injects a failing micro-batch into [[HoodieStreamingSink]] by pointing the record key at a + * non-existent column so that every Hudi write throws. With STREAMING_IGNORE_FAILED_BATCH + * enabled the sink must swallow the failure (unpersisting the write RDDs), let the streaming + * query complete without crashing, and produce no commit. + */ + @Test + def testStructuredStreamingIgnoreFailedBatch(): Unit = { + val (sourcePath, destPath) = initStreamingSourceAndDestPath("source", "dest") + val records1 = recordsToStrings(dataGen.generateInsertsForPartition( + "000", 100, HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH)).asScala.toList + val inputDF1 = spark.read.json(spark.sparkContext.parallelize(records1, 2)) + val schema = inputDF1.schema + inputDF1.coalesce(1).write.mode(SaveMode.Append).json(sourcePath) + + val failingOpts = commonOpts ++ Map( + DataSourceWriteOptions.RECORDKEY_FIELD.key -> "non_existent_field", + DataSourceWriteOptions.STREAMING_IGNORE_FAILED_BATCH.key -> "true", + DataSourceWriteOptions.STREAMING_RETRY_CNT.key -> "1" + ) + + val query = spark.readStream + .schema(schema) + .json(sourcePath) + .writeStream + .format("org.apache.hudi") + .options(failingOpts) + .outputMode(OutputMode.Append) + .option("checkpointLocation", s"$basePath/checkpoint_ignore") + .start(destPath) + + // Must not throw even though the micro-batch fails; the failure is ignored. + query.processAllAvailable() + query.stop() + + // No completed commit must exist since every batch failed and was ignored. + val completedCommits = + try { + HoodieTestUtils.createMetaClient(storage, destPath) + .getActiveTimeline.getCommitsTimeline.filterCompletedInstants().countInstants() + } catch { + case _: TableNotFoundException => 0 + } + assertEquals(0, completedCommits) + } + @ParameterizedTest @CsvSource(Array( "COPY_ON_WRITE,EVENT_TIME_ORDERING", diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala index 51905c00caa4e..4bada04ab29d7 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala @@ -21,6 +21,7 @@ package org.apache.hudi.functional.cdc import org.apache.hudi.DataSourceWriteOptions import org.apache.hudi.DataSourceWriteOptions.{MOR_TABLE_TYPE_OPT_VAL, PARTITIONPATH_FIELD_OPT_KEY, PRECOMBINE_FIELD_OPT_KEY, RECORDKEY_FIELD_OPT_KEY} import org.apache.hudi.QuickstartUtils.getQuickstartWriteConfigs +import org.apache.hudi.common.model.HoodieRecord import org.apache.hudi.common.table.{HoodieTableConfig, TableSchemaResolver} import org.apache.hudi.common.table.cdc.{HoodieCDCOperation, HoodieCDCSupplementalLoggingMode} import org.apache.hudi.common.table.cdc.HoodieCDCSupplementalLoggingMode.OP_KEY_ONLY @@ -959,4 +960,64 @@ class TestCDCDataFrameSuite extends HoodieCDCTestBase { } assertTrue(newRecordFound, "Should have found the new record with complex data types in CDC") } + + /** + * Regression test for HUDI-14363: the CDC incremental query must produce before/after images + * that contain only business columns, never the _hoodie_* meta columns. Before the fix, images + * inferred directly from base/log data files (e.g. the BASE_FILE_INSERT case, which is hit by an + * insert-only commit that writes no CDC log file) leaked the meta columns, while images served + * from the supplemental CDC log did not - producing an inconsistent, alternating-per-commit + * schema. This reproduces the reporter's scenario (MOR + inline compaction every delta commit + + * upsert inserts) and asserts no image carries meta fields for any supplemental logging mode. + */ + @ParameterizedTest + @EnumSource(classOf[HoodieCDCSupplementalLoggingMode]) + def testCDCImagesExcludeHoodieMetaFields(loggingMode: HoodieCDCSupplementalLoggingMode): Unit = { + val options = commonOpts ++ Map( + DataSourceWriteOptions.TABLE_TYPE.key() -> DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL, + HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key -> loggingMode.name(), + "hoodie.compact.inline" -> "true", + "hoodie.compact.inline.max.delta.commits" -> "1" + ) + + // 1. Insert - this commit writes no CDC log file, so its change data is inferred from the base + // file via the BASE_FILE_INSERT case (the path that previously leaked _hoodie_* meta columns). + val records1 = recordsToStrings(dataGen.generateInserts("000", 100)).asScala.toList + spark.read.json(spark.sparkContext.parallelize(records1, 2)) + .write.format("org.apache.hudi") + .options(options) + .mode(SaveMode.Overwrite) + .save(basePath) + metaClient = createMetaClient(spark, basePath) + val instant1 = metaClient.reloadActiveTimeline.lastInstant().get() + assertFalse(hasCDCLogFile(instant1)) + val commitTime1 = instant1.requestedTime + + // 2. Upsert (updates + new inserts) - exercises the supplemental CDC log (AS_IS) path too. + val updates = recordsToStrings(dataGen.generateUniqueUpdates("001", 30)).asScala.toList + val inserts = recordsToStrings(dataGen.generateInserts("001", 20)).asScala.toList + spark.read.json(spark.sparkContext.parallelize(updates ++ inserts, 2)) + .write.format("org.apache.hudi") + .options(options) + .mode(SaveMode.Append) + .save(basePath) + + // Read all change data and assert no before/after image contains a Hudi meta column. Note we + // check the actual meta-column names rather than the "_hoodie_" prefix: _hoodie_is_deleted is a + // business/payload field (the soft-delete marker carried in the record schema), not a meta + // column, so it is expected to remain in the image. + val allCDCData = cdcDataFrame((commitTime1.toLong - 1).toString).collect() + assertTrue(allCDCData.nonEmpty, "Expected some CDC rows") + allCDCData.foreach { row => + Seq("before", "after").foreach { col => + val json = row.getAs[String](col) + if (json != null) { + HoodieRecord.HOODIE_META_COLUMNS_WITH_OPERATION.asScala.foreach { metaCol => + assertFalse(json.contains(metaCol), + s"$col image should not contain meta column $metaCol, but was: $json") + } + } + } + } + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieAnalysisErrorHandling.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieAnalysisErrorHandling.scala new file mode 100644 index 0000000000000..06e31163df43c --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieAnalysisErrorHandling.scala @@ -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.spark.sql.hudi.analysis + +import org.apache.hudi.HoodieSparkUtils + +import org.apache.spark.sql.AnalysisException +import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase + +/** + * Regression tests covering how Hudi's analysis surface unresolved references in + * Spark SQL queries. Hudi's [[ProducesHudiMetaFields]] extractor and the MERGE INTO + * resolution path in [[HoodieSparkBaseAnalysis]] used to swallow the + * [[org.apache.spark.sql.catalyst.analysis.UnresolvedException]] and rewrite it as a + * generic Hudi error, which lost Spark's "did you mean" suggestions. Both sites now + * fall through to Spark's CheckAnalysis so the user-facing error remains + * Spark-native (e.g. `UNRESOLVED_COLUMN.WITH_SUGGESTION`). + */ +class TestHoodieAnalysisErrorHandling extends HoodieSparkSqlTestBase { + + test("MERGE INTO with unresolved column in source query surfaces Spark's native error") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |CREATE TABLE $tableName ( + | id INT, + | name STRING, + | price DOUBLE, + | ts INT + |) USING hudi + |LOCATION '${tmp.getCanonicalPath}' + |TBLPROPERTIES ( + | primaryKey = 'id', + | preCombineField = 'ts' + |) + """.stripMargin) + + spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 10.0, 1000)") + + // Source query references a non-existent column. Spark's analyzer + // should produce a precise error naming the missing column. + val ex = intercept[AnalysisException] { + spark.sql( + s""" + |MERGE INTO $tableName AS target + |USING ( + | SELECT 1 AS id, 'updated' AS name, 20.0 AS price, 2000 AS ts, nonexistent_column AS extra + |) AS source + |ON target.id = source.id + |WHEN MATCHED THEN UPDATE SET * + |WHEN NOT MATCHED THEN INSERT * + """.stripMargin) + } + val msg = ex.getMessage + assertNativeUnresolvedColumn(msg, "nonexistent_column") + assertNoHudiGenericRewrite(msg) + } + } + + test("INSERT INTO from non-existent source table surfaces Spark's native error") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |CREATE TABLE $tableName ( + | id INT, + | name STRING, + | price DOUBLE, + | ts INT + |) USING hudi + |LOCATION '${tmp.getCanonicalPath}' + |TBLPROPERTIES ( + | primaryKey = 'id', + | preCombineField = 'ts' + |) + """.stripMargin) + + val ex = intercept[AnalysisException] { + spark.sql( + s""" + |INSERT INTO $tableName + |SELECT * FROM nonexistent_source_table + """.stripMargin) + } + val msg = ex.getMessage + assertNativeTableNotFound(msg, "nonexistent_source_table") + assertNoHudiGenericRewrite(msg) + } + } + + test("MERGE INTO with unresolved column in ON predicate surfaces Spark's native error") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |CREATE TABLE $tableName ( + | id INT, name STRING, price DOUBLE, ts INT + |) USING hudi + |LOCATION '${tmp.getCanonicalPath}' + |TBLPROPERTIES (primaryKey = 'id', preCombineField = 'ts') + """.stripMargin) + spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 10.0, 1000)") + + val ex = intercept[AnalysisException] { + spark.sql( + s""" + |MERGE INTO $tableName AS target + |USING (SELECT 1 AS id, 'u' AS name, 20.0 AS price, 2000 AS ts) AS source + |ON target.nonexistent_id = source.id + |WHEN MATCHED THEN UPDATE SET * + |WHEN NOT MATCHED THEN INSERT * + """.stripMargin) + } + val msg = ex.getMessage + assertNativeUnresolvedColumn(msg, "nonexistent_id") + assertNoHudiGenericRewrite(msg) + } + } + + /** + * Assert the failure is Spark's native unresolved-column error and not a Hudi rewrite. + * + * On Spark 3.4+ we require the structured `UNRESOLVED_COLUMN` error-class token. That + * token is produced only by Spark's own `CheckAnalysis`; the pre-PR Hudi path rewrote + * the failure as a generic "Failed to resolve query ..." message that never carried it, + * so requiring the token here actually distinguishes the new behavior from the old (the + * looser "cannot be resolved" substring would have matched either way — see + * https://github.com/apache/hudi/pull/18147#discussion_r2795763747). On Spark 3.3, which + * predates error classes, the phrasing varies by code path — accept the legacy + * "cannot resolve" / "cannot be resolved" forms as well as "Column '...' does not exist" + * (what Spark 3.3 emits for the unresolved references in these queries). + * + * In all cases require the offending column name to appear, so we know the precise + * column was reported rather than some unrelated resolution failure. + */ + private def assertNativeUnresolvedColumn(msg: String, columnName: String): Unit = { + if (HoodieSparkUtils.gteqSpark3_4) { + assert(msg.contains("UNRESOLVED_COLUMN"), + s"Expected Spark's structured UNRESOLVED_COLUMN error class; got: $msg") + } else { + assert(msg.contains("cannot resolve") || msg.contains("cannot be resolved") || + msg.contains("does not exist"), + s"Expected Spark's native unresolved-column error; got: $msg") + } + assert(msg.contains(columnName), + s"Expected error to mention column '$columnName'; got: $msg") + } + + /** + * Assert the failure is Spark's native table-not-found error. On Spark 3.4+ require the + * structured `TABLE_OR_VIEW_NOT_FOUND` error-class token (same reasoning as + * [[assertNativeUnresolvedColumn]]); on Spark 3.3 fall back to the legacy phrasing. + */ + private def assertNativeTableNotFound(msg: String, tableName: String): Unit = { + if (HoodieSparkUtils.gteqSpark3_4) { + assert(msg.contains("TABLE_OR_VIEW_NOT_FOUND"), + s"Expected Spark's structured TABLE_OR_VIEW_NOT_FOUND error class; got: $msg") + } else { + assert(msg.toLowerCase.contains("table or view not found"), + s"Expected Spark's native table-not-found error; got: $msg") + } + assert(msg.contains(tableName), + s"Expected error to mention table '$tableName'; got: $msg") + } + + /** + * The earlier version of this PR caught UnresolvedException and rewrote it as + * "Failed to resolve query. The query contains unresolved columns or tables. + * Please check for: (1) typos ...". Make sure we no longer mask Spark's + * native message with that generic Hudi wrapper. + */ + private def assertNoHudiGenericRewrite(msg: String): Unit = { + assert(!msg.contains("Failed to resolve query"), + s"Hudi should not rewrap Spark's analysis error; got: $msg") + assert(!msg.contains("Please check for: (1) typos"), + s"Hudi should not rewrap Spark's analysis error; got: $msg") + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala new file mode 100644 index 0000000000000..c607b434580c8 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/catalog/TestHoodieCatalogStagedTable.scala @@ -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.spark.sql.hudi.catalog + +import org.apache.hudi.exception.HoodieException +import org.apache.hudi.testutils.HoodieClientTestUtils + +import org.apache.spark.api.java.JavaSparkContext +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.connector.catalog.{Identifier, StagedTable, SupportsWrite, Table, TableCatalog} +import org.apache.spark.sql.connector.expressions.Transform +import org.apache.spark.sql.connector.write.{LogicalWriteInfo, WriteBuilder} +import org.apache.spark.sql.types.{IntegerType, StructField, StructType} +import org.junit.jupiter.api.{AfterAll, BeforeAll, TestInstance} +import org.junit.jupiter.api.Assertions.{assertEquals, assertSame, assertThrows, assertTrue} +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito.{mock, when} + +import java.util + +import scala.collection.JavaConverters._ + +/** + * Tests the staging methods of {@link HoodieCatalog} for a non-Hudi table, which stage through + * {@link BasicStagedTable} and hand the write off to the delegate catalog's table. + */ +@TestInstance(Lifecycle.PER_CLASS) +class TestHoodieCatalogStagedTable { + + private val ident = Identifier.of(Array("default"), "tbl") + private val schema = StructType(Seq(StructField("id", IntegerType))) + private val partitions = Array.empty[Transform] + // Not a Hudi provider, so the staging methods take the BasicStagedTable branch + private val properties: util.Map[String, String] = Map("provider" -> "parquet").asJava + + private var sparkSession: SparkSession = _ + + @BeforeAll + def setUp(): Unit = { + val jsc = new JavaSparkContext( + HoodieClientTestUtils.getSparkConfForTest(classOf[TestHoodieCatalogStagedTable].getName)) + jsc.setLogLevel("ERROR") + // HoodieCatalog resolves SparkSession.active in its constructor + sparkSession = SparkSession.builder.config(jsc.getConf).getOrCreate + } + + @AfterAll + def tearDown(): Unit = { + sparkSession.close() + } + + @ParameterizedTest + @ValueSource(strings = Array("stageCreate", "stageReplace", "stageCreateOrReplace")) + def testStagedWriteIsDelegatedToWritableTable(stagingMethod: String): Unit = { + val delegateTable = mock(classOf[SupportsWrite]) + val info = mock(classOf[LogicalWriteInfo]) + val writeBuilder = mock(classOf[WriteBuilder]) + when(delegateTable.newWriteBuilder(info)).thenReturn(writeBuilder) + val delegate = mock(classOf[TableCatalog]) + when(delegate.createTable(ident, schema, partitions, properties)).thenReturn(delegateTable) + + val staged = stage(stagingMethod, delegate) + + assertSame(writeBuilder, staged.asInstanceOf[SupportsWrite].newWriteBuilder(info)) + } + + @ParameterizedTest + @ValueSource(strings = Array("stageCreate", "stageReplace", "stageCreateOrReplace")) + def testStagedTableIsLoadedWhenDelegateCreateTableReturnsNull(stagingMethod: String): Unit = { + // V2SessionCatalog, the default delegate, returns null from createTable by design, to save the loadTable call + val delegate = mock(classOf[TableCatalog]) + val loadedTable = mock(classOf[Table]) + when(loadedTable.schema()).thenReturn(schema) + when(delegate.loadTable(ident)).thenReturn(loadedTable) + + val staged = stage(stagingMethod, delegate) + + // The staged table is backed by the table that was just created, rather than by null + assertEquals(schema, staged.schema()) + // It is not writable, so the write is rejected instead of being delegated + val ex = assertThrows(classOf[HoodieException], + () => staged.asInstanceOf[SupportsWrite].newWriteBuilder(mock(classOf[LogicalWriteInfo]))) + assertTrue(ex.getMessage.contains("`tbl` does not support writes")) + } + + private def stage(stagingMethod: String, delegate: TableCatalog): StagedTable = { + val catalog = new HoodieCatalog() + catalog.setDelegateCatalog(delegate) + stagingMethod match { + case "stageCreate" => catalog.stageCreate(ident, schema, partitions, properties) + case "stageReplace" => catalog.stageReplace(ident, schema, partitions, properties) + case "stageCreateOrReplace" => catalog.stageCreateOrReplace(ident, schema, partitions, properties) + } + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala index ead9943421620..ab70edb18b4ef 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/ddl/TestCreateTable.scala @@ -416,6 +416,46 @@ class TestCreateTable extends HoodieSparkSqlTestBase { Seq(1, "a1", 10, "2021-04-01") ) + // Create table with multi-level partition + val tableNameMultiLevelPartition = generateTableName + spark.sql( + s""" + | create table $tableNameMultiLevelPartition using hudi + | partitioned by (year, month, day) + | tblproperties( + | primaryKey = 'id', + | type = '$tableType' + | ) + | location '${tmp.getCanonicalPath}/$tableNameMultiLevelPartition' + | AS + | select 1 as id, 'a1' as name, 10 as price, '2021' as year, '04' as month, '01' as day + """.stripMargin + ) + + checkAnswer(s"select id, name, price, year, month, day from $tableNameMultiLevelPartition")( + Seq(1, "a1", 10, "2021", "04", "01") + ) + + // Create table with multi-level partition and out-of-order partition columns + val tableNameMultiLevelPartitionDisorder = generateTableName + spark.sql( + s""" + | create table $tableNameMultiLevelPartitionDisorder using hudi + | partitioned by (year, month, day) + | tblproperties( + | primaryKey = 'id', + | type = '$tableType' + | ) + | location '${tmp.getCanonicalPath}/$tableNameMultiLevelPartitionDisorder' + | AS + | select 1 as id, 'a1' as name, 10 as price, '04' as month, '01' as day, '2021' as year + """.stripMargin + ) + + checkAnswer(s"select id, name, price, year, month, day from $tableNameMultiLevelPartitionDisorder")( + Seq(1, "a1", 10, "2021", "04", "01") + ) + // Create Partitioned table with timestamp data type val tableName3 = generateTableName // CTAS failed with null primaryKey @@ -2024,6 +2064,11 @@ class TestCreateTable extends HoodieSparkSqlTestBase { // Verify structure matches blob schema assertTrue(videoField.dataType.isInstanceOf[StructType]) assertEquals(BlobType(), videoField.dataType) + + // The catalog-stored copy of the schema must retain the hudi_type metadata too + val catalogField = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) + .schema.find(_.name == "video").get + assertEquals(HoodieSchemaType.BLOB.name(), catalogField.metadata.getString(HoodieSchema.TYPE_METADATA_FIELD)) } } @@ -2143,6 +2188,11 @@ class TestCreateTable extends HoodieSparkSqlTestBase { assertEquals("VECTOR(128)", embeddingField.metadata.getString(HoodieSchema.TYPE_METADATA_FIELD)) assertEquals("document embedding", embeddingField.metadata.getString("comment")) assertEquals(ArrayType(FloatType, containsNull = false), embeddingField.dataType) + + // The catalog-stored copy of the schema must retain the hudi_type metadata too + val catalogField = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) + .schema.find(_.name == "embedding").get + assertEquals("VECTOR(128)", catalogField.metadata.getString(HoodieSchema.TYPE_METADATA_FIELD)) } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/TestDataSkippingQuery.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/TestDataSkippingQuery.scala index 60513bd9c0288..38cc323f58c6b 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/TestDataSkippingQuery.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/TestDataSkippingQuery.scala @@ -208,4 +208,66 @@ class TestDataSkippingQuery extends HoodieSparkSqlTestBase { } } } + + test("Test column stats data skipping across multiple files with range, IN and equality predicates") { + Seq("cow", "mor").foreach { tableType => + withTempDir { tmp => + val tableName = generateTableName + withSQLConf( + "hoodie.metadata.enable" -> "true", + "hoodie.metadata.index.column.stats.enable" -> "true", + "hoodie.enable.data.skipping" -> "true", + "hoodie.metadata.index.column.stats.column.list" -> "id,price", + // keep every commit in its own base file so column-stats pruning has multiple files to skip + "hoodie.parquet.small.file.limit" -> "0" + ) { + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + |) using hudi + | tblproperties (primaryKey = 'id', orderingFields = 'ts', type = '$tableType') + | location '${tmp.getCanonicalPath}' + """.stripMargin) + // Three separate commits, each landing in its own base file with disjoint id / price ranges. + spark.sql(s"insert into $tableName values (1, 'a1', 10, 1000), (2, 'a2', 20, 1000)") + spark.sql(s"insert into $tableName values (11, 'b1', 110, 2000), (12, 'b2', 120, 2000)") + spark.sql(s"insert into $tableName values (21, 'c1', 210, 3000), (22, 'c2', 220, 3000)") + + // Equality on the indexed key column -> only the first file qualifies. + checkAnswer(s"select id, name, price from $tableName where id = 1")( + Seq(1, "a1", 10.0) + ) + // Range predicate that only the last file can satisfy. + checkAnswer(s"select id, name, price from $tableName where id > 20")( + Seq(21, "c1", 210.0), + Seq(22, "c2", 220.0) + ) + // IN predicate touching the first and last files, skipping the middle one. + checkAnswer(s"select id, name, price from $tableName where id in (2, 22)")( + Seq(2, "a2", 20.0), + Seq(22, "c2", 220.0) + ) + // Range on a second indexed column keeps only the middle file. + checkAnswer(s"select id, name, price from $tableName where price >= 110 and price < 210")( + Seq(11, "b1", 110.0), + Seq(12, "b2", 120.0) + ) + // Predicate that matches nothing -> all files pruned, empty result. + checkAnswer(s"select id, name, price from $tableName where id = 999")() + + // Data skipping must not change results compared to a full scan. + withSQLConf("hoodie.enable.data.skipping" -> "false") { + checkAnswer(s"select id, name, price from $tableName where id in (2, 22)")( + Seq(2, "a2", 20.0), + Seq(22, "c2", 220.0) + ) + } + } + } + } + } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/index/TestIndexSyntax.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/index/TestIndexSyntax.scala index 8fb3fd8648c03..2e186ae5347b4 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/index/TestIndexSyntax.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/feature/index/TestIndexSyntax.scala @@ -26,7 +26,7 @@ import org.apache.hudi.metadata.HoodieTableMetadataUtil import org.apache.spark.sql.catalyst.analysis.Analyzer import org.apache.spark.sql.catalyst.catalog.CatalogTable import org.apache.spark.sql.catalyst.parser.ParserInterface -import org.apache.spark.sql.hudi.command.{CreateIndexCommand, DropIndexCommand, ShowIndexesCommand} +import org.apache.spark.sql.hudi.command.{CreateIndexCommand, DropIndexCommand, RefreshIndexCommand, ShowIndexesCommand} import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase import org.junit.jupiter.api.Assertions.{assertFalse, assertTrue} @@ -90,6 +90,11 @@ class TestIndexSyntax extends HoodieSparkSqlTestBase { assertTableIdentifier(resolvedLogicalPlan.asInstanceOf[DropIndexCommand].table, databaseName, tableName) assertResult("idx_name")(resolvedLogicalPlan.asInstanceOf[DropIndexCommand].indexName) assertResult(true)(resolvedLogicalPlan.asInstanceOf[DropIndexCommand].ignoreIfNotExists) + + logicalPlan = sqlParser.parsePlan(s"refresh index idx_name on $tableName") + resolvedLogicalPlan = analyzer.execute(logicalPlan) + assertTableIdentifier(resolvedLogicalPlan.asInstanceOf[RefreshIndexCommand].table, databaseName, tableName) + assertResult("idx_name")(resolvedLogicalPlan.asInstanceOf[RefreshIndexCommand].indexName) } } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestArchiveCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestArchiveCommitsProcedure.scala index c81ffcfb59d6f..e5be572eb65f1 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestArchiveCommitsProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestArchiveCommitsProcedure.scala @@ -21,52 +21,121 @@ package org.apache.spark.sql.hudi.procedure class TestArchiveCommitsProcedure extends HoodieSparkProcedureTestBase { - test("Test Call archive_commits Procedure by Table") { + /** + * Helper: create a fresh COW table at the given location with `numCommits` + * insert commits already written. Returns the table name. + */ + private def createTableWithCommits(location: String, numCommits: Int): String = { + val tableName = generateTableName + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + | ) using hudi + | location '$location' + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | orderingFields = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + + (1 to numCommits).foreach { i => + spark.sql(s"insert into $tableName values($i, 'a$i', ${i * 10}, ${i * 1000})") + } + tableName + } + + test("Test Call archive_commits Procedure with named parameters") { withTempDir { tmp => - val tableName = generateTableName - spark.sql( - s""" - |create table $tableName ( - | id int, - | name string, - | price double, - | ts long - | ) using hudi - | location '${tmp.getCanonicalPath}' - | tblproperties ( - | primaryKey = 'id', - | type = 'cow', - | orderingFields = 'ts', - | hoodie.metadata.enable = "false" - | ) - |""".stripMargin) - - spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)") - spark.sql(s"insert into $tableName values(2, 'a2', 20, 2000)") - spark.sql(s"insert into $tableName values(3, 'a3', 30, 3000)") - spark.sql(s"insert into $tableName values(4, 'a4', 40, 4000)") - spark.sql(s"insert into $tableName values(5, 'a5', 50, 5000)") - spark.sql(s"insert into $tableName values(6, 'a6', 60, 6000)") - - val result1 = spark.sql(s"call archive_commits(table => '$tableName'" + - s", min_commits => 2, max_commits => 3, retain_commits => 1, enable_metadata => false)") + val tableName = createTableWithCommits(tmp.getCanonicalPath, 6) + + val result = spark.sql( + s"call archive_commits(table => '$tableName'," + + " min_commits => 2, max_commits => 3, retain_commits => 1, enable_metadata => false)") .collect() .map(row => Seq(row.getInt(0))) - assertResult(1)(result1.length) - assertResult(0)(result1(0).head) + assertResult(1)(result.length) + assertResult(0)(result(0).head) - // collect active commits for table - val commits = spark.sql(s"""call show_commits(table => '$tableName', limit => 10)""").collect() - assertResult(2) { - commits.length - } + val commits = spark.sql(s"call show_commits(table => '$tableName', limit => 10)").collect() + assertResult(2)(commits.length) + + val endTs = commits(0).get(0).toString + val archived = spark.sql( + s"call show_archived_commits(table => '$tableName', end_ts => '$endTs')").collect() + assertResult(4)(archived.length) + } + } + + test("Test Call archive_commits Procedure driven only by options") { + withTempDir { tmp => + val tableName = createTableWithCommits(tmp.getCanonicalPath, 6) + + // No min/max named params — archival behavior must come from `options` alone. + // This used to fail (Expected 2, but got 6) because withArchivalConfig#putAll + // would overwrite hoodie.keep.min.commits/hoodie.keep.max.commits from + // user props with the procedure's named-default min=20/max=30. + val result = spark.sql( + s"call archive_commits(table => '$tableName'," + + " retain_commits => 1," + + " options => 'hoodie.keep.min.commits=2,hoodie.keep.max.commits=3," + + "hoodie.commits.archival.batch=1,hoodie.metadata.enable=false')") + .collect() + .map(row => Seq(row.getInt(0))) + assertResult(1)(result.length) + assertResult(0)(result(0).head) + + val commits = spark.sql(s"call show_commits(table => '$tableName', limit => 10)").collect() + assertResult(2)(commits.length) - // collect archived commits for table val endTs = commits(0).get(0).toString - val archivedCommits = spark.sql(s"""call show_archived_commits(table => '$tableName', end_ts => '$endTs')""").collect() - assertResult(4) { - archivedCommits.length + val archived = spark.sql( + s"call show_archived_commits(table => '$tableName', end_ts => '$endTs')").collect() + assertResult(4)(archived.length) + } + } + + test("Test Call archive_commits Procedure: named parameters override options") { + withTempDir { tmp => + val tableName = createTableWithCommits(tmp.getCanonicalPath, 6) + + // options requests min=10/max=20 (would archive nothing for 6 commits), + // but named min_commits=2/max_commits=3 must take precedence. + val result = spark.sql( + s"call archive_commits(table => '$tableName'," + + " min_commits => 2, max_commits => 3, retain_commits => 1, enable_metadata => false," + + " options => 'hoodie.keep.min.commits=10,hoodie.keep.max.commits=20')") + .collect() + .map(row => Seq(row.getInt(0))) + assertResult(1)(result.length) + assertResult(0)(result(0).head) + + val commits = spark.sql(s"call show_commits(table => '$tableName', limit => 10)").collect() + // named params won → archival happened, only 2 active commits left + assertResult(2)(commits.length) + + val endTs = commits(0).get(0).toString + val archived = spark.sql( + s"call show_archived_commits(table => '$tableName', end_ts => '$endTs')").collect() + assertResult(4)(archived.length) + } + } + + test("Test Call archive_commits Procedure: invalid options string fails fast") { + withTempDir { tmp => + val tableName = createTableWithCommits(tmp.getCanonicalPath, 2) + + val ex = intercept[IllegalArgumentException] { + spark.sql( + s"call archive_commits(table => '$tableName', options => 'invalid_token')") + .collect() } + assert(ex.getMessage.contains("Invalid options format")) } } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCleanupStaleInflightCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCleanupStaleInflightCommitsProcedure.scala new file mode 100644 index 0000000000000..f18a173e29ad1 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCleanupStaleInflightCommitsProcedure.scala @@ -0,0 +1,413 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.hudi.procedure + +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline} +import org.apache.hudi.common.util.{Option => HOption} +import org.apache.hudi.hadoop.fs.HadoopFSUtils + +import java.text.SimpleDateFormat +import java.util.Date + +import scala.collection.JavaConverters._ + +class TestCleanupStaleInflightCommitsProcedure extends HoodieSparkProcedureTestBase { + + /** + * Creates a table DDL without inserting data. Tests that manipulate inflight instants must NOT + * insert data before injecting the inflight, because BaseRollbackActionExecutor + * .validateRollbackCommitSequence throws HoodieRollbackException when committed instants exist + * after the injected (old) timestamp and no heartbeat exists for the injected instant. + * With no prior inserts, commitTimeline.empty() = true and the guard is bypassed. + */ + private def createEmptyTable(tableName: String, tablePath: String): Unit = { + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + | ) using hudi + | location '$tablePath' + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | preCombineField = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + } + + private def createEmptyPartitionedTable(tableName: String, tablePath: String, tableType: String): Unit = { + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long, + | part int + | ) using hudi + | partitioned by (part) + | location '$tablePath' + | tblproperties ( + | primaryKey = 'id', + | type = '$tableType', + | preCombineField = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + } + + test("Test cleanup_stale_inflight_commits returns empty when no stale inflights exist") { + withTempDir { tmp => + val tableName = generateTableName + createEmptyTable(tableName, tmp.getCanonicalPath) + spark.sql(s"insert into $tableName values(1, 'a1', 1000)") + spark.sql(s"insert into $tableName values(2, 'a2', 2000)") + + val result = spark.sql(s"call cleanup_stale_inflight_commits(table => '$tableName')").collect() + assertResult(0)(result.length) + } + } + + test("Test cleanup_stale_inflight_commits rolls back stale REPLACE_COMMIT_ACTION inflight") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + // No inserts before injecting — see createEmptyTable docstring + createEmptyTable(tableName, tablePath) + + val staleTs = "20200101120000" + // Must use REPLACE_COMMIT_ACTION: inflightWriteCommitsOlderThan with + // include_ingestion_commits=false (default) filters out COMMIT_ACTION and DELTA_COMMIT_ACTION. + // REPLACE_COMMIT_ACTION is included in both getWriteTimeline() and getCommitsTimeline(), + // so client.rollback() finds it and returns true. + injectInflightInstant(tablePath, HoodieTimeline.REPLACE_COMMIT_ACTION, staleTs) + + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + assertResult(HoodieTimeline.REPLACE_COMMIT_ACTION)(result(0).getString(1)) + assertResult(true)(result(0).getBoolean(2)) + + // Verify the instant is gone from the active timeline + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val remaining = metaClient.reloadActiveTimeline().filterInflightsAndRequested() + .getInstants.asScala + assert(!remaining.exists(_.requestedTime == staleTs), + s"Stale instant $staleTs should have been removed from the timeline after rollback") + } + } + + test("Test cleanup_stale_inflight_commits respects allowed_inflight_interval_minutes threshold") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + // No inserts before injecting — see createEmptyTable docstring + createEmptyTable(tableName, tablePath) + + val staleTs = "20200101120000" + val freshTs = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + + injectInflightInstant(tablePath, HoodieTimeline.REPLACE_COMMIT_ACTION, staleTs) + injectInflightInstant(tablePath, HoodieTimeline.REPLACE_COMMIT_ACTION, freshTs) + + // 60-minute threshold: only the stale instant qualifies; fresh instant is too recent + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 60)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + } + } + + test("Test cleanup_stale_inflight_commits cleans COMMIT_ACTION with include_ingestion_commits=true") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + // No inserts before injecting — see createEmptyTable docstring + createEmptyTable(tableName, tablePath) + + val staleTs = "20200101120000" + // COMMIT_ACTION is filtered out with the default include_ingestion_commits=false, + // but included when include_ingestion_commits=true. + // client.rollback returns true for COMMIT_ACTION since getCommitsTimeline() includes it. + injectInflightInstant(tablePath, HoodieTimeline.COMMIT_ACTION, staleTs) + + // Default (include_ingestion_commits=false): should not find COMMIT_ACTION inflight + val defaultResult = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1)").collect() + assertResult(0)(defaultResult.length) + + // With include_ingestion_commits=true: should find and process COMMIT_ACTION inflight + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1, " + + s"include_ingestion_commits => true)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + assertResult(HoodieTimeline.COMMIT_ACTION)(result(0).getString(1)) + assertResult(true)(result(0).getBoolean(2)) + } + } + + test("Test cleanup_stale_inflight_commits dry_run lists matched instants without rolling back") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + createEmptyTable(tableName, tablePath) + + val staleTs = "20200101120000" + injectInflightInstant(tablePath, HoodieTimeline.REPLACE_COMMIT_ACTION, staleTs) + + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1, " + + s"dry_run => true)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + assertResult(HoodieTimeline.REPLACE_COMMIT_ACTION)(result(0).getString(1)) + // dry_run: rollback_status is NULL meaning "matched but not actioned" + assert(result(0).isNullAt(2), + "Expected rollback_status=NULL in dry_run mode, but got non-null value") + + // Verify the instant is STILL on the active timeline (dry_run did not act) + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val remaining = metaClient.reloadActiveTimeline().filterInflightsAndRequested() + .getInstants.asScala + assert(remaining.exists(_.requestedTime == staleTs), + s"dry_run should not have rolled back $staleTs; expected to find it still on the timeline") + } + } + + test("Test cleanup_stale_inflight_commits rolls back stale COMPACTION_ACTION inflight via table.rollbackInflightCompaction") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + // Compaction is MOR-only. Use the real schedule + run + delete-commit pattern from + // TestRunRollbackInflightTableServiceProcedure to construct a valid compaction inflight. + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + |) using hudi + | tblproperties ( + | primaryKey = 'id', + | type = 'mor', + | preCombineField = 'ts' + | ) + | partitioned by(ts) + | location '$tablePath' + """.stripMargin) + withSQLConf( + "hoodie.parquet.max.file.size" -> "10000", + "hoodie.compact.inline" -> "false", + "hoodie.compact.schedule.inline" -> "false", + // Prevent auto-clean from creating a clean instant after compaction completion, + // which would shift getReverseOrderedInstants.findFirst() away from the compaction commit. + "hoodie.clean.automatic" -> "false" + ) { + spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)") + spark.sql(s"insert into $tableName values(2, 'a2', 10, 1000)") + spark.sql(s"insert into $tableName values(3, 'a3', 10, 1000)") + spark.sql(s"insert into $tableName values(4, 'a4', 10, 1000)") + spark.sql(s"update $tableName set price = 11 where id = 1") + + spark.sql(s"call run_compaction(op => 'schedule', table => '$tableName')") + spark.sql(s"call run_compaction(op => 'run', table => '$tableName')") + + // Delete the completed compaction commit file so the inflight remains + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val compactionInstant = metaClient.getActiveTimeline.getReverseOrderedInstants.findFirst().get() + metaClient.getActiveTimeline.deleteInstantFileIfExists(compactionInstant) + val compactionInstantTime = compactionInstant.requestedTime + + // Confirm the compaction inflight is actually present before we call cleanup. + // If this assertion fires, the test setup (schedule+run+delete) didn't produce the expected + // state and the rest of the test is moot — fail with a clear diagnostic instead of an empty result. + val reloadedTimeline = metaClient.reloadActiveTimeline() + val compactionInflightPresent = reloadedTimeline.getWriteTimeline.filterInflightsAndRequested.getInstants.asScala + .exists(i => i.getAction == HoodieTimeline.COMPACTION_ACTION && i.requestedTime == compactionInstantTime) + assert(compactionInflightPresent, + s"Setup failure: compaction inflight at $compactionInstantTime not present after deleting completed commit. " + + s"Active timeline: ${reloadedTimeline.getInstants.asScala.map(i => s"${i.requestedTime}/${i.getAction}/${i.getState}").mkString(", ")}") + + // Sleep so the second-precision cutoff timestamp is strictly newer than the inflight's timestamp + Thread.sleep(2000) + + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 0)").collect() + + val compactionRow = result.find(r => r.getString(0) == compactionInstantTime) + assert(compactionRow.isDefined, + s"Expected compaction inflight $compactionInstantTime in result; got ${result.map(r => s"${r.getString(0)}/${r.getString(1)}").mkString(",")}") + assertResult(HoodieTimeline.COMPACTION_ACTION)(compactionRow.get.getString(1)) + assertResult(true)(compactionRow.get.getBoolean(2)) + + // Inflight should be removed by table.rollbackInflightCompaction + val instantGenerator = metaClient.getTimelineLayout.getInstantGenerator + val expectedInflight = instantGenerator.createNewInstant( + HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionInstantTime) + assert(!metaClient.reloadActiveTimeline().getInstants.contains(expectedInflight), + s"Compaction inflight $compactionInstantTime should be gone after rollback") + } + } + } + + test("Test cleanup_stale_inflight_commits rolls back stale CLUSTERING_ACTION inflight via table.rollbackInflightClustering") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + // Use the real schedule + execute + delete-commit pattern from + // TestRunRollbackInflightTableServiceProcedure so the clustering inflight is valid. + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + |) using hudi + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | preCombineField = 'ts' + | ) + | partitioned by(ts) + | location '$tablePath' + """.stripMargin) + spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)") + spark.sql(s"insert into $tableName values(2, 'a2', 10, 1001)") + spark.sql(s"insert into $tableName values(3, 'a3', 10, 1002)") + + spark.sql(s"call run_clustering(table => '$tableName', op => 'schedule')") + spark.sql(s"call run_clustering(table => '$tableName', op => 'execute')") + + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val clusteringInstant = metaClient.getActiveTimeline.getCompletedReplaceTimeline.getInstants.get(0) + metaClient.getActiveTimeline.deleteInstantFileIfExists(clusteringInstant) + val clusteringInstantTime = clusteringInstant.requestedTime + + Thread.sleep(2000) + + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 0)").collect() + + val clusteringRow = result.find(r => r.getString(0) == clusteringInstantTime) + assert(clusteringRow.isDefined, + s"Expected clustering inflight $clusteringInstantTime in result; got ${result.map(_.getString(0)).mkString(",")}") + assertResult(HoodieTimeline.CLUSTERING_ACTION)(clusteringRow.get.getString(1)) + assertResult(true)(clusteringRow.get.getBoolean(2)) + + val instantGenerator = metaClient.getTimelineLayout.getInstantGenerator + val expectedInflight = instantGenerator.createNewInstant( + HoodieInstant.State.INFLIGHT, HoodieTimeline.CLUSTERING_ACTION, clusteringInstantTime) + assert(!metaClient.reloadActiveTimeline().getInstants.contains(expectedInflight), + s"Clustering inflight $clusteringInstantTime should be gone after rollback") + } + } + + test("Test cleanup_stale_inflight_commits handles partitioned COW table") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + createEmptyPartitionedTable(tableName, tablePath, "cow") + + val staleTs = "20200101120000" + injectInflightInstant(tablePath, HoodieTimeline.REPLACE_COMMIT_ACTION, staleTs) + + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + assertResult(HoodieTimeline.REPLACE_COMMIT_ACTION)(result(0).getString(1)) + assertResult(true)(result(0).getBoolean(2)) + } + } + + test("Test cleanup_stale_inflight_commits handles MOR table DELTA_COMMIT_ACTION with include_ingestion_commits") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + createEmptyPartitionedTable(tableName, tablePath, "mor") + + val staleTs = "20200101120000" + injectInflightInstant(tablePath, HoodieTimeline.DELTA_COMMIT_ACTION, staleTs) + + // Default (include_ingestion_commits=false): DELTA_COMMIT_ACTION is filtered out + val defaultResult = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1)").collect() + assertResult(0)(defaultResult.length) + + // With include_ingestion_commits=true: DELTA_COMMIT_ACTION is processed + val result = spark.sql( + s"call cleanup_stale_inflight_commits(table => '$tableName', " + + s"allowed_inflight_interval_minutes => 1, " + + s"include_ingestion_commits => true)").collect() + + assertResult(1)(result.length) + assertResult(staleTs)(result(0).getString(0)) + assertResult(HoodieTimeline.DELTA_COMMIT_ACTION)(result(0).getString(1)) + assertResult(true)(result(0).getBoolean(2)) + } + } + + /** + * Injects a REQUESTED→INFLIGHT instant into the active timeline without completing it. + * Used to simulate stale inflight operations for testing. + */ + private def injectInflightInstant(tablePath: String, action: String, instantTime: String): Unit = { + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val timeline = metaClient.getActiveTimeline + val instantGenerator = metaClient.getTimelineLayout.getInstantGenerator + val requested = instantGenerator.createNewInstant(HoodieInstant.State.REQUESTED, action, instantTime) + timeline.createNewInstant(requested) + timeline.transitionRequestedToInflight(requested, HOption.empty[Array[Byte]]()) + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringWithCustomMerger.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringWithCustomMerger.scala new file mode 100644 index 0000000000000..7319bba9768cf --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringWithCustomMerger.scala @@ -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.spark.sql.hudi.procedure + +import org.apache.hudi.{DefaultSparkRecordMerger, HoodieDataSourceHelpers} +import org.apache.hudi.common.config.HoodieReaderConfig +import org.apache.hudi.common.model.HoodieRecordMerger +import org.apache.hudi.common.table.timeline.HoodieTimeline + +import org.apache.hadoop.fs.Path + +import scala.collection.JavaConverters._ + +/** + * Regression test for HUDI issue #18980: + * clustering on a table configured with {@code hoodie.write.record.merge.mode=CUSTOM} and a custom + * Spark merger failed with + * "No valid spark merger implementation set for `hoodie.write.record.merge.custom.implementation.classes`". + * + *

    The merge mode and strategy id are persisted as table config, but the custom merger impl classes + * are a write-side config that is not persisted. Before the fix, + * {@code ClusteringExecutionStrategy.getReaderProperties} built a fresh property set containing only + * the spill/memory keys, dropping the impl classes, so the file group reader could not resolve the + * configured merger. The fix seeds the reader properties from the full write config. + * + *

    Both clustering execution paths call {@code getReaderProperties} and reproduce the bug: + * the row-writer path ({@code MultipleSparkJobExecutionStrategy#readRecordsForGroupAsRow}, used when + * {@code hoodie.datasource.write.row.writer.enable} is true — the default the reporter hit) and the + * RDD path ({@code MultipleSparkJobExecutionStrategy#readRecordsForGroup} when it is false). The test + * is parameterized over both. + * + *

    This lives in {@code hudi-spark} (not {@code hudi-spark-client}): a CUSTOM/SPARK-typed merger + * forces the InternalRow write path, which needs a concrete {@code SparkXXXAdapter} and real Spark + * records — neither is available to {@code hudi-spark-client}'s test classpath / Avro test-data path. + */ +class TestClusteringWithCustomMerger extends HoodieSparkProcedureTestBase { + + test("Test clustering with CUSTOM record merge mode and a custom Spark merger") { + // hoodie.datasource.write.row.writer.enable: true exercises the row-writer clustering path + // (the default the reporter hit), false exercises the RDD path. Both call getReaderProperties. + Seq("true", "false").foreach { rowWriterEnabled => + withTempDir { tmp => + val tableName = generateTableName + val basePath = s"${tmp.getCanonicalPath}/$tableName" + val mergerClass = classOf[CustomSparkRecordMergerForClustering].getName + + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long, + | part long + |) using hudi + | tblproperties ( + | primaryKey = 'id', + | type = 'mor', + | orderingFields = 'ts', + | "hoodie.write.record.merge.mode" = "CUSTOM", + | "hoodie.write.record.merge.strategy.id" = "${HoodieRecordMerger.CUSTOM_MERGE_STRATEGY_UUID}", + | "${HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY}" = "$mergerClass", + | "hoodie.parquet.small.file.limit" = "0" + | ) + | partitioned by (part) + | location '$basePath' + """.stripMargin) + + withSQLConf( + "hoodie.compact.inline" -> "false", + "hoodie.compact.schedule.inline" -> "false") { + // Several commits into the same partition create multiple file groups for clustering to + // combine (small.file.limit=0 keeps every insert in a new base file). + spark.sql(s"insert into $tableName values (1, 'a1', 10.0, 1000, 100)") + spark.sql(s"insert into $tableName values (2, 'a2', 20.0, 1001, 100)") + spark.sql(s"insert into $tableName values (3, 'a3', 30.0, 1002, 100)") + // An update lands in a log file, so clustering must merge base + log records through the + // file group reader using the CUSTOM Spark merger. + spark.sql(s"update $tableName set price = 99.0, ts = 1003 where id = 1") + + // Pin the row-writer setting and propagate the custom merger impl classes (write-side, + // non-persisted) to the clustering write client. Before HUDI-18980 the reader properties + // dropped these and clustering failed with "No valid spark merger implementation set". + val clusteringOptions = + s"hoodie.datasource.write.row.writer.enable=$rowWriterEnabled," + + s"${HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY}=$mergerClass" + spark.sql(s"call run_clustering(table => '$tableName', options => '$clusteringOptions')").show() + + // Clustering must have produced exactly one completed replace commit. + val fs = new Path(basePath).getFileSystem(spark.sessionState.newHadoopConf()) + val replaceCommits = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) + .getInstants.iterator().asScala + .filter(_.getAction == HoodieTimeline.REPLACE_COMMIT_ACTION) + .toSeq + assertResult(1)(replaceCommits.size) + + // All records remain readable after clustering, with the update applied. + checkAnswer(s"select id, name, price, ts, part from $tableName order by id")( + Seq(1, "a1", 99.0, 1003, 100), + Seq(2, "a2", 20.0, 1001, 100), + Seq(3, "a3", 30.0, 1002, 100) + ) + } + } + } + } +} + +/** + * A custom Spark record merger whose only purpose is to advertise the CUSTOM merge strategy id, so the + * table is configured with {@code RecordMergeMode.CUSTOM} and a custom merger impl class. It otherwise + * reuses the default Spark merge behavior. + */ +class CustomSparkRecordMergerForClustering extends DefaultSparkRecordMerger { + override def getMergingStrategy: String = HoodieRecordMerger.CUSTOM_MERGE_STRATEGY_UUID +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestExportInstantsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestExportInstantsProcedure.scala index 372c6cc5e7c55..2b5a9357473f0 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestExportInstantsProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestExportInstantsProcedure.scala @@ -17,34 +17,116 @@ package org.apache.spark.sql.hudi.procedure +import org.apache.spark.sql.Row + +import java.io.File + class TestExportInstantsProcedure extends HoodieSparkProcedureTestBase { + private def createCowTable(tableName: String, path: String): Unit = { + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + |) using hudi + | location '$path' + | tblproperties ( + | primaryKey = 'id', + | orderingFields = 'ts' + | ) + """.stripMargin) + } + + private def newExportDir(tmp: File, name: String): File = { + val dir = new File(tmp, name) + assert(dir.mkdirs(), s"Failed to create export dir $dir") + dir + } + + private def exportedCount(result: Array[Row]): Int = { + assertResult(1)(result.length) + val detail = result.head.getString(0) + val matched = "Exported (\\d+) Instants".r.findFirstMatchIn(detail) + assert(matched.isDefined, s"Unexpected export detail: $detail") + matched.get.group(1).toInt + } + test("Test Call export_instants Procedure") { withTempDir { tmp => val tableName = generateTableName - // create table - spark.sql( - s""" - |create table $tableName ( - | id int, - | name string, - | price double, - | ts long - |) using hudi - | location '${tmp.getCanonicalPath}/$tableName' - | tblproperties ( - | primaryKey = 'id', - | orderingFields = 'ts' - | ) - """.stripMargin) + createCowTable(tableName, s"${tmp.getCanonicalPath}/$tableName") + + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + val exportDir = newExportDir(tmp, "export_basic") + val result = spark.sql( + s"""call export_instants(table => '$tableName', local_folder => '${exportDir.getCanonicalPath}')""").collect() + + // A single insert produces one exportable commit instant that is written to disk. + assertResult(1)(exportedCount(result)) + assertResult(1)(exportDir.listFiles().count(_.getName.endsWith(".commit"))) + } + } + + test("Test Call export_instants Procedure with desc ordering") { + withTempDir { tmp => + val tableName = generateTableName + createCowTable(tableName, s"${tmp.getCanonicalPath}/$tableName") + + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + spark.sql(s"insert into $tableName select 2, 'a2', 20, 2000") + spark.sql(s"insert into $tableName select 3, 'a3', 30, 3000") + + val exportDir = newExportDir(tmp, "export_desc") + val result = spark.sql( + s"""call export_instants(table => '$tableName', + | local_folder => '${exportDir.getCanonicalPath}', desc => true)""".stripMargin).collect() + + // The desc branch reverses the active instants and exports all three commits to disk. + assertResult(3)(exportedCount(result)) + assertResult(3)(exportDir.listFiles().count(_.getName.endsWith(".commit"))) + } + } + + test("Test Call export_instants Procedure filters by action") { + withTempDir { tmp => + val tableName = generateTableName + createCowTable(tableName, s"${tmp.getCanonicalPath}/$tableName") + + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + spark.sql(s"insert into $tableName select 2, 'a2', 20, 2000") - // insert data to table + // Restricting to an action that is not present exports nothing. + val cleanDir = newExportDir(tmp, "export_clean_only") + val cleanResult = spark.sql( + s"""call export_instants(table => '$tableName', + | local_folder => '${cleanDir.getCanonicalPath}', actions => 'clean')""".stripMargin).collect() + assertResult(0)(exportedCount(cleanResult)) + assertResult(0)(cleanDir.listFiles().count(_.getName.endsWith(".clean"))) + + // Restricting to the commit action exports exactly the commit instants. + val commitDir = newExportDir(tmp, "export_commit_only") + val commitResult = spark.sql( + s"""call export_instants(table => '$tableName', + | local_folder => '${commitDir.getCanonicalPath}', actions => 'commit')""".stripMargin).collect() + assertResult(2)(exportedCount(commitResult)) + assertResult(2)(commitDir.listFiles().count(_.getName.endsWith(".commit"))) + } + } + + test("Test Call export_instants Procedure with an invalid local folder") { + withTempDir { tmp => + val tableName = generateTableName + createCowTable(tableName, s"${tmp.getCanonicalPath}/$tableName") spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") - val result = spark.sql(s"""call export_instants(table => '$tableName', local_folder => '${tmp.getCanonicalPath}/$tableName')""").limit(1).collect() - assertResult(1) { - result.length - } + val notADir = new File(tmp, "does_not_exist").getCanonicalPath + checkExceptionContain( + s"""call export_instants(table => '$tableName', local_folder => '$notADir')""")( + "is not a valid local directory") } } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala new file mode 100644 index 0000000000000..b77c3bd11e43f --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.hudi.procedure + +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.testutils.FileCreateUtils +import org.apache.hudi.hadoop.fs.HadoopFSUtils + +import org.apache.hadoop.fs.Path + +import java.util.UUID + +class TestRepairOrphanFilesProcedure extends HoodieSparkProcedureTestBase { + + private val ORPHAN_INSTANT = "20000101000000000" // Year 2000; never in any test timeline + + private def metaClientFor(tablePath: String): HoodieTableMetaClient = { + HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + } + + // Test 1 — dry run detects a base-file orphan in a non-partitioned COW table + test("Test Call repair_orphan_files dry run finds base file orphan") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + + // Create a non-partitioned COW table and write one real commit + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + // Inject orphan base file directly onto the filesystem with a stale instant timestamp + val orphanFileId = UUID.randomUUID().toString + FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", ORPHAN_INSTANT, orphanFileId) + + // dry_run=true (default): should return exactly 1 row for the orphan, touch nothing + val result = spark.sql(s"call repair_orphan_files(table => '$tableName')").collect() + assertResult(1)(result.length) + + val row = result(0) + assertResult("")(row.getString(0)) // partition (root = "" for non-partitioned) + assert(row.getString(1).contains(ORPHAN_INSTANT), s"file_name should contain orphan instant: ${row.getString(1)}") + assertResult(ORPHAN_INSTANT)(row.getString(2)) // instant_time + assertResult("")(row.getString(3)) // backup_path is empty in dry run + assertResult("IDENTIFIED")(row.getString(4)) // status + } + } + + // Test 2 — cleanup mode backs up the orphan file and removes it from the table path + test("Test Call repair_orphan_files cleanup backs up base file orphan") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + val backupDir = s"${tmp.getCanonicalPath}/backup" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + val orphanFileId = UUID.randomUUID().toString + // FileCreateUtils.createBaseFile returns the absolute path of the created file + val orphanAbsPath = FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", ORPHAN_INSTANT, orphanFileId) + val orphanFilePath = new Path(orphanAbsPath) + + val hadoopConf = spark.sparkContext.hadoopConfiguration + val fs = HadoopFSUtils.getFs(tablePath, hadoopConf) + assert(fs.exists(orphanFilePath), "Orphan file should exist before cleanup") + + // dry_run=false: orphan should be moved to backup + val result = spark.sql( + s"""call repair_orphan_files( + | table => '$tableName', + | dry_run => false, + | backup_path => '$backupDir' + |)""".stripMargin).collect() + + assertResult(1)(result.length) + assertResult("BACKED_UP")(result(0).getString(4)) + + // Orphan file must be gone from the table path + assert(!fs.exists(orphanFilePath), "Orphan file should no longer exist at original path") + + // Orphan file must exist at the backup path + val backedUpPath = new Path(result(0).getString(3)) + assert(fs.exists(backedUpPath), "Orphan file should exist at backup path") + + // Real data must still be readable + assertResult(1)(spark.sql(s"select id from $tableName").collect().length) + } + } + + // Test 3 — inflight commit files are skipped (RepairUtils skips non-completed instants) + test("Test Call repair_orphan_files skips inflight commit files") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + val inflightTs = "20010101000000000" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + val metaClient = metaClientFor(tablePath) + // Create the inflight marker in .hoodie so the active timeline sees it as non-completed + FileCreateUtils.createInflightCommit(metaClient, inflightTs) + + // Place a data file with the inflight instant timestamp on disk + FileCreateUtils.createBaseFile(metaClient, "", inflightTs, UUID.randomUUID().toString) + + // Procedure must return 0 rows — inflight instant is excluded by RepairUtils + val result = spark.sql(s"call repair_orphan_files(table => '$tableName')").collect() + assertResult(0)(result.length) + } + } + + // Test 4 — backup_path validation: error when dry_run=false and no backup_path given + test("Test Call repair_orphan_files requires backup_path when not dry run") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + + checkExceptionContain( + s"call repair_orphan_files(table => '$tableName', dry_run => false)" + )("backup_path is required") + } + } + + // Test 5 — partition filter scopes the scan to a specific partition + test("Test Call repair_orphan_files partition filter scopes scan") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + // Inject orphan at table root (partition = "") + FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", ORPHAN_INSTANT, UUID.randomUUID().toString) + + // Filtering to a non-existent partition should find nothing + val filtered = spark.sql( + s"call repair_orphan_files(table => '$tableName', partition => 'nonexistent')").collect() + assertResult(0)(filtered.length) + + // Filtering to the root partition ("") should find the orphan + val root = spark.sql( + s"call repair_orphan_files(table => '$tableName', partition => '')").collect() + assertResult(1)(root.length) + assertResult("IDENTIFIED")(root(0).getString(4)) + } + } + + // Test 6 — log file orphan detected on a MOR table + test("Test Call repair_orphan_files detects log file orphan on MOR table") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts', type = 'mor') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + // Inject an orphan log file with a stale instant timestamp + val orphanFileId = UUID.randomUUID().toString + FileCreateUtils.createLogFile(metaClientFor(tablePath), "", ORPHAN_INSTANT, orphanFileId, 1) + + val result = spark.sql(s"call repair_orphan_files(table => '$tableName')").collect() + + // At least the orphan log file must appear in the result + val orphanRows = result.filter(_.getString(2) == ORPHAN_INSTANT) + assert(orphanRows.length >= 1, + s"Expected at least 1 orphan row with instant $ORPHAN_INSTANT, got: ${result.mkString(", ")}") + assert(orphanRows.forall(_.getString(4) == "IDENTIFIED")) + } + } + + // Note: the SKIPPED_PRESENT_IN_MDT branch (surfaces MDT-visible orphan candidates instead of + // silently dropping them) is verified by inspection rather than an end-to-end test. + // HoodieBackedTableMetadata.getAllFilesInPartition dynamically filters by the data table's + // timeline state — deleting a timeline file immediately removes the corresponding data file + // from the MDT's view as well. As a result, the (orphan ∧ in-MDT) state cannot be constructed + // by manipulating the timeline alone; only direct MDT writes could produce it, and the setup + // cost outweighs the value for what is a defense-in-depth path. + + // Test 7 — max_orphans cap prevents driver OOM: error when detected count exceeds the cap + test("Test Call repair_orphan_files max_orphans cap triggers error when exceeded") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = s"${tmp.getCanonicalPath}/$tableName" + + spark.sql( + s"""create table $tableName (id int, name string, price double, ts long) + |using hudi + |location '$tablePath' + |tblproperties (primaryKey = 'id', preCombineField = 'ts') + |""".stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000") + + // Inject 3 orphan files, then cap at 2 — should throw + val metaClient = metaClientFor(tablePath) + FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, UUID.randomUUID().toString) + FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, UUID.randomUUID().toString) + FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, UUID.randomUUID().toString) + + checkExceptionContain( + s"call repair_orphan_files(table => '$tableName', max_orphans => 2)" + )("exceeds max_orphans=2") + } + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRestoreProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRestoreProcedure.scala new file mode 100644 index 0000000000000..7674fa6abaa8f --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRestoreProcedure.scala @@ -0,0 +1,258 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.hudi.procedure + +class TestRestoreProcedure extends HoodieSparkProcedureTestBase { + + private def createTableAndInsertData(tableName: String, tablePath: String, tableType: String = "cow"): Array[String] = { + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | price double, + | ts long + |) using hudi + | location '$tablePath' + | tblproperties ( + | primaryKey = 'id', + | preCombineField = 'ts', + | type = '$tableType' + | ) + """.stripMargin) + spark.sql(s"insert into $tableName select 1, 'a1', 10.0, 1000") + spark.sql(s"insert into $tableName select 2, 'a2', 20.0, 1500") + spark.sql(s"insert into $tableName select 3, 'a3', 30.0, 2000") + spark.sql(s"insert into $tableName select 4, 'a4', 40.0, 2500") + spark.sql(s"call show_commits(table => '$tableName')").collect() + .map(_.getString(0)).sorted + } + + test("Test restore_to_instant basic CoW") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + assertResult(4)(commits.length) + + // restore to after the 2nd commit — only commits(0) and commits(1) should remain + val result = spark.sql( + s"call restore_to_instant(table => '$tableName', instant_time => '${commits(1)}')" + ).collect() + + assertResult(1)(result.length) + assertResult(true)(result(0).getBoolean(0)) // restore_result + assert(result(0).getString(1) != null) // start_restore_time (dynamic timestamp) + assert(result(0).getLong(2) >= 0) // time_taken_in_millis + assert(result(0).getLong(3) >= 0L) // instants_rolled_back + assertResult(true)(result(0).isNullAt(4)) // audit_result = null (audit not requested) + + // verify data reverted to 2 records + val count = spark.sql(s"select count(*) from $tableName").collect()(0).getLong(0) + assertResult(2)(count) + } + } + + test("Test restore_to_instant basic MoR") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath, tableType = "mor") + assertResult(4)(commits.length) + + val result = spark.sql( + s"call restore_to_instant(table => '$tableName', instant_time => '${commits(1)}')" + ).collect() + + assertResult(1)(result.length) + assertResult(true)(result(0).getBoolean(0)) + assert(result(0).getString(1) != null) + assert(result(0).getLong(2) >= 0) + assert(result(0).getLong(3) >= 0L) + assertResult(true)(result(0).isNullAt(4)) + + val count = spark.sql(s"select count(*) from $tableName").collect()(0).getLong(0) + assertResult(2)(count) + } + } + + test("Test restore_to_instant using path parameter") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + assertResult(4)(commits.length) + + val result = spark.sql( + s"call restore_to_instant(path => '$tablePath', instant_time => '${commits(1)}')" + ).collect() + + assertResult(1)(result.length) + assertResult(true)(result(0).getBoolean(0)) + assert(result(0).isNullAt(4)) + + val count = spark.sql(s"select count(*) from $tableName").collect()(0).getLong(0) + assertResult(2)(count) + } + } + + test("Test restore_to_instant with audit_post_restore") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + assertResult(4)(commits.length) + + val result = spark.sql( + s"""call restore_to_instant( + | table => '$tableName', + | instant_time => '${commits(1)}', + | audit_post_restore => true + |)""".stripMargin + ).collect() + + assertResult(1)(result.length) + assertResult(true)(result(0).getBoolean(0)) // restore_result + assert(result(0).getString(1) != null) // start_restore_time + // audit_result should be "PASSED": all rolled-back files are absent + assertResult(false)(result(0).isNullAt(4)) + assertResult("PASSED")(result(0).getString(4)) + + val count = spark.sql(s"select count(*) from $tableName").collect()(0).getLong(0) + assertResult(2)(count) + } + } + + test("Test restore_to_instant audit_only mode") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + assertResult(4)(commits.length) + + // Round 1: perform the restore and capture the restore operation's own timeline timestamp + val restoreRows = spark.sql( + s"call restore_to_instant(table => '$tableName', instant_time => '${commits(1)}')" + ).collect() + assertResult(true)(restoreRows(0).getBoolean(0)) + // start_restore_time is the restore instant's timeline timestamp — NOT commits(1) + val restoreInstantTs = restoreRows(0).getString(1) + assert(restoreInstantTs != null) + assert(restoreInstantTs != commits(1)) + + // Round 2: audit_only using start_restore_time (the original target commit is not needed) + val auditRows = spark.sql( + s"""call restore_to_instant( + | table => '$tableName', + | audit_only => true, + | start_restore_time => '$restoreInstantTs' + |)""".stripMargin + ).collect() + + assertResult(1)(auditRows.length) + assertResult(true)(auditRows(0).isNullAt(0)) // restore_result = null (no restore performed) + assertResult(true)(auditRows(0).isNullAt(1)) // start_restore_time = null + assertResult(true)(auditRows(0).isNullAt(2)) // time_taken_in_millis = null + assertResult(true)(auditRows(0).isNullAt(3)) // instants_rolled_back = null + assertResult(false)(auditRows(0).isNullAt(4)) // audit_result is present + assertResult("PASSED")(auditRows(0).getString(4)) + } + } + + test("Test restore_to_instant audit_only with non-existent restore instant throws") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + createTableAndInsertData(tableName, tablePath) + + // start_restore_time pointing at a timestamp that has never been a restore instant. + assertThrows[Exception] { + spark.sql( + s"""call restore_to_instant( + | table => '$tableName', + | audit_only => true, + | start_restore_time => '19700101000000000' + |)""".stripMargin + ).collect() + } + } + } + + test("Test restore_to_instant cross-validation: audit_only=true requires start_restore_time") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + createTableAndInsertData(tableName, tablePath) + + assertThrows[Exception] { + spark.sql( + s"call restore_to_instant(table => '$tableName', audit_only => true)" + ).collect() + } + } + } + + test("Test restore_to_instant cross-validation: audit_only=false rejects start_restore_time") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + + assertThrows[Exception] { + spark.sql( + s"""call restore_to_instant( + | table => '$tableName', + | instant_time => '${commits(1)}', + | start_restore_time => '20990101000000000' + |)""".stripMargin + ).collect() + } + } + } + + test("Test restore_to_instant cross-validation: audit_only=true rejects instant_time") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + val commits = createTableAndInsertData(tableName, tablePath) + + assertThrows[Exception] { + spark.sql( + s"""call restore_to_instant( + | table => '$tableName', + | audit_only => true, + | instant_time => '${commits(1)}', + | start_restore_time => '20990101000000000' + |)""".stripMargin + ).collect() + } + } + } + + test("Test restore_to_instant cross-validation: audit_only=false requires instant_time") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + "/" + tableName + createTableAndInsertData(tableName, tablePath) + + assertThrows[Exception] { + spark.sql(s"call restore_to_instant(table => '$tableName')").collect() + } + } + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowInflightCommitsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowInflightCommitsProcedure.scala new file mode 100644 index 0000000000000..ca77172bd607b --- /dev/null +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowInflightCommitsProcedure.scala @@ -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.spark.sql.hudi.procedure + +import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline} +import org.apache.hudi.common.util.{Option => HOption} +import org.apache.hudi.hadoop.fs.HadoopFSUtils + +import java.text.SimpleDateFormat +import java.util.Date + +class TestShowInflightCommitsProcedure extends HoodieSparkProcedureTestBase { + + test("Test show_inflight_commits returns empty for a fully committed table") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + | ) using hudi + | location '${tmp.getCanonicalPath}' + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | preCombineField = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + spark.sql(s"insert into $tableName values(1, 'a1', 1000)") + spark.sql(s"insert into $tableName values(2, 'a2', 2000)") + + val result = spark.sql(s"call show_inflight_commits(table => '$tableName')").collect() + assertResult(0)(result.length) + } + } + + test("Test show_inflight_commits returns injected inflight instant") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + | ) using hudi + | location '$tablePath' + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | preCombineField = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + + val injectedTs = "20200101120000" + injectInflightInstant(tablePath, HoodieTimeline.COMMIT_ACTION, injectedTs) + + val result = spark.sql(s"call show_inflight_commits(table => '$tableName')").collect() + assert(result.length >= 1) + val row = result.find(r => r.getString(0) == injectedTs) + assert(row.isDefined, s"Expected inflight instant $injectedTs not found in results") + assertResult(HoodieTimeline.COMMIT_ACTION)(row.get.getString(1)) + assertResult("INFLIGHT")(row.get.getString(2)) + } + } + + test("Test show_inflight_commits min_age_minutes filter includes old and excludes recent") { + withTempDir { tmp => + val tableName = generateTableName + val tablePath = tmp.getCanonicalPath + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + | ) using hudi + | location '$tablePath' + | tblproperties ( + | primaryKey = 'id', + | type = 'cow', + | preCombineField = 'ts', + | hoodie.metadata.enable = "false" + | ) + |""".stripMargin) + + val oldTs = "20200101120000" + val freshTs = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + + injectInflightInstant(tablePath, HoodieTimeline.COMMIT_ACTION, oldTs) + injectInflightInstant(tablePath, HoodieTimeline.DELTA_COMMIT_ACTION, freshTs) + + // No filter: both should appear + val allResults = spark.sql(s"call show_inflight_commits(table => '$tableName', min_age_minutes => 0)").collect() + assert(allResults.length >= 2) + assert(allResults.exists(r => r.getString(0) == oldTs)) + assert(allResults.exists(r => r.getString(0) == freshTs)) + + // 60-minute filter: only the old instant should appear + val filteredResults = spark.sql( + s"call show_inflight_commits(table => '$tableName', min_age_minutes => 60)").collect() + assert(filteredResults.exists(r => r.getString(0) == oldTs), + s"Expected old instant $oldTs to appear with min_age_minutes=60") + assert(!filteredResults.exists(r => r.getString(0) == freshTs), + s"Fresh instant $freshTs should not appear with min_age_minutes=60") + } + } + + test("Test show_inflight_commits requires table parameter") { + checkExceptionContain( + "call show_inflight_commits()")( + "Argument: table is required") + } + + /** + * Injects a REQUESTED→INFLIGHT instant into the active timeline without completing it. + * Used to simulate stale inflight operations for testing. + */ + private def injectInflightInstant(tablePath: String, action: String, instantTime: String): Unit = { + val metaClient = HoodieTableMetaClient.builder + .setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration)) + .setBasePath(tablePath) + .build + val timeline = metaClient.getActiveTimeline + val instantGenerator = metaClient.getTimelineLayout.getInstantGenerator + val requested = instantGenerator.createNewInstant(HoodieInstant.State.REQUESTED, action, instantTime) + timeline.createNewInstant(requested) + timeline.transitionRequestedToInflight(requested, HOption.empty[Array[Byte]]()) + } +} diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowTimelineTableProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowTimelineTableProcedure.scala index c1e84cca83f18..c21b0ec8713b6 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowTimelineTableProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowTimelineTableProcedure.scala @@ -25,7 +25,7 @@ import org.apache.hudi.common.engine.{HoodieEngineContext, HoodieLocalEngineCont import org.apache.hudi.common.engine.LocalTaskContextSupplier import org.apache.hudi.common.model.{ActionType, HoodieArchivedLogFile, HoodieAvroIndexedRecord, HoodieCommitMetadata, HoodieLogFile, HoodieRecord, WriteOperationType} import org.apache.hudi.common.table.{HoodieTableMetaClient, HoodieTableVersion} -import org.apache.hudi.common.table.log.HoodieLogFormat +import org.apache.hudi.common.table.log.{HoodieLogFormat, HoodieLogFormatWriter} import org.apache.hudi.common.table.log.block.{HoodieAvroDataBlock, HoodieLogBlock} import org.apache.hudi.common.table.timeline.{ActiveAction, HoodieInstant, HoodieTimeline} import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion @@ -398,9 +398,9 @@ class TestShowTimelineTableProcedure extends HoodieSparkSqlTestBase { storage.createDirectory(archivePath) } - val writer = HoodieLogFormat.newWriterBuilder() - .onParentPath(archiveFilePath.getParent()) - .withFileId(archiveFilePath.getName()) + val writer = HoodieLogFormatWriter.builder() + .withParentPath(archiveFilePath.getParent()) + .withLogFileId(archiveFilePath.getName()) .withFileExtension(HoodieArchivedLogFile.ARCHIVE_EXTENSION) .withStorage(storage) .withInstantTime("") diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/hudi/Spark3HoodiePartitionValues.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/hudi/Spark3HoodiePartitionValues.scala index bd50e3ebfca21..acdc175be2859 100644 --- a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/hudi/Spark3HoodiePartitionValues.scala +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/hudi/Spark3HoodiePartitionValues.scala @@ -20,88 +20,11 @@ package org.apache.hudi import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.util.{ArrayData, MapData} -import org.apache.spark.sql.types.{DataType, Decimal} -import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String} -case class Spark3HoodiePartitionValues(values: InternalRow) extends HoodiePartitionValues { - override def numFields: Int = { - values.numFields - } - - override def setNullAt(i: Int): Unit = { - values.setNullAt(i) - } - - override def update(i: Int, value: Any): Unit = { - values.update(i, value) - } +case class Spark3HoodiePartitionValues(override val values: InternalRow) + extends BaseHoodiePartitionValues(values) { override def copy(): InternalRow = { Spark3HoodiePartitionValues(values.copy()) } - - override def isNullAt(ordinal: Int): Boolean = { - values.isNullAt(ordinal) - } - - override def getBoolean(ordinal: Int): Boolean = { - values.getBoolean(ordinal) - } - - override def getByte(ordinal: Int): Byte = { - values.getByte(ordinal) - } - - override def getShort(ordinal: Int): Short = { - values.getShort(ordinal) - } - - override def getInt(ordinal: Int): Int = { - values.getInt(ordinal) - } - - override def getLong(ordinal: Int): Long = { - values.getLong(ordinal) - } - - override def getFloat(ordinal: Int): Float = { - values.getFloat(ordinal) - } - - override def getDouble(ordinal: Int): Double = { - values.getDouble(ordinal) - } - - override def getDecimal(ordinal: Int, precision: Int, scale: Int): Decimal = { - values.getDecimal(ordinal, precision, scale) - } - - override def getUTF8String(ordinal: Int): UTF8String = { - values.getUTF8String(ordinal) - } - - override def getBinary(ordinal: Int): Array[Byte] = { - values.getBinary(ordinal) - } - - override def getInterval(ordinal: Int): CalendarInterval = { - values.getInterval(ordinal) - } - - override def getStruct(ordinal: Int, numFields: Int): InternalRow = { - values.getStruct(ordinal, numFields) - } - - override def getArray(ordinal: Int): ArrayData = { - values.getArray(ordinal) - } - - override def getMap(ordinal: Int): MapData = { - values.getMap(ordinal) - } - - override def get(ordinal: Int, dataType: DataType): AnyRef = { - values.get(ordinal, dataType) - } } diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystPlanUtils.scala new file mode 100644 index 0000000000000..eb22674d9de7d --- /dev/null +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3CatalystPlanUtils.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 + +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.plans.logical.{Assignment, UpdateAction} +import org.apache.spark.sql.execution.streaming.SerializedOffset + +/** + * Implementation of [[HoodieCatalystPlansUtils]] carrying the method bodies shared by all + * supported Spark 3.x versions + */ +abstract class HoodieSpark3CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { + + override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { + mergeAction match { + case UpdateAction(condition, assignments) => Some((condition, assignments)) + case _ => None + } + } + + override def extractJsonFromSerializedOffset(offset: Any): Option[String] = { + offset match { + case SerializedOffset(json) => Some(json) + case _ => None + } + } +} diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3SchemaUtils.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3SchemaUtils.scala new file mode 100644 index 0000000000000..6adad02c1d918 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/HoodieSpark3SchemaUtils.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 + +import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils +import org.apache.spark.sql.jdbc.JdbcDialect +import org.apache.spark.sql.types.StructType + +import java.sql.{Connection, ResultSet} + +/** + * Utils on schema shared by all supported Spark 3.x versions. + */ +abstract class HoodieSpark3SchemaUtils extends HoodieSchemaUtils { + override def getSchema(conn: Connection, + resultSet: ResultSet, + dialect: JdbcDialect, + alwaysNullable: Boolean = false, + isTimestampNTZ: Boolean = false): StructType = { + JdbcUtils.getSchema(resultSet, dialect, alwaysNullable) + } +} diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala index c7039c951c4ef..90be1df5068d8 100644 --- a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.{DefaultSource, HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, HoodieSchemaConversionUtils, Spark3HoodiePartitionCDCFileGroupMapping, Spark3HoodiePartitionFileSliceMapping} +import org.apache.hudi.{DefaultSource, HoodieFileScanRDD, HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, HoodieSchemaConversionUtils, Spark3HoodiePartitionCDCFileGroupMapping, Spark3HoodiePartitionFileSliceMapping} import org.apache.hudi.client.model.{HoodieInternalRow, Spark3HoodieInternalRow} import org.apache.hudi.common.model.FileSlice import org.apache.hudi.common.schema.HoodieSchema @@ -36,7 +36,7 @@ import org.apache.spark.sql.FileFormatUtilsForFileGroupReader.applyFiltersToPlan import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{Expression, InterpretedPredicate, Predicate, SpecializedGetters} +import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression, InterpretedPredicate, Predicate, SpecializedGetters} import org.apache.spark.sql.catalyst.parser.ParseException import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan @@ -101,6 +101,14 @@ abstract class BaseSpark3Adapter extends SparkAdapter with Logging { Predicate.createInterpreted(e) } + override def createHoodieFileScanRDD(sparkSession: SparkSession, + readFunction: PartitionedFile => Iterator[InternalRow], + filePartitions: Seq[FilePartition], + readDataSchema: StructType, + metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { + new HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) + } + override def createRelation(sqlContext: SQLContext, metaClient: HoodieTableMetaClient, schema: HoodieSchema, diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala similarity index 97% rename from hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala rename to hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala index b5eba6be24cd3..2a9244508182c 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala @@ -33,7 +33,12 @@ import org.apache.spark.sql.catalyst.expressions.{SpecificInternalRow, UnsafeArr import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData, DateTimeUtils, GenericArrayData, RebaseDateTime} import org.apache.spark.sql.catalyst.util.DateTimeConstants.MILLIS_PER_DAY import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy +// LegacyBehaviorPolicy is nested in SQLConf on Spark 3.3/3.4 (org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy) +// but a top-level object on Spark 3.5 (org.apache.spark.sql.internal.LegacyBehaviorPolicy). Importing both +// containers via wildcards lets this single shared source resolve the enum on every 3.x version: exactly one of +// the two wildcards contributes LegacyBehaviorPolicy on any given version, so there is no ambiguity. +import org.apache.spark.sql.internal._ +import org.apache.spark.sql.internal.SQLConf._ import org.apache.spark.sql.types._ import org.apache.spark.unsafe.types.UTF8String diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala similarity index 97% rename from hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala rename to hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala index a1241b72e58bc..a432eec0ffedb 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala @@ -34,8 +34,13 @@ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.expressions.{SpecializedGetters, SpecificInternalRow} import org.apache.spark.sql.catalyst.util.{DateTimeUtils, RebaseDateTime} import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy +// LegacyBehaviorPolicy is nested in SQLConf on Spark 3.3/3.4 (org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy) +// but a top-level object on Spark 3.5 (org.apache.spark.sql.internal.LegacyBehaviorPolicy). Importing both +// containers via wildcards lets this single shared source resolve the enum on every 3.x version: exactly one of +// the two wildcards contributes LegacyBehaviorPolicy on any given version, so there is no ambiguity. The same two +// wildcards also keep the SQLConf object in scope for the SQLConf.get / SQLConf.AVRO_REBASE_MODE_IN_WRITE usages. +import org.apache.spark.sql.internal._ +import org.apache.spark.sql.internal.SQLConf._ import org.apache.spark.sql.types._ import java.nio.ByteBuffer diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala new file mode 100644 index 0000000000000..d6b3ab3e3f990 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala @@ -0,0 +1,480 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.parquet + +import org.apache.hudi.client.utils.SparkInternalSchemaConverter +import org.apache.hudi.common.fs.FSUtils +import org.apache.hudi.common.table.timeline.TimelineLayout +import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion +import org.apache.hudi.common.util.InternalSchemaCache +import org.apache.hudi.common.util.StringUtils.isNullOrEmpty +import org.apache.hudi.common.util.collection.Pair +import org.apache.hudi.hadoop.fs.HadoopFSUtils +import org.apache.hudi.internal.schema.InternalSchema +import org.apache.hudi.internal.schema.action.InternalSchemaMerger +import org.apache.hudi.internal.schema.utils.{InternalSchemaUtils, SerDeHelper} +import org.apache.hudi.storage.HoodieStorageUtils + +import org.apache.hadoop.conf.Configuration +import org.apache.hadoop.fs.Path +import org.apache.hadoop.mapred.FileSplit +import org.apache.hadoop.mapreduce.{JobID, TaskAttemptID, TaskID, TaskType} +import org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl +import org.apache.parquet.filter2.compat.FilterCompat +import org.apache.parquet.filter2.predicate.FilterApi +import org.apache.parquet.format.converter.ParquetMetadataConverter.SKIP_ROW_GROUPS +import org.apache.parquet.hadoop.{ParquetInputFormat, ParquetRecordReader} +import org.apache.spark.TaskContext +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, JoinedRow} +import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection +import org.apache.spark.sql.catalyst.util.DateTimeUtils +import org.apache.spark.sql.execution.datasources.{DataSourceUtils, PartitionedFile, RecordReaderIterator} +import org.apache.spark.sql.execution.datasources.parquet.Spark3LegacyHoodieParquetFileFormat._ +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.sources._ +import org.apache.spark.sql.types.{AtomicType, DataType, StructField, StructType} +import org.apache.spark.util.SerializableConfiguration + +import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` + +/** + * Base [[ParquetFileFormat]] shared by the Spark 3.3, 3.4 and 3.5 legacy readers. + * + * It holds the logic common to all three versions. Every expression that relies on a + * version-specific Spark API is delegated to a protected hook that each concrete subclass + * overrides, so this class compiles unchanged against each supported Spark 3.x version. + * + * This is an extension of [[ParquetFileFormat]] overriding Spark-specific behavior + * that's not possible to customize in any other way, with the following changes applied: + *

      + *
    1. Avoiding appending partition values to the rows read from the data file
    2. + *
    3. Schema on-read
    4. + *
    + */ +abstract class Spark3LegacyHoodieParquetFileFormat(shouldAppendPartitionValues: Boolean) extends ParquetFileFormat { + + /** + * Converts a [[StructType]] to its attributes. Spark 3.3/3.4 expose [[StructType.toAttributes]] + * while Spark 3.5 moved it to [[org.apache.spark.sql.catalyst.types.DataTypeUtils]]. + */ + protected def toAttributes(structType: StructType): Seq[Attribute] + + /** + * Extracts the [[Path]] of the file being read. Spark 3.3 keeps the path as a string while + * Spark 3.4+ wraps it in a `SparkPath`. + */ + protected def getFilePath(file: PartitionedFile): Path + + /** + * Whether the vectorized reader is enabled for the given schema. + */ + protected def isVectorizedReaderEnabled(sparkSession: SparkSession, resultSchema: StructType): Boolean + + /** + * Whether string-predicate push-down is enabled (renamed between Spark 3.3 and 3.4). + */ + protected def getPushDownStringPredicate(sqlConf: SQLConf): Boolean + + /** + * Whether the reader should return columnar batches. + */ + protected def getReturningBatch(sparkSession: SparkSession, resultSchema: StructType): Boolean + + /** + * Sets the version-specific timestamp and nanos-as-long related flags on the hadoop conf. + */ + protected def setParquetTimeConfs(hadoopConf: Configuration, sparkSession: SparkSession): Unit + + override def buildReaderWithPartitionValues(sparkSession: SparkSession, + dataSchema: StructType, + partitionSchema: StructType, + requiredSchema: StructType, + filters: Seq[Filter], + options: Map[String, String], + hadoopConf: Configuration): PartitionedFile => Iterator[InternalRow] = { + hadoopConf.set(ParquetInputFormat.READ_SUPPORT_CLASS, classOf[ParquetReadSupport].getName) + hadoopConf.set( + ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, + requiredSchema.json) + hadoopConf.set( + ParquetWriteSupport.SPARK_ROW_SCHEMA, + requiredSchema.json) + hadoopConf.set( + SQLConf.SESSION_LOCAL_TIMEZONE.key, + sparkSession.sessionState.conf.sessionLocalTimeZone) + hadoopConf.setBoolean( + SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, + sparkSession.sessionState.conf.nestedSchemaPruningEnabled) + hadoopConf.setBoolean( + SQLConf.CASE_SENSITIVE.key, + sparkSession.sessionState.conf.caseSensitiveAnalysis) + + ParquetWriteSupport.setSchema(requiredSchema, hadoopConf) + + // Sets flags for `ParquetToSparkSchemaConverter` + hadoopConf.setBoolean( + SQLConf.PARQUET_BINARY_AS_STRING.key, + sparkSession.sessionState.conf.isParquetBinaryAsString) + hadoopConf.setBoolean( + SQLConf.PARQUET_INT96_AS_TIMESTAMP.key, + sparkSession.sessionState.conf.isParquetINT96AsTimestamp) + // Version-specific timestamp and nanos-as-long flags. + setParquetTimeConfs(hadoopConf, sparkSession) + val internalSchemaStr = hadoopConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) + // For Spark DataSource v1, there's no Physical Plan projection/schema pruning w/in Spark itself, + // therefore it's safe to do schema projection here + if (!isNullOrEmpty(internalSchemaStr)) { + val prunedInternalSchemaStr = + pruneInternalSchema(internalSchemaStr, requiredSchema) + hadoopConf.set(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA, prunedInternalSchemaStr) + } + + val broadcastedHadoopConf = + sparkSession.sparkContext.broadcast(new SerializableConfiguration(hadoopConf)) + + // TODO: if you move this into the closure it reverts to the default values. + // If true, enable using the custom RecordReader for parquet. This only works for + // a subset of the types (no complex types). + val resultSchema = StructType(partitionSchema.fields ++ requiredSchema.fields) + val sqlConf = sparkSession.sessionState.conf + val enableOffHeapColumnVector = sqlConf.offHeapColumnVectorEnabled + val enableVectorizedReader: Boolean = isVectorizedReaderEnabled(sparkSession, resultSchema) + val enableRecordFilter: Boolean = sqlConf.parquetRecordFilterEnabled + val timestampConversion: Boolean = sqlConf.isParquetINT96TimestampConversion + val capacity = sqlConf.parquetVectorizedReaderBatchSize + val enableParquetFilterPushDown: Boolean = sqlConf.parquetFilterPushDown + val pushDownDate = sqlConf.parquetFilterPushDownDate + val pushDownTimestamp = sqlConf.parquetFilterPushDownTimestamp + val pushDownDecimal = sqlConf.parquetFilterPushDownDecimal + val pushDownStringStartWith = getPushDownStringPredicate(sqlConf) + val pushDownInFilterThreshold = sqlConf.parquetFilterPushDownInFilterThreshold + val isCaseSensitive = sqlConf.caseSensitiveAnalysis + val parquetOptions = new ParquetOptions(options, sparkSession.sessionState.conf) + val datetimeRebaseModeInRead = parquetOptions.datetimeRebaseModeInRead + val int96RebaseModeInRead = parquetOptions.int96RebaseModeInRead + val timeZoneId = Option(sqlConf.sessionLocalTimeZone) + // Whole stage codegen (PhysicalRDD) is able to deal with batches directly. + val returningBatch = getReturningBatch(sparkSession, resultSchema) + + (file: PartitionedFile) => { + assert(!shouldAppendPartitionValues || file.partitionValues.numFields == partitionSchema.size) + + val filePath = getFilePath(file) + val split = new FileSplit(filePath, file.start, file.length, Array.empty[String]) + + val sharedConf = broadcastedHadoopConf.value.value + + // Fetch internal schema + val internalSchemaStr = sharedConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) + // Internal schema has to be pruned at this point + val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) + + var shouldUseInternalSchema = !isNullOrEmpty(internalSchemaStr) && querySchemaOption.isPresent + + val tablePath = sharedConf.get(SparkInternalSchemaConverter.HOODIE_TABLE_PATH) + val fileSchema = if (shouldUseInternalSchema) { + val commitInstantTime = FSUtils.getCommitTime(filePath.getName).toLong; + val validCommits = sharedConf.get(SparkInternalSchemaConverter.HOODIE_VALID_COMMITS_LIST) + //TODO: HARDCODED TIMELINE OBJECT + val layout = TimelineLayout.fromVersion(TimelineLayoutVersion.CURR_LAYOUT_VERSION) + val storage = HoodieStorageUtils.getStorage(tablePath, HadoopFSUtils.getStorageConf(sharedConf)) + InternalSchemaCache.getInternalSchemaByVersionId( + commitInstantTime, tablePath, storage, if (validCommits == null) "" else validCommits, + layout) + } else { + null + } + + lazy val footerFileMetaData = + ParquetFooterReader.readFooter(sharedConf, filePath, SKIP_ROW_GROUPS).getFileMetaData + // Try to push down filters when filter push-down is enabled. + val pushed = if (enableParquetFilterPushDown) { + val parquetSchema = footerFileMetaData.getSchema + val datetimeRebaseSpec = + DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) + val parquetFilters = new ParquetFilters( + parquetSchema, + pushDownDate, + pushDownTimestamp, + pushDownDecimal, + pushDownStringStartWith, + pushDownInFilterThreshold, + isCaseSensitive, + datetimeRebaseSpec) + filters.map(rebuildFilterFromParquet(_, fileSchema, querySchemaOption.orElse(null))) + // Collects all converted Parquet filter predicates. Notice that not all predicates can be + // converted (`ParquetFilters.createFilter` returns an `Option`). That's why a `flatMap` + // is used here. + .flatMap(parquetFilters.createFilter) + .reduceOption(FilterApi.and) + } else { + None + } + + // PARQUET_INT96_TIMESTAMP_CONVERSION says to apply timezone conversions to int96 timestamps' + // *only* if the file was created by something other than "parquet-mr", so check the actual + // writer here for this file. We have to do this per-file, as each file in the table may + // have different writers. + // Define isCreatedByParquetMr as function to avoid unnecessary parquet footer reads. + def isCreatedByParquetMr: Boolean = + footerFileMetaData.getCreatedBy().startsWith("parquet-mr") + + val convertTz = + if (timestampConversion && !isCreatedByParquetMr) { + Some(DateTimeUtils.getZoneId(sharedConf.get(SQLConf.SESSION_LOCAL_TIMEZONE.key))) + } else { + None + } + + val attemptId = new TaskAttemptID(new TaskID(new JobID(), TaskType.MAP, 0), 0) + + // Clone new conf + val hadoopAttemptConf = new Configuration(broadcastedHadoopConf.value.value) + val typeChangeInfos: java.util.Map[Integer, Pair[DataType, DataType]] = if (shouldUseInternalSchema) { + val mergedInternalSchema = new InternalSchemaMerger(fileSchema, querySchemaOption.get(), true, true).mergeSchema() + val mergedSchema = SparkInternalSchemaConverter.constructSparkSchemaFromInternalSchema(mergedInternalSchema) + + hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, mergedSchema.json) + + SparkInternalSchemaConverter.collectTypeChangedCols(querySchemaOption.get(), mergedInternalSchema) + } else { + val (implicitTypeChangeInfo, sparkRequestSchema) = HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo(hadoopAttemptConf, footerFileMetaData, requiredSchema) + if (!implicitTypeChangeInfo.isEmpty) { + shouldUseInternalSchema = true + hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, sparkRequestSchema.json) + } + implicitTypeChangeInfo + } + + if (enableVectorizedReader && shouldUseInternalSchema && + !typeChangeInfos.values().forall(_.getLeft.isInstanceOf[AtomicType])) { + throw new IllegalArgumentException( + "Nested types with type changes(implicit or explicit) cannot be read in vectorized mode. " + + "To workaround this issue, set spark.sql.parquet.enableVectorizedReader=false.") + } + + val hadoopAttemptContext = + new TaskAttemptContextImpl(hadoopAttemptConf, attemptId) + + // Try to push down filters when filter push-down is enabled. + // Notice: This push-down is RowGroups level, not individual records. + if (pushed.isDefined) { + ParquetInputFormat.setFilterPredicate(hadoopAttemptContext.getConfiguration, pushed.get) + } + val taskContext = Option(TaskContext.get()) + if (enableVectorizedReader) { + val vectorizedReader = + if (shouldUseInternalSchema) { + val int96RebaseSpec = + DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) + val datetimeRebaseSpec = + DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) + new HoodieVectorizedParquetRecordReader( + convertTz.orNull, + datetimeRebaseSpec.mode.toString, + datetimeRebaseSpec.timeZone, + int96RebaseSpec.mode.toString, + int96RebaseSpec.timeZone, + enableOffHeapColumnVector && taskContext.isDefined, + capacity, + typeChangeInfos) + } else { + val int96RebaseSpec = + DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) + val datetimeRebaseSpec = + DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) + new VectorizedParquetRecordReader( + convertTz.orNull, + datetimeRebaseSpec.mode.toString, + datetimeRebaseSpec.timeZone, + int96RebaseSpec.mode.toString, + int96RebaseSpec.timeZone, + enableOffHeapColumnVector && taskContext.isDefined, + capacity) + } + + // SPARK-37089: We cannot register a task completion listener to close this iterator here + // because downstream exec nodes have already registered their listeners. Since listeners + // are executed in reverse order of registration, a listener registered here would close the + // iterator while downstream exec nodes are still running. When off-heap column vectors are + // enabled, this can cause a use-after-free bug leading to a segfault. + // + // Instead, we use FileScanRDD's task completion listener to close this iterator. + val iter = new RecordReaderIterator(vectorizedReader) + try { + vectorizedReader.initialize(split, hadoopAttemptContext) + + // NOTE: We're making appending of the partitioned values to the rows read from the + // data file configurable + if (shouldAppendPartitionValues) { + logDebug(s"Appending $partitionSchema ${file.partitionValues}") + vectorizedReader.initBatch(partitionSchema, file.partitionValues) + } else { + vectorizedReader.initBatch(StructType(Nil), InternalRow.empty) + } + + if (returningBatch) { + vectorizedReader.enableReturningBatches() + } + + // UnsafeRowParquetRecordReader appends the columns internally to avoid another copy. + iter.asInstanceOf[Iterator[InternalRow]] + } catch { + case e: Throwable => + // SPARK-23457: In case there is an exception in initialization, close the iterator to + // avoid leaking resources. + iter.close() + throw e + } + } else { + logDebug(s"Falling back to parquet-mr") + val int96RebaseSpec = + DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) + val datetimeRebaseSpec = + DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) + val readSupport = new HoodieParquetReadSupport( + convertTz, + enableVectorizedReader = false, + enableTimestampFieldRepair = true, + datetimeRebaseSpec, + int96RebaseSpec) + + val reader = if (pushed.isDefined && enableRecordFilter) { + val parquetFilter = FilterCompat.get(pushed.get, null) + new ParquetRecordReader[InternalRow](readSupport, parquetFilter) + } else { + new ParquetRecordReader[InternalRow](readSupport) + } + val iter = new RecordReaderIterator[InternalRow](reader) + try { + reader.initialize(split, hadoopAttemptContext) + + val fullSchema = toAttributes(requiredSchema) ++ toAttributes(partitionSchema) + val unsafeProjection = if (typeChangeInfos.isEmpty) { + GenerateUnsafeProjection.generate(fullSchema, fullSchema) + } else { + // find type changed. + val newSchema = new StructType(requiredSchema.fields.zipWithIndex.map { case (f, i) => + if (typeChangeInfos.containsKey(i)) { + StructField(f.name, typeChangeInfos.get(i).getRight, f.nullable, f.metadata) + } else f + }) + val newFullSchema = toAttributes(newSchema) ++ toAttributes(partitionSchema) + val castSchema = newFullSchema.zipWithIndex.map { case (attr, i) => + if (typeChangeInfos.containsKey(i)) { + val srcType = typeChangeInfos.get(i).getRight + val dstType = typeChangeInfos.get(i).getLeft + val needTimeZone = Cast.needsTimeZone(srcType, dstType) + Cast(attr, dstType, if (needTimeZone) timeZoneId else None) + } else attr + } + GenerateUnsafeProjection.generate(castSchema, newFullSchema) + } + + // NOTE: We're making appending of the partitioned values to the rows read from the + // data file configurable + if (!shouldAppendPartitionValues || partitionSchema.length == 0) { + // There is no partition columns + iter.map(unsafeProjection) + } else { + val joinedRow = new JoinedRow() + iter.map(d => unsafeProjection(joinedRow(d, file.partitionValues))) + } + } catch { + case e: Throwable => + // SPARK-23457: In case there is an exception in initialization, close the iterator to + // avoid leaking resources. + iter.close() + throw e + } + } + } + } +} + +object Spark3LegacyHoodieParquetFileFormat { + + def pruneInternalSchema(internalSchemaStr: String, requiredSchema: StructType): String = { + val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) + if (querySchemaOption.isPresent && requiredSchema.nonEmpty) { + val prunedSchema = SparkInternalSchemaConverter.convertAndPruneStructTypeToInternalSchema(requiredSchema, querySchemaOption.get()) + SerDeHelper.toJson(prunedSchema) + } else { + internalSchemaStr + } + } + + private def rebuildFilterFromParquet(oldFilter: Filter, fileSchema: InternalSchema, querySchema: InternalSchema): Filter = { + if (fileSchema == null || querySchema == null) { + oldFilter + } else { + oldFilter match { + case eq: EqualTo => + val newAttribute = InternalSchemaUtils.reBuildFilterName(eq.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else eq.copy(attribute = newAttribute) + case eqs: EqualNullSafe => + val newAttribute = InternalSchemaUtils.reBuildFilterName(eqs.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else eqs.copy(attribute = newAttribute) + case gt: GreaterThan => + val newAttribute = InternalSchemaUtils.reBuildFilterName(gt.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else gt.copy(attribute = newAttribute) + case gtr: GreaterThanOrEqual => + val newAttribute = InternalSchemaUtils.reBuildFilterName(gtr.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else gtr.copy(attribute = newAttribute) + case lt: LessThan => + val newAttribute = InternalSchemaUtils.reBuildFilterName(lt.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else lt.copy(attribute = newAttribute) + case lte: LessThanOrEqual => + val newAttribute = InternalSchemaUtils.reBuildFilterName(lte.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else lte.copy(attribute = newAttribute) + case i: In => + val newAttribute = InternalSchemaUtils.reBuildFilterName(i.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else i.copy(attribute = newAttribute) + case isn: IsNull => + val newAttribute = InternalSchemaUtils.reBuildFilterName(isn.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else isn.copy(attribute = newAttribute) + case isnn: IsNotNull => + val newAttribute = InternalSchemaUtils.reBuildFilterName(isnn.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else isnn.copy(attribute = newAttribute) + case And(left, right) => + And(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) + case Or(left, right) => + Or(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) + case Not(child) => + Not(rebuildFilterFromParquet(child, fileSchema, querySchema)) + case ssw: StringStartsWith => + val newAttribute = InternalSchemaUtils.reBuildFilterName(ssw.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else ssw.copy(attribute = newAttribute) + case ses: StringEndsWith => + val newAttribute = InternalSchemaUtils.reBuildFilterName(ses.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else ses.copy(attribute = newAttribute) + case sc: StringContains => + val newAttribute = InternalSchemaUtils.reBuildFilterName(sc.attribute, fileSchema, querySchema) + if (newAttribute.isEmpty) AlwaysTrue else sc.copy(attribute = newAttribute) + case AlwaysTrue => + AlwaysTrue + case AlwaysFalse => + AlwaysFalse + case _ => + AlwaysTrue + } + } + } +} diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/Spark3ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/Spark3ResolveHudiAlterTableCommand.scala new file mode 100644 index 0000000000000..6246c8480ab7e --- /dev/null +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/Spark3ResolveHudiAlterTableCommand.scala @@ -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.spark.sql.hudi + +import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.plans.logical.{AlterColumn, LogicalPlan} +import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} + +/** + * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. + * for alter table column commands. + */ +class Spark3ResolveHudiAlterTableCommand(sparkSession: SparkSession) + extends BaseResolveHudiAlterTableCommand(sparkSession) { + + // NOTE: The command is matched by type rather than by destructuring [[AlterColumn]], since + // the arity of its unapply differs between Spark 3.3 and Spark 3.4+ + override protected def resolveAlterColumnCommand: PartialFunction[LogicalPlan, LogicalPlan] = { + case alter: AlterColumn if alter.resolved => + alter.table match { + case ResolvedHoodieV2TablePlan(t) => + HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) + case _ => alter + } + } +} diff --git a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala index b03b6fec3b8fc..bd4ca0c751e76 100644 --- a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala @@ -20,6 +20,7 @@ package org.apache.spark.sql.hudi.command import org.apache.hudi.{HoodieSparkSqlWriter, SparkAdapterSupport} import org.apache.hudi.DataSourceWriteOptions.{SPARK_SQL_OPTIMIZED_WRITES, SPARK_SQL_WRITES_PREPPED_KEY} import org.apache.hudi.common.table.HoodieTableConfig +import org.apache.hudi.keygen.KeyGenUtils import org.apache.spark.sql._ import org.apache.spark.sql.SparkSession @@ -34,6 +35,8 @@ import org.apache.spark.sql.hudi.ProvidesHoodieConfig import org.apache.spark.sql.hudi.command.HoodieCommandMetrics.updateCommitMetrics import org.apache.spark.sql.hudi.command.HoodieLeafRunnableCommand.stripMetaFieldAttributes +import scala.collection.JavaConverters._ + case class DeleteHoodieTableCommand(catalogTable: HoodieCatalogTable, query: LogicalPlan, config: Map[String, String]) extends DataWritingCommand with SparkAdapterSupport with ProvidesHoodieConfig { @@ -76,7 +79,7 @@ object DeleteHoodieTableCommand extends SparkAdapterSupport with ProvidesHoodieC } val recordKeysStr = config.getOrElse(HoodieTableConfig.RECORDKEY_FIELDS.key(), "") - val recordKeys = recordKeysStr.split(",").filter(_.nonEmpty) + val recordKeys = KeyGenUtils.getRecordKeyFields(recordKeysStr).asScala.toSeq // get all columns which are used in condition val conditionColumns = if (condition == null) { diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/hudi/Spark33HoodieFileScanRDD.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/hudi/Spark33HoodieFileScanRDD.scala deleted file mode 100644 index b2ed3bce23321..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/hudi/Spark33HoodieFileScanRDD.scala +++ /dev/null @@ -1,36 +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.hudi - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.execution.datasources.{FilePartition, FileScanRDD, PartitionedFile} -import org.apache.spark.sql.types.StructType - -class Spark33HoodieFileScanRDD(@transient private val sparkSession: SparkSession, - read: PartitionedFile => Iterator[InternalRow], - @transient filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty) - extends FileScanRDD(sparkSession, read, filePartitions, readDataSchema, metadataColumns) - with HoodieUnsafeRDD { - - override final def collect(): Array[InternalRow] = super[HoodieUnsafeRDD].collect() -} diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystExpressionUtils.scala index e083313742408..8ebfce7785fc8 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystExpressionUtils.scala @@ -17,26 +17,16 @@ package org.apache.spark.sql -import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering import org.apache.spark.sql.catalyst.encoders.{ExpressionEncoder, RowEncoder} -import org.apache.spark.sql.catalyst.expressions.{Add, AnsiCast, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, ParseToDate, ParseToTimestamp, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} -import org.apache.spark.sql.execution.datasources.DataSourceStrategy +import org.apache.spark.sql.catalyst.expressions.{AnsiCast, Cast, Expression, ParseToDate, ParseToTimestamp} import org.apache.spark.sql.types.{DataType, StructType} -object HoodieSpark33CatalystExpressionUtils extends HoodieSpark3CatalystExpressionUtils with PredicateHelper { +object HoodieSpark33CatalystExpressionUtils extends BaseHoodieCatalystExpressionUtils { override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { RowEncoder.apply(schema).resolveAndBind() } - override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = - DataSourceStrategy.normalizeExprs(exprs, attributes) - - override def extractPredicatesWithinOutputSet(condition: Expression, - outputSet: AttributeSet): Option[Expression] = { - super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) - } - override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = expr match { case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) @@ -44,16 +34,6 @@ object HoodieSpark33CatalystExpressionUtils extends HoodieSpark3CatalystExpressi case _ => None } - override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { - expr match { - case OrderPreservingTransformation(attrRef) => Some(attrRef) - case _ => None - } - } - - def canUpCast(fromType: DataType, toType: DataType): Boolean = - Cast.canUpCast(fromType, toType) - override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = expr match { case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => @@ -63,57 +43,10 @@ object HoodieSpark33CatalystExpressionUtils extends HoodieSpark3CatalystExpressi case _ => None } - private object OrderPreservingTransformation { - def unapply(expr: Expression): Option[AttributeReference] = { - expr match { - // Date/Time Expressions - case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ParseToDate(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case ParseToTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // String Expressions - case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Left API change: Improve RuntimeReplaceable - // https://issues.apache.org/jira/browse/SPARK-38240 - case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Math Expressions - // Binary - case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Unary - case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Other - case cast @ Cast(OrderPreservingTransformation(attrRef), _, _, _) - if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) - - // Identity transformation - case attrRef: AttributeReference => Some(attrRef) - // No match - case _ => None - } + override protected def unapplyOrderPreservingDateParsing(expr: Expression): Option[Expression] = + expr match { + case ParseToDate(child, _, _) => Some(child) + case ParseToTimestamp(child, _, _, _) => Some(child) + case _ => None } - } } diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystPlanUtils.scala index 114bc958c1722..1a81ab0468665 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33CatalystPlanUtils.scala @@ -18,25 +18,14 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.{AnalysisErrorAt, ResolvedTable} -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} +import org.apache.spark.sql.catalyst.analysis.AnalysisErrorAt +import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression} import org.apache.spark.sql.catalyst.planning.ScanOperation -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} -import org.apache.spark.sql.execution.command.RepairTableCommand +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, MergeIntoTable} import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} -import org.apache.spark.sql.execution.streaming.SerializedOffset -import org.apache.spark.sql.types.StructType -object HoodieSpark33CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { - - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = - plan match { - case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) - case _ => None - } +object HoodieSpark33CatalystPlanUtils extends HoodieSpark3CatalystPlanUtils { override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { plan match { @@ -57,22 +46,6 @@ object HoodieSpark33CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { } } - override def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = - ProjectionOverSchema(schema, output) - - override def isRepairTable(plan: LogicalPlan): Boolean = { - plan.isInstanceOf[RepairTableCommand] - } - - override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { - plan match { - case rtc: RepairTableCommand => - Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) - case _ => - None - } - } - override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { a.failAnalysis(s"cannot resolve ${a.sql} in MERGE command given columns [$cols]") } @@ -80,72 +53,4 @@ object HoodieSpark33CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { override def failTableNotFound(tableName: String): Unit = { throw new AnalysisException(s"Table or view not found: $tableName") } - - override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { - plan match { - case ci @ CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => - Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) - case _ => - None - } - } - - override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { - plan match { - case ci @ DropIndex(table, indexName, ignoreIfNotExists) => - Some((table, indexName, ignoreIfNotExists)) - case _ => - None - } - } - - override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { - plan match { - case ci @ HoodieShowIndexes(table, output) => - Some((table, output)) - case _ => - None - } - } - - override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { - plan match { - case ci @ RefreshIndex(table, indexName) => - Some((table, indexName)) - case _ => - None - } - } - - override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { - plan match { - case insert: InsertIntoStatement => - Some((insert.table, insert.userSpecifiedCols, insert.partitionSpec, insert.query, insert.overwrite, insert.ifPartitionNotExists)) - case _ => - None - } - } - - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { - plan match { - case insert: InsertIntoStatement => - Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) - case _ => - None - } - } - - override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { - mergeAction match { - case UpdateAction(condition, assignments) => Some((condition, assignments)) - case _ => None - } - } - - override def extractJsonFromSerializedOffset(offset: Any): Option[String] = { - offset match { - case SerializedOffset(json) => Some(json) - case _ => None - } - } } diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33SchemaUtils.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33SchemaUtils.scala index 41748ac155535..7b968bde80648 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33SchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/HoodieSpark33SchemaUtils.scala @@ -20,17 +20,13 @@ package org.apache.spark.sql import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils -import org.apache.spark.sql.jdbc.JdbcDialect import org.apache.spark.sql.types.StructType import org.apache.spark.sql.util.SchemaUtils -import java.sql.{Connection, ResultSet} - /** * Utils on schema for Spark 3.3. */ -object HoodieSpark33SchemaUtils extends HoodieSchemaUtils { +object HoodieSpark33SchemaUtils extends HoodieSpark3SchemaUtils { override def checkColumnNameDuplication(columnNames: Seq[String], colType: String, caseSensitiveAnalysis: Boolean): Unit = { @@ -40,12 +36,4 @@ object HoodieSpark33SchemaUtils extends HoodieSchemaUtils { override def toAttributes(struct: StructType): Seq[Attribute] = { struct.toAttributes } - - override def getSchema(conn: Connection, - resultSet: ResultSet, - dialect: JdbcDialect, - alwaysNullable: Boolean = false, - isTimestampNTZ: Boolean = false): StructType = { - JdbcUtils.getSchema(resultSet, dialect, alwaysNullable) - } } diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_3Adapter.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_3Adapter.scala index a4cb5b70e72e0..25553f8ebdd74 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_3Adapter.scala +++ b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_3Adapter.scala @@ -17,7 +17,6 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.Spark33HoodieFileScanRDD import org.apache.hudi.common.schema.HoodieSchema import org.apache.hudi.storage.StorageConfiguration @@ -31,14 +30,14 @@ import org.apache.spark.sql.avro._ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, ResolvedTable} import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression} +import org.apache.spark.sql.catalyst.expressions.{Expression} import org.apache.spark.sql.catalyst.parser.ParserInterface import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, RebaseDateTime} import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback} import org.apache.spark.sql.execution.datasources._ -import org.apache.spark.sql.execution.datasources.orc.Spark33OrcReader +import org.apache.spark.sql.execution.datasources.orc.{OrcColumnarBatchReader, SparkOrcReaderBase} import org.apache.spark.sql.execution.datasources.parquet.{ParquetFileFormat, ParquetFilters, Spark33LegacyHoodieParquetFileFormat, Spark33ParquetReader} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.hudi.analysis.TableValuedFunctions @@ -100,14 +99,6 @@ class Spark3_3Adapter extends BaseSpark3Adapter { Some(new Spark33LegacyHoodieParquetFileFormat(appendPartitionValues)) } - override def createHoodieFileScanRDD(sparkSession: SparkSession, - readFunction: PartitionedFile => Iterator[InternalRow], - filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { - new Spark33HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) - } - override def extractDeleteCondition(deleteFromTable: Command): Expression = { deleteFromTable.asInstanceOf[DeleteFromTable].condition } @@ -163,7 +154,8 @@ class Spark3_3Adapter extends BaseSpark3Adapter { } override def createOrcFileReader(vectorized: Boolean, sqlConf: SQLConf, options: Map[String, String], hadoopConf: Configuration, dataSchema: StructType): SparkColumnarFileReader = { - Spark33OrcReader.build(vectorized, sqlConf, options, hadoopConf, dataSchema) + SparkOrcReaderBase.build(vectorized, sqlConf, options, hadoopConf, dataSchema, + (capacity, _) => new OrcColumnarBatchReader(capacity)) } override def createLanceFileReader(vectorized: Boolean, diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark33NestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark33NestedSchemaPruning.scala deleted file mode 100644 index c2235506eee6e..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark33NestedSchemaPruning.scala +++ /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.spark.sql.execution.datasources - -import org.apache.hudi.HoodieBaseRelation - -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.catalyst.planning.PhysicalOperation -import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan -import org.apache.spark.sql.sources.BaseRelation -import org.apache.spark.sql.types.StructType - -class Spark33NestedSchemaPruning extends BaseHoodieNestedSchemaPruning { - - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], - requiredSchema: StructType): Seq[AttributeReference] = { - // We need to replace the expression ids of the pruned relation output attributes - // with the expression ids of the original relation output attributes so that - // references to the original relation's output are not broken - val outputIdMap = output.map(att => (att.name, att.exprId)).toMap - requiredSchema - .toAttributes - .map { - case att if outputIdMap.contains(att.name) => - att.withExprId(outputIdMap(att.name)) - case att => att - } - } - - override protected def apply0(plan: LogicalPlan): LogicalPlan = - plan transformDown { - case op @ PhysicalOperation(projects, filters, - // NOTE: This is modified to accommodate for Hudi's custom relations, given that original - // [[NestedSchemaPruning]] rule is tightly coupled w/ [[HadoopFsRelation]] - // TODO generalize to any file-based relation - l @ LogicalRelation(relation: HoodieBaseRelation, _, _, _)) - if relation.canPruneRelationSchema => - - prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, - prunedDataSchema => { - val prunedRelation = - relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) - buildPrunedRelation(l, prunedRelation) - }).getOrElse(op) - } -} diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark33OrcReader.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark33OrcReader.scala deleted file mode 100644 index 25ac5938b8d1c..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark33OrcReader.scala +++ /dev/null @@ -1,85 +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.spark.sql.execution.datasources.orc - -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.Path -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile} -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType - -import java.net.URI - -class Spark33OrcReader(enableVectorizedReader: Boolean, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean, - capacity: Int) extends SparkOrcReaderBase(enableVectorizedReader, dataSchema, orcFilterPushDown, isCaseSensitive) { - - override def partitionedFileToPath(file: PartitionedFile): Path = { - new Path(new URI(file.filePath)) - } - - override def buildReader(): OrcColumnarBatchReader = { - new OrcColumnarBatchReader(capacity) - } - - override def structTypeToAttributes(schema: StructType): Seq[Attribute] = { - schema.toAttributes - } -} - -object Spark33OrcReader { - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @return ORC file reader - */ - def build(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): Spark33OrcReader = { - //set hadoopconf - hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) - hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) - - val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && - options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, - throw new IllegalArgumentException( - "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + - "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) - .equals("true") - - new Spark33OrcReader( - enableVectorizedReader = enableVectorizedReader && vectorized, - isCaseSensitive = sqlConf.caseSensitiveAnalysis, - capacity = sqlConf.orcVectorizedReaderBatchSize, - orcFilterPushDown = sqlConf.orcFilterPushDown, - dataSchema = dataSchema) - } -} - diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33DataSourceUtils.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33DataSourceUtils.scala deleted file mode 100644 index 2aa85660eb511..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33DataSourceUtils.scala +++ /dev/null @@ -1,77 +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.spark.sql.execution.datasources.parquet - -import org.apache.spark.sql.SPARK_VERSION_METADATA_KEY -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy -import org.apache.spark.util.Utils - -object Spark33DataSourceUtils { - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def int96RebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 3.0 and earlier follow the legacy hybrid calendar and we need to - // rebase the INT96 timestamp values. - // Files written by Spark 3.1 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.1.0" || lookupFileMeta("org.apache.spark.legacyINT96") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def datetimeRebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 2.4 and earlier follow the legacy hybrid calendar and we need to - // rebase the datetime values. - // Files written by Spark 3.0 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.0.0" || lookupFileMeta("org.apache.spark.legacyDateTime") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - -} diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33LegacyHoodieParquetFileFormat.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33LegacyHoodieParquetFileFormat.scala index c19d3a5126d63..fc1df0b16f8e6 100644 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33LegacyHoodieParquetFileFormat.scala +++ b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33LegacyHoodieParquetFileFormat.scala @@ -1,451 +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 + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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.parquet -import org.apache.hudi.client.utils.SparkInternalSchemaConverter -import org.apache.hudi.common.fs.FSUtils -import org.apache.hudi.common.table.timeline.TimelineLayout -import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion -import org.apache.hudi.common.util.InternalSchemaCache -import org.apache.hudi.common.util.StringUtils.isNullOrEmpty -import org.apache.hudi.common.util.collection.Pair -import org.apache.hudi.hadoop.fs.HadoopFSUtils -import org.apache.hudi.internal.schema.InternalSchema -import org.apache.hudi.internal.schema.action.InternalSchemaMerger -import org.apache.hudi.internal.schema.utils.{InternalSchemaUtils, SerDeHelper} -import org.apache.hudi.storage.HoodieStorageUtils - import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.Path -import org.apache.hadoop.mapred.FileSplit -import org.apache.hadoop.mapreduce.{JobID, TaskAttemptID, TaskID, TaskType} -import org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl -import org.apache.parquet.filter2.compat.FilterCompat -import org.apache.parquet.filter2.predicate.FilterApi -import org.apache.parquet.format.converter.ParquetMetadataConverter.SKIP_ROW_GROUPS -import org.apache.parquet.hadoop.{ParquetInputFormat, ParquetRecordReader} -import org.apache.spark.TaskContext import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{Cast, JoinedRow} -import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection -import org.apache.spark.sql.catalyst.util.DateTimeUtils -import org.apache.spark.sql.execution.datasources.{DataSourceUtils, PartitionedFile, RecordReaderIterator} -import org.apache.spark.sql.execution.datasources.parquet.Spark33LegacyHoodieParquetFileFormat._ +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.execution.datasources.PartitionedFile import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.sources._ -import org.apache.spark.sql.types.{AtomicType, DataType, StructField, StructType} -import org.apache.spark.util.SerializableConfiguration +import org.apache.spark.sql.types.StructType import java.net.URI -import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` - /** - * This class is an extension of [[ParquetFileFormat]] overriding Spark-specific behavior - * that's not possible to customize in any other way - * - * NOTE: This is a version of [[AvroDeserializer]] impl from Spark 3.2.1 w/ w/ the following changes applied to it: - *
      - *
    1. Avoiding appending partition values to the rows read from the data file
    2. - *
    3. Schema on-read
    4. - *
    + * Spark 3.3 concrete implementation of [[Spark3LegacyHoodieParquetFileFormat]]. It only overrides + * the version-specific hooks; the shared reader logic lives in the base class. */ -class Spark33LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValues: Boolean) extends ParquetFileFormat { +class Spark33LegacyHoodieParquetFileFormat(appendPartitionValues: Boolean) + extends Spark3LegacyHoodieParquetFileFormat(appendPartitionValues) { - override def buildReaderWithPartitionValues(sparkSession: SparkSession, - dataSchema: StructType, - partitionSchema: StructType, - requiredSchema: StructType, - filters: Seq[Filter], - options: Map[String, String], - hadoopConf: Configuration): PartitionedFile => Iterator[InternalRow] = { - hadoopConf.set(ParquetInputFormat.READ_SUPPORT_CLASS, classOf[ParquetReadSupport].getName) - hadoopConf.set( - ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, - requiredSchema.json) - hadoopConf.set( - ParquetWriteSupport.SPARK_ROW_SCHEMA, - requiredSchema.json) - hadoopConf.set( - SQLConf.SESSION_LOCAL_TIMEZONE.key, - sparkSession.sessionState.conf.sessionLocalTimeZone) - hadoopConf.setBoolean( - SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, - sparkSession.sessionState.conf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean( - SQLConf.CASE_SENSITIVE.key, - sparkSession.sessionState.conf.caseSensitiveAnalysis) + override protected def toAttributes(structType: StructType): Seq[Attribute] = + structType.toAttributes - ParquetWriteSupport.setSchema(requiredSchema, hadoopConf) + override protected def getFilePath(file: PartitionedFile): Path = + new Path(new URI(file.filePath)) - // Sets flags for `ParquetToSparkSchemaConverter` - hadoopConf.setBoolean( - SQLConf.PARQUET_BINARY_AS_STRING.key, - sparkSession.sessionState.conf.isParquetBinaryAsString) - hadoopConf.setBoolean( - SQLConf.PARQUET_INT96_AS_TIMESTAMP.key, - sparkSession.sessionState.conf.isParquetINT96AsTimestamp) + override protected def isVectorizedReaderEnabled(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + ParquetUtils.isBatchReadSupportedForSchema(sparkSession.sessionState.conf, resultSchema) + + override protected def getPushDownStringPredicate(sqlConf: SQLConf): Boolean = + sqlConf.parquetFilterPushDownStringStartWith + + override protected def getReturningBatch(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + supportBatch(sparkSession, resultSchema) + + override protected def setParquetTimeConfs(hadoopConf: Configuration, sparkSession: SparkSession): Unit = { // Using string value of this conf to preserve compatibility across spark versions. hadoopConf.setBoolean( "spark.sql.legacy.parquet.nanosAsLong", sparkSession.sessionState.conf.getConfString("spark.sql.legacy.parquet.nanosAsLong", "false").toBoolean ) - val internalSchemaStr = hadoopConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // For Spark DataSource v1, there's no Physical Plan projection/schema pruning w/in Spark itself, - // therefore it's safe to do schema projection here - if (!isNullOrEmpty(internalSchemaStr)) { - val prunedInternalSchemaStr = - pruneInternalSchema(internalSchemaStr, requiredSchema) - hadoopConf.set(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA, prunedInternalSchemaStr) - } - - val broadcastedHadoopConf = - sparkSession.sparkContext.broadcast(new SerializableConfiguration(hadoopConf)) - - // TODO: if you move this into the closure it reverts to the default values. - // If true, enable using the custom RecordReader for parquet. This only works for - // a subset of the types (no complex types). - val resultSchema = StructType(partitionSchema.fields ++ requiredSchema.fields) - val sqlConf = sparkSession.sessionState.conf - val enableOffHeapColumnVector = sqlConf.offHeapColumnVectorEnabled - val enableVectorizedReader: Boolean = - ParquetUtils.isBatchReadSupportedForSchema(sqlConf, resultSchema) - val enableRecordFilter: Boolean = sqlConf.parquetRecordFilterEnabled - val timestampConversion: Boolean = sqlConf.isParquetINT96TimestampConversion - val capacity = sqlConf.parquetVectorizedReaderBatchSize - val enableParquetFilterPushDown: Boolean = sqlConf.parquetFilterPushDown - // Whole stage codegen (PhysicalRDD) is able to deal with batches directly - val returningBatch = supportBatch(sparkSession, resultSchema) - val pushDownDate = sqlConf.parquetFilterPushDownDate - val pushDownTimestamp = sqlConf.parquetFilterPushDownTimestamp - val pushDownDecimal = sqlConf.parquetFilterPushDownDecimal - val pushDownStringStartWith = sqlConf.parquetFilterPushDownStringStartWith - val pushDownInFilterThreshold = sqlConf.parquetFilterPushDownInFilterThreshold - val isCaseSensitive = sqlConf.caseSensitiveAnalysis - val parquetOptions = new ParquetOptions(options, sparkSession.sessionState.conf) - val datetimeRebaseModeInRead = parquetOptions.datetimeRebaseModeInRead - val int96RebaseModeInRead = parquetOptions.int96RebaseModeInRead - val timeZoneId = Option(sqlConf.sessionLocalTimeZone) - - (file: PartitionedFile) => { - assert(!shouldAppendPartitionValues || file.partitionValues.numFields == partitionSchema.size) - - val filePath = new Path(new URI(file.filePath)) - val split = new FileSplit(filePath, file.start, file.length, Array.empty[String]) - - val sharedConf = broadcastedHadoopConf.value.value - - // Fetch internal schema - val internalSchemaStr = sharedConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // Internal schema has to be pruned at this point - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - - var shouldUseInternalSchema = !isNullOrEmpty(internalSchemaStr) && querySchemaOption.isPresent - - val tablePath = sharedConf.get(SparkInternalSchemaConverter.HOODIE_TABLE_PATH) - val fileSchema = if (shouldUseInternalSchema) { - val commitInstantTime = FSUtils.getCommitTime(filePath.getName).toLong; - val validCommits = sharedConf.get(SparkInternalSchemaConverter.HOODIE_VALID_COMMITS_LIST) - //TODO: HARDCODED TIMELINE OBJECT - val layout = TimelineLayout.fromVersion(TimelineLayoutVersion.CURR_LAYOUT_VERSION) - val storage = HoodieStorageUtils.getStorage(tablePath, HadoopFSUtils.getStorageConf(sharedConf)) - InternalSchemaCache.getInternalSchemaByVersionId( - commitInstantTime, tablePath, storage, if (validCommits == null) "" else validCommits, - layout) - } else { - null - } - - lazy val footerFileMetaData = - ParquetFooterReader.readFooter(sharedConf, filePath, SKIP_ROW_GROUPS).getFileMetaData - // Try to push down filters when filter push-down is enabled. - val pushed = if (enableParquetFilterPushDown) { - val parquetSchema = footerFileMetaData.getSchema - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val parquetFilters = new ParquetFilters( - parquetSchema, - pushDownDate, - pushDownTimestamp, - pushDownDecimal, - pushDownStringStartWith, - pushDownInFilterThreshold, - isCaseSensitive, - datetimeRebaseSpec) - filters.map(rebuildFilterFromParquet(_, fileSchema, querySchemaOption.orElse(null))) - // Collects all converted Parquet filter predicates. Notice that not all predicates can be - // converted (`ParquetFilters.createFilter` returns an `Option`). That's why a `flatMap` - // is used here. - .flatMap(parquetFilters.createFilter) - .reduceOption(FilterApi.and) - } else { - None - } - - // PARQUET_INT96_TIMESTAMP_CONVERSION says to apply timezone conversions to int96 timestamps' - // *only* if the file was created by something other than "parquet-mr", so check the actual - // writer here for this file. We have to do this per-file, as each file in the table may - // have different writers. - // Define isCreatedByParquetMr as function to avoid unnecessary parquet footer reads. - def isCreatedByParquetMr: Boolean = - footerFileMetaData.getCreatedBy().startsWith("parquet-mr") - - val convertTz = - if (timestampConversion && !isCreatedByParquetMr) { - Some(DateTimeUtils.getZoneId(sharedConf.get(SQLConf.SESSION_LOCAL_TIMEZONE.key))) - } else { - None - } - - val attemptId = new TaskAttemptID(new TaskID(new JobID(), TaskType.MAP, 0), 0) - - // Clone new conf - val hadoopAttemptConf = new Configuration(broadcastedHadoopConf.value.value) - val typeChangeInfos: java.util.Map[Integer, Pair[DataType, DataType]] = if (shouldUseInternalSchema) { - val mergedInternalSchema = new InternalSchemaMerger(fileSchema, querySchemaOption.get(), true, true).mergeSchema() - val mergedSchema = SparkInternalSchemaConverter.constructSparkSchemaFromInternalSchema(mergedInternalSchema) - - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, mergedSchema.json) - - SparkInternalSchemaConverter.collectTypeChangedCols(querySchemaOption.get(), mergedInternalSchema) - } else { - val (implicitTypeChangeInfo, sparkRequestSchema) = HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo(hadoopAttemptConf, footerFileMetaData, requiredSchema) - if (!implicitTypeChangeInfo.isEmpty) { - shouldUseInternalSchema = true - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, sparkRequestSchema.json) - } - implicitTypeChangeInfo - } - - if (enableVectorizedReader && shouldUseInternalSchema && - !typeChangeInfos.values().forall(_.getLeft.isInstanceOf[AtomicType])) { - throw new IllegalArgumentException( - "Nested types with type changes(implicit or explicit) cannot be read in vectorized mode. " + - "To workaround this issue, set spark.sql.parquet.enableVectorizedReader=false.") - } - - val hadoopAttemptContext = - new TaskAttemptContextImpl(hadoopAttemptConf, attemptId) - - // Try to push down filters when filter push-down is enabled. - // Notice: This push-down is RowGroups level, not individual records. - if (pushed.isDefined) { - ParquetInputFormat.setFilterPredicate(hadoopAttemptContext.getConfiguration, pushed.get) - } - val taskContext = Option(TaskContext.get()) - if (enableVectorizedReader) { - val vectorizedReader = - if (shouldUseInternalSchema) { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new HoodieVectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity, - typeChangeInfos) - } else { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new VectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity) - } - - // SPARK-37089: We cannot register a task completion listener to close this iterator here - // because downstream exec nodes have already registered their listeners. Since listeners - // are executed in reverse order of registration, a listener registered here would close the - // iterator while downstream exec nodes are still running. When off-heap column vectors are - // enabled, this can cause a use-after-free bug leading to a segfault. - // - // Instead, we use FileScanRDD's task completion listener to close this iterator. - val iter = new RecordReaderIterator(vectorizedReader) - try { - vectorizedReader.initialize(split, hadoopAttemptContext) - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (shouldAppendPartitionValues) { - logDebug(s"Appending $partitionSchema ${file.partitionValues}") - vectorizedReader.initBatch(partitionSchema, file.partitionValues) - } else { - vectorizedReader.initBatch(StructType(Nil), InternalRow.empty) - } - - if (returningBatch) { - vectorizedReader.enableReturningBatches() - } - - // UnsafeRowParquetRecordReader appends the columns internally to avoid another copy. - iter.asInstanceOf[Iterator[InternalRow]] - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } else { - logDebug(s"Falling back to parquet-mr") - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val readSupport = new HoodieParquetReadSupport( - convertTz, - enableVectorizedReader = false, - enableTimestampFieldRepair = true, - datetimeRebaseSpec, - int96RebaseSpec) - - val reader = if (pushed.isDefined && enableRecordFilter) { - val parquetFilter = FilterCompat.get(pushed.get, null) - new ParquetRecordReader[InternalRow](readSupport, parquetFilter) - } else { - new ParquetRecordReader[InternalRow](readSupport) - } - val iter = new RecordReaderIterator[InternalRow](reader) - try { - reader.initialize(split, hadoopAttemptContext) - - val fullSchema = requiredSchema.toAttributes ++ partitionSchema.toAttributes - val unsafeProjection = if (typeChangeInfos.isEmpty) { - GenerateUnsafeProjection.generate(fullSchema, fullSchema) - } else { - // find type changed. - val newFullSchema = new StructType(requiredSchema.fields.zipWithIndex.map { case (f, i) => - if (typeChangeInfos.containsKey(i)) { - StructField(f.name, typeChangeInfos.get(i).getRight, f.nullable, f.metadata) - } else f - }).toAttributes ++ partitionSchema.toAttributes - val castSchema = newFullSchema.zipWithIndex.map { case (attr, i) => - if (typeChangeInfos.containsKey(i)) { - val srcType = typeChangeInfos.get(i).getRight - val dstType = typeChangeInfos.get(i).getLeft - val needTimeZone = Cast.needsTimeZone(srcType, dstType) - Cast(attr, dstType, if (needTimeZone) timeZoneId else None) - } else attr - } - GenerateUnsafeProjection.generate(castSchema, newFullSchema) - } - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (!shouldAppendPartitionValues || partitionSchema.length == 0) { - // There is no partition columns - iter.map(unsafeProjection) - } else { - val joinedRow = new JoinedRow() - iter.map(d => unsafeProjection(joinedRow(d, file.partitionValues))) - } - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } - } - } -} - -object Spark33LegacyHoodieParquetFileFormat { - - def pruneInternalSchema(internalSchemaStr: String, requiredSchema: StructType): String = { - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - if (querySchemaOption.isPresent && requiredSchema.nonEmpty) { - val prunedSchema = SparkInternalSchemaConverter.convertAndPruneStructTypeToInternalSchema(requiredSchema, querySchemaOption.get()) - SerDeHelper.toJson(prunedSchema) - } else { - internalSchemaStr - } - } - - private def rebuildFilterFromParquet(oldFilter: Filter, fileSchema: InternalSchema, querySchema: InternalSchema): Filter = { - if (fileSchema == null || querySchema == null) { - oldFilter - } else { - oldFilter match { - case eq: EqualTo => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eq.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eq.copy(attribute = newAttribute) - case eqs: EqualNullSafe => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eqs.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eqs.copy(attribute = newAttribute) - case gt: GreaterThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gt.copy(attribute = newAttribute) - case gtr: GreaterThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gtr.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gtr.copy(attribute = newAttribute) - case lt: LessThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lt.copy(attribute = newAttribute) - case lte: LessThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lte.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lte.copy(attribute = newAttribute) - case i: In => - val newAttribute = InternalSchemaUtils.reBuildFilterName(i.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else i.copy(attribute = newAttribute) - case isn: IsNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isn.copy(attribute = newAttribute) - case isnn: IsNotNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isnn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isnn.copy(attribute = newAttribute) - case And(left, right) => - And(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Or(left, right) => - Or(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Not(child) => - Not(rebuildFilterFromParquet(child, fileSchema, querySchema)) - case ssw: StringStartsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ssw.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ssw.copy(attribute = newAttribute) - case ses: StringEndsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ses.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ses.copy(attribute = newAttribute) - case sc: StringContains => - val newAttribute = InternalSchemaUtils.reBuildFilterName(sc.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else sc.copy(attribute = newAttribute) - case AlwaysTrue => - AlwaysTrue - case AlwaysFalse => - AlwaysFalse - case _ => - AlwaysTrue - } - } } } diff --git a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/hudi/Spark33ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/hudi/Spark33ResolveHudiAlterTableCommand.scala deleted file mode 100644 index 55159bcc71b49..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/hudi/Spark33ResolveHudiAlterTableCommand.scala +++ /dev/null @@ -1,67 +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.spark.sql.hudi - -import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.analysis.ResolvedTable -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table -import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} - -/** - * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. - * for alter table column commands. - */ -class Spark33ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { - - def apply(plan: LogicalPlan): LogicalPlan = { - if (ProvidesHoodieConfig.isSchemaEvolutionEnabled(sparkSession)) { - plan.resolveOperatorsUp { - case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => - HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) - case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => - HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) - case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => - HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) - case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => - HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) - case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => - HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) - case alter@AlterColumn(ResolvedHoodieV2TablePlan(t), _, _, _, _, _) if alter.resolved => - HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) - case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => - HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) - } - } else { - plan - } - } - - object ResolvedHoodieV2TablePlan { - def unapply(plan: LogicalPlan): Option[HoodieInternalV2Table] = { - plan match { - case ResolvedTable(_, _, v2Table: HoodieInternalV2Table, _) => Some(v2Table) - case _ => None - } - } - } -} - diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/hudi/Spark34HoodieFileScanRDD.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/hudi/Spark34HoodieFileScanRDD.scala deleted file mode 100644 index df86e5b169c07..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/hudi/Spark34HoodieFileScanRDD.scala +++ /dev/null @@ -1,36 +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.hudi - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.execution.datasources.{FilePartition, FileScanRDD, PartitionedFile} -import org.apache.spark.sql.types.StructType - -class Spark34HoodieFileScanRDD(@transient private val sparkSession: SparkSession, - read: PartitionedFile => Iterator[InternalRow], - @transient filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty) - extends FileScanRDD(sparkSession, read, filePartitions, readDataSchema, metadataColumns) - with HoodieUnsafeRDD { - - override final def collect(): Array[InternalRow] = super[HoodieUnsafeRDD].collect() -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystExpressionUtils.scala index 03c7d0412f8a2..f5767eb82a76b 100644 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystExpressionUtils.scala @@ -17,26 +17,16 @@ package org.apache.spark.sql -import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering import org.apache.spark.sql.catalyst.encoders.{ExpressionEncoder, RowEncoder} -import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, EvalMode, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, ParseToDate, ParseToTimestamp, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} -import org.apache.spark.sql.execution.datasources.DataSourceStrategy +import org.apache.spark.sql.catalyst.expressions.{Cast, EvalMode, Expression, ParseToDate, ParseToTimestamp} import org.apache.spark.sql.types.{DataType, StructType} -object HoodieSpark34CatalystExpressionUtils extends HoodieSpark3CatalystExpressionUtils with PredicateHelper { +object HoodieSpark34CatalystExpressionUtils extends BaseHoodieCatalystExpressionUtils { override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { RowEncoder.apply(schema).resolveAndBind() } - override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = { - DataSourceStrategy.normalizeExprs(exprs, attributes) - } - - override def extractPredicatesWithinOutputSet(condition: Expression, outputSet: AttributeSet): Option[Expression] = { - super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) - } - override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = { expr match { case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) @@ -44,16 +34,6 @@ object HoodieSpark34CatalystExpressionUtils extends HoodieSpark3CatalystExpressi } } - override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { - expr match { - case OrderPreservingTransformation(attrRef) => Some(attrRef) - case _ => None - } - } - - def canUpCast(fromType: DataType, toType: DataType): Boolean = - Cast.canUpCast(fromType, toType) - override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = expr match { case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => @@ -61,57 +41,10 @@ object HoodieSpark34CatalystExpressionUtils extends HoodieSpark3CatalystExpressi case _ => None } - private object OrderPreservingTransformation { - def unapply(expr: Expression): Option[AttributeReference] = { - expr match { - // Date/Time Expressions - case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ParseToDate(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case ParseToTimestamp(OrderPreservingTransformation(attrRef), _, _, _, _) => Some(attrRef) - case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // String Expressions - case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Left API change: Improve RuntimeReplaceable - // https://issues.apache.org/jira/browse/SPARK-38240 - case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Math Expressions - // Binary - case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Unary - case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Other - case cast @ Cast(OrderPreservingTransformation(attrRef), _, _, _) - if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) - - // Identity transformation - case attrRef: AttributeReference => Some(attrRef) - // No match - case _ => None - } + override protected def unapplyOrderPreservingDateParsing(expr: Expression): Option[Expression] = + expr match { + case ParseToDate(child, _, _) => Some(child) + case ParseToTimestamp(child, _, _, _, _) => Some(child) + case _ => None } - } } diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystPlanUtils.scala index 78c918f325a13..97b922ba4cb61 100644 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34CatalystPlanUtils.scala @@ -18,26 +18,15 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.{AnalysisErrorAt, ResolvedTable} -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} +import org.apache.spark.sql.catalyst.analysis.AnalysisErrorAt +import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression} import org.apache.spark.sql.catalyst.planning.ScanOperation -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} -import org.apache.spark.sql.execution.command.RepairTableCommand +import org.apache.spark.sql.catalyst.plans.logical.{InsertIntoStatement, LogicalPlan, MergeIntoTable} import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} -import org.apache.spark.sql.execution.streaming.SerializedOffset import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType -object HoodieSpark34CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { - - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = - plan match { - case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) - case _ => None - } +object HoodieSpark34CatalystPlanUtils extends HoodieSpark3CatalystPlanUtils { override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { plan match { @@ -58,22 +47,6 @@ object HoodieSpark34CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { } } - override def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = - ProjectionOverSchema(schema, output) - - override def isRepairTable(plan: LogicalPlan): Boolean = { - plan.isInstanceOf[RepairTableCommand] - } - - override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { - plan match { - case rtc: RepairTableCommand => - Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) - case _ => - None - } - } - override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { a.failAnalysis( errorClass = "_LEGACY_ERROR_TEMP_2309", @@ -88,42 +61,6 @@ object HoodieSpark34CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { messageParameters = Map("relationName" -> s"`$tableName`")) } - override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { - plan match { - case ci@CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => - Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) - case _ => - None - } - } - - override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { - plan match { - case ci@DropIndex(table, indexName, ignoreIfNotExists) => - Some((table, indexName, ignoreIfNotExists)) - case _ => - None - } - } - - override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { - plan match { - case ci@HoodieShowIndexes(table, output) => - Some((table, output)) - case _ => - None - } - } - - override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { - plan match { - case ci@RefreshIndex(table, indexName) => - Some((table, indexName)) - case _ => - None - } - } - override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { plan match { case insert: InsertIntoStatement => @@ -145,27 +82,4 @@ object HoodieSpark34CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { None } } - - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { - plan match { - case insert: InsertIntoStatement => - Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) - case _ => - None - } - } - - override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { - mergeAction match { - case UpdateAction(condition, assignments) => Some((condition, assignments)) - case _ => None - } - } - - override def extractJsonFromSerializedOffset(offset: Any): Option[String] = { - offset match { - case SerializedOffset(json) => Some(json) - case _ => None - } - } } diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34SchemaUtils.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34SchemaUtils.scala index c5fb1beded17d..c272208f7b8a4 100644 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34SchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/HoodieSpark34SchemaUtils.scala @@ -20,17 +20,13 @@ package org.apache.spark.sql import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils -import org.apache.spark.sql.jdbc.JdbcDialect import org.apache.spark.sql.types.StructType import org.apache.spark.sql.util.SchemaUtils -import java.sql.{Connection, ResultSet} - /** * Utils on schema for Spark 3.4. */ -object HoodieSpark34SchemaUtils extends HoodieSchemaUtils { +object HoodieSpark34SchemaUtils extends HoodieSpark3SchemaUtils { override def checkColumnNameDuplication(columnNames: Seq[String], colType: String, caseSensitiveAnalysis: Boolean): Unit = { @@ -40,12 +36,4 @@ object HoodieSpark34SchemaUtils extends HoodieSchemaUtils { override def toAttributes(struct: StructType): Seq[Attribute] = { struct.toAttributes } - - override def getSchema(conn: Connection, - resultSet: ResultSet, - dialect: JdbcDialect, - alwaysNullable: Boolean = false, - isTimestampNTZ: Boolean = false): StructType = { - JdbcUtils.getSchema(resultSet, dialect, alwaysNullable) - } } diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_4Adapter.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_4Adapter.scala index 923c8ac91959c..3d62ba1edc0c7 100644 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_4Adapter.scala +++ b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_4Adapter.scala @@ -17,7 +17,6 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.Spark34HoodieFileScanRDD import org.apache.hudi.common.schema.HoodieSchema import org.apache.hudi.storage.StorageConfiguration @@ -31,7 +30,7 @@ import org.apache.spark.sql.avro._ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, ResolvedTable} import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression} +import org.apache.spark.sql.catalyst.expressions.{Expression} import org.apache.spark.sql.catalyst.parser.ParserInterface import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical._ @@ -39,7 +38,7 @@ import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, RebaseDateTime import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback} import org.apache.spark.sql.execution.datasources._ import org.apache.spark.sql.execution.datasources.lance.SparkLanceReaderBase -import org.apache.spark.sql.execution.datasources.orc.Spark34OrcReader +import org.apache.spark.sql.execution.datasources.orc.{OrcColumnarBatchReader, SparkOrcReaderBase} import org.apache.spark.sql.execution.datasources.parquet.{ParquetFileFormat, ParquetFilters, Spark34LegacyHoodieParquetFileFormat, Spark34ParquetReader} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.hudi.analysis.TableValuedFunctions @@ -101,14 +100,6 @@ class Spark3_4Adapter extends BaseSpark3Adapter { Some(new Spark34LegacyHoodieParquetFileFormat(appendPartitionValues)) } - override def createHoodieFileScanRDD(sparkSession: SparkSession, - readFunction: PartitionedFile => Iterator[InternalRow], - filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { - new Spark34HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) - } - override def extractDeleteCondition(deleteFromTable: Command): Expression = { deleteFromTable.asInstanceOf[DeleteFromTable].condition } @@ -164,7 +155,8 @@ class Spark3_4Adapter extends BaseSpark3Adapter { } override def createOrcFileReader(vectorized: Boolean, sqlConf: SQLConf, options: Map[String, String], hadoopConf: Configuration, dataSchema: StructType): SparkColumnarFileReader = { - Spark34OrcReader.build(vectorized, sqlConf, options, hadoopConf, dataSchema) + SparkOrcReaderBase.build(vectorized, sqlConf, options, hadoopConf, dataSchema, + (capacity, memoryMode) => new OrcColumnarBatchReader(capacity, memoryMode)) } override def createLanceFileReader(vectorized: Boolean, diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala deleted file mode 100644 index b5eba6be24cd3..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala +++ /dev/null @@ -1,531 +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.spark.sql.avro - -import org.apache.hudi.common.schema.HoodieSchema -import org.apache.hudi.common.schema.HoodieSchema.VectorLogicalType - -import org.apache.avro.{LogicalTypes, Schema, SchemaBuilder} -import org.apache.avro.Conversions.DecimalConversion -import org.apache.avro.LogicalTypes.{LocalTimestampMicros, LocalTimestampMillis, TimestampMicros, TimestampMillis} -import org.apache.avro.Schema.Type._ -import org.apache.avro.generic._ -import org.apache.avro.util.Utf8 -import org.apache.spark.sql.avro.AvroDeserializer.{createDateRebaseFuncInRead, createTimestampRebaseFuncInRead, RebaseSpec} -import org.apache.spark.sql.avro.AvroUtils.{toFieldStr, AvroMatchedField} -import org.apache.spark.sql.catalyst.{InternalRow, NoopFilters, StructFilters} -import org.apache.spark.sql.catalyst.expressions.{SpecificInternalRow, UnsafeArrayData} -import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData, DateTimeUtils, GenericArrayData, RebaseDateTime} -import org.apache.spark.sql.catalyst.util.DateTimeConstants.MILLIS_PER_DAY -import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy -import org.apache.spark.sql.types._ -import org.apache.spark.unsafe.types.UTF8String - -import java.math.BigDecimal -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.TimeZone - -import scala.collection.JavaConverters._ - -/** - * A deserializer to deserialize data in avro format to data in catalyst format. - * - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] class AvroDeserializer(rootAvroType: Schema, - rootCatalystType: DataType, - positionalFieldMatch: Boolean, - datetimeRebaseSpec: RebaseSpec, - filters: StructFilters) { - - def this(rootAvroType: Schema, - rootCatalystType: DataType, - datetimeRebaseMode: String) = { - this( - rootAvroType, - rootCatalystType, - positionalFieldMatch = false, - RebaseSpec(LegacyBehaviorPolicy.withName(datetimeRebaseMode)), - new NoopFilters) - } - - private lazy val decimalConversions = new DecimalConversion() - - private val dateRebaseFunc = createDateRebaseFuncInRead(datetimeRebaseSpec.mode, "Avro") - - private val timestampRebaseFunc = createTimestampRebaseFuncInRead(datetimeRebaseSpec, "Avro") - - private val converter: Any => Option[Any] = try { - rootCatalystType match { - // A shortcut for empty schema. - case st: StructType if st.isEmpty => - (_: Any) => Some(InternalRow.empty) - - case st: StructType => - val resultRow = new SpecificInternalRow(st.map(_.dataType)) - val fieldUpdater = new RowUpdater(resultRow) - val applyFilters = filters.skipRow(resultRow, _) - val writer = getRecordWriter(rootAvroType, st, Nil, Nil, applyFilters) - (data: Any) => { - val record = data.asInstanceOf[GenericRecord] - val skipRow = writer(fieldUpdater, record) - if (skipRow) None else Some(resultRow) - } - - case _ => - val tmpRow = new SpecificInternalRow(Seq(rootCatalystType)) - val fieldUpdater = new RowUpdater(tmpRow) - val writer = newWriter(rootAvroType, rootCatalystType, Nil, Nil) - (data: Any) => { - writer(fieldUpdater, 0, data) - Some(tmpRow.get(0, rootCatalystType)) - } - } - } catch { - case ise: IncompatibleSchemaException => throw new IncompatibleSchemaException( - s"Cannot convert Avro type $rootAvroType to SQL type ${rootCatalystType.sql}.", ise) - } - - def deserialize(data: Any): Option[Any] = converter(data) - - /** - * Creates a writer to write avro values to Catalyst values at the given ordinal with the given - * updater. - */ - private def newWriter(avroType: Schema, - catalystType: DataType, - avroPath: Seq[String], - catalystPath: Seq[String]): (CatalystDataUpdater, Int, Any) => Unit = { - val errorPrefix = s"Cannot convert Avro ${toFieldStr(avroPath)} to " + - s"SQL ${toFieldStr(catalystPath)} because " - val incompatibleMsg = errorPrefix + - s"schema is incompatible (avroType = $avroType, sqlType = ${catalystType.sql})" - - (avroType.getType, catalystType) match { - case (NULL, NullType) => (updater, ordinal, _) => - updater.setNullAt(ordinal) - - // TODO: we can avoid boxing if future version of avro provide primitive accessors. - case (BOOLEAN, BooleanType) => (updater, ordinal, value) => - updater.setBoolean(ordinal, value.asInstanceOf[Boolean]) - - case (INT, IntegerType) => (updater, ordinal, value) => - updater.setInt(ordinal, value.asInstanceOf[Int]) - - case (INT, DateType) => (updater, ordinal, value) => - updater.setInt(ordinal, dateRebaseFunc(value.asInstanceOf[Int])) - - case (LONG, LongType) => (updater, ordinal, value) => - updater.setLong(ordinal, value.asInstanceOf[Long]) - - case (LONG, TimestampType) => avroType.getLogicalType match { - // For backward compatibility, if the Avro type is Long and it is not logical type - // (the `null` case), the value is processed as timestamp type with millisecond precision. - case null | _: TimestampMillis => (updater, ordinal, value) => - val millis = value.asInstanceOf[Long] - val micros = DateTimeUtils.millisToMicros(millis) - updater.setLong(ordinal, timestampRebaseFunc(micros)) - case _: TimestampMicros => (updater, ordinal, value) => - val micros = value.asInstanceOf[Long] - updater.setLong(ordinal, timestampRebaseFunc(micros)) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"Avro logical type $other cannot be converted to SQL type ${TimestampType.sql}.") - } - - case (LONG, TimestampNTZType) => avroType.getLogicalType match { - // To keep consistent with TimestampType, if the Avro type is Long and it is not - // logical type (the `null` case), the value is processed as TimestampNTZ - // with millisecond precision. - case null | _: LocalTimestampMillis => (updater, ordinal, value) => - val millis = value.asInstanceOf[Long] - val micros = DateTimeUtils.millisToMicros(millis) - updater.setLong(ordinal, micros) - case _: LocalTimestampMicros => (updater, ordinal, value) => - val micros = value.asInstanceOf[Long] - updater.setLong(ordinal, micros) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"Avro logical type $other cannot be converted to SQL type ${TimestampNTZType.sql}.") - } - - // Handle VECTOR logical type (FLOAT, DOUBLE, INT8) - case (FIXED, ArrayType(elementType, false)) => avroType.getLogicalType match { - case vectorLogicalType: VectorLogicalType => - val dimension = vectorLogicalType.getDimension - val vecElementType = HoodieSchema.Vector.VectorElementType.fromString(vectorLogicalType.getElementType) - val elementSize = vecElementType.getElementSize - (updater, ordinal, value) => { - val bytes = value.asInstanceOf[GenericData.Fixed].bytes() - val expectedSize = Math.multiplyExact(dimension, elementSize) - if (bytes.length != expectedSize) { - throw new IncompatibleSchemaException( - s"VECTOR byte size mismatch: expected=$expectedSize, actual=${bytes.length}") - } - elementType match { - case FloatType => - val buffer = ByteBuffer.wrap(bytes).order(VectorLogicalType.VECTOR_BYTE_ORDER) - val floats = new Array[Float](dimension) - var i = 0; while (i < dimension) { floats(i) = buffer.getFloat(); i += 1 } - updater.set(ordinal, ArrayData.toArrayData(floats)) - case DoubleType => - val buffer = ByteBuffer.wrap(bytes).order(VectorLogicalType.VECTOR_BYTE_ORDER) - val doubles = new Array[Double](dimension) - var i = 0; while (i < dimension) { doubles(i) = buffer.getDouble(); i += 1 } - updater.set(ordinal, ArrayData.toArrayData(doubles)) - case ByteType => - updater.set(ordinal, ArrayData.toArrayData(bytes.clone())) - } - } - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - - // Before we upgrade Avro to 1.8 for logical type support, spark-avro converts Long to Date. - // For backward compatibility, we still keep this conversion. - case (LONG, DateType) => (updater, ordinal, value) => - updater.setInt(ordinal, (value.asInstanceOf[Long] / MILLIS_PER_DAY).toInt) - - case (FLOAT, FloatType) => (updater, ordinal, value) => - updater.setFloat(ordinal, value.asInstanceOf[Float]) - - case (DOUBLE, DoubleType) => (updater, ordinal, value) => - updater.setDouble(ordinal, value.asInstanceOf[Double]) - - case (STRING, StringType) => (updater, ordinal, value) => - val str = value match { - case s: String => UTF8String.fromString(s) - case s: Utf8 => - val bytes = new Array[Byte](s.getByteLength) - System.arraycopy(s.getBytes, 0, bytes, 0, s.getByteLength) - UTF8String.fromBytes(bytes) - case s: GenericData.EnumSymbol => UTF8String.fromString(s.toString) - } - updater.set(ordinal, str) - - case (ENUM, StringType) => (updater, ordinal, value) => - updater.set(ordinal, UTF8String.fromString(value.toString)) - - case (FIXED, BinaryType) => (updater, ordinal, value) => - updater.set(ordinal, value.asInstanceOf[GenericFixed].bytes().clone()) - - case (BYTES, BinaryType) => (updater, ordinal, value) => - val bytes = value match { - case b: ByteBuffer => - val bytes = new Array[Byte](b.remaining) - b.get(bytes) - // Do not forget to reset the position - b.rewind() - bytes - case b: Array[Byte] => b - case other => - throw new RuntimeException(errorPrefix + s"$other is not a valid avro binary.") - } - updater.set(ordinal, bytes) - - case (FIXED, _: DecimalType) => (updater, ordinal, value) => - val d = avroType.getLogicalType.asInstanceOf[LogicalTypes.Decimal] - val bigDecimal = decimalConversions.fromFixed(value.asInstanceOf[GenericFixed], avroType, d) - val decimal = createDecimal(bigDecimal, d.getPrecision, d.getScale) - updater.setDecimal(ordinal, decimal) - - case (BYTES, _: DecimalType) => (updater, ordinal, value) => - val d = avroType.getLogicalType.asInstanceOf[LogicalTypes.Decimal] - val bigDecimal = decimalConversions.fromBytes(value.asInstanceOf[ByteBuffer], avroType, d) - val decimal = createDecimal(bigDecimal, d.getPrecision, d.getScale) - updater.setDecimal(ordinal, decimal) - - case (RECORD, st: StructType) => - // Avro datasource doesn't accept filters with nested attributes. See SPARK-32328. - // We can always return `false` from `applyFilters` for nested records. - val writeRecord = - getRecordWriter(avroType, st, avroPath, catalystPath, applyFilters = _ => false) - (updater, ordinal, value) => - val row = new SpecificInternalRow(st) - writeRecord(new RowUpdater(row), value.asInstanceOf[GenericRecord]) - updater.set(ordinal, row) - - case (ARRAY, ArrayType(elementType, containsNull)) => - val avroElementPath = avroPath :+ "element" - val elementWriter = newWriter(avroType.getElementType, elementType, - avroElementPath, catalystPath :+ "element") - (updater, ordinal, value) => - val collection = value.asInstanceOf[java.util.Collection[Any]] - val result = createArrayData(elementType, collection.size()) - val elementUpdater = new ArrayDataUpdater(result) - - var i = 0 - val iter = collection.iterator() - while (iter.hasNext) { - val element = iter.next() - if (element == null) { - if (!containsNull) { - throw new RuntimeException( - s"Array value at path ${toFieldStr(avroElementPath)} is not allowed to be null") - } else { - elementUpdater.setNullAt(i) - } - } else { - elementWriter(elementUpdater, i, element) - } - i += 1 - } - - updater.set(ordinal, result) - - case (MAP, MapType(keyType, valueType, valueContainsNull)) if keyType == StringType => - val keyWriter = newWriter(SchemaBuilder.builder().stringType(), StringType, - avroPath :+ "key", catalystPath :+ "key") - val valueWriter = newWriter(avroType.getValueType, valueType, - avroPath :+ "value", catalystPath :+ "value") - (updater, ordinal, value) => - val map = value.asInstanceOf[java.util.Map[AnyRef, AnyRef]] - val keyArray = createArrayData(keyType, map.size()) - val keyUpdater = new ArrayDataUpdater(keyArray) - val valueArray = createArrayData(valueType, map.size()) - val valueUpdater = new ArrayDataUpdater(valueArray) - val iter = map.entrySet().iterator() - var i = 0 - while (iter.hasNext) { - val entry = iter.next() - assert(entry.getKey != null) - keyWriter(keyUpdater, i, entry.getKey) - if (entry.getValue == null) { - if (!valueContainsNull) { - throw new RuntimeException( - s"Map value at path ${toFieldStr(avroPath :+ "value")} is not allowed to be null") - } else { - valueUpdater.setNullAt(i) - } - } else { - valueWriter(valueUpdater, i, entry.getValue) - } - i += 1 - } - - // The Avro map will never have null or duplicated map keys, it's safe to create a - // ArrayBasedMapData directly here. - updater.set(ordinal, new ArrayBasedMapData(keyArray, valueArray)) - - case (UNION, _) => - val allTypes = avroType.getTypes.asScala - val nonNullTypes = allTypes.filter(_.getType != NULL) - val nonNullAvroType = Schema.createUnion(nonNullTypes.asJava) - if (nonNullTypes.nonEmpty) { - if (nonNullTypes.length == 1) { - newWriter(nonNullTypes.head, catalystType, avroPath, catalystPath) - } else { - nonNullTypes.map(_.getType).toSeq match { - case Seq(a, b) if Set(a, b) == Set(INT, LONG) && catalystType == LongType => - (updater, ordinal, value) => value match { - case null => updater.setNullAt(ordinal) - case l: java.lang.Long => updater.setLong(ordinal, l) - case i: java.lang.Integer => updater.setLong(ordinal, i.longValue()) - } - - case Seq(a, b) if Set(a, b) == Set(FLOAT, DOUBLE) && catalystType == DoubleType => - (updater, ordinal, value) => value match { - case null => updater.setNullAt(ordinal) - case d: java.lang.Double => updater.setDouble(ordinal, d) - case f: java.lang.Float => updater.setDouble(ordinal, f.doubleValue()) - } - - case _ => - catalystType match { - case st: StructType if st.length == nonNullTypes.size => - val fieldWriters = nonNullTypes.zip(st.fields).map { - case (schema, field) => - newWriter(schema, field.dataType, avroPath, catalystPath :+ field.name) - }.toArray - (updater, ordinal, value) => { - val row = new SpecificInternalRow(st) - val fieldUpdater = new RowUpdater(row) - val i = GenericData.get().resolveUnion(nonNullAvroType, value) - fieldWriters(i)(fieldUpdater, i, value) - updater.set(ordinal, row) - } - - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - } - } - } else { - (updater, ordinal, _) => updater.setNullAt(ordinal) - } - - case (INT, _: YearMonthIntervalType) => (updater, ordinal, value) => - updater.setInt(ordinal, value.asInstanceOf[Int]) - - case (LONG, _: DayTimeIntervalType) => (updater, ordinal, value) => - updater.setLong(ordinal, value.asInstanceOf[Long]) - - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - } - - // TODO: move the following method in Decimal object on creating Decimal from BigDecimal? - private def createDecimal(decimal: BigDecimal, precision: Int, scale: Int): Decimal = { - if (precision <= Decimal.MAX_LONG_DIGITS) { - // Constructs a `Decimal` with an unscaled `Long` value if possible. - Decimal(decimal.unscaledValue().longValue(), precision, scale) - } else { - // Otherwise, resorts to an unscaled `BigInteger` instead. - Decimal(decimal, precision, scale) - } - } - - private def getRecordWriter( - avroType: Schema, - catalystType: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - applyFilters: Int => Boolean): (CatalystDataUpdater, GenericRecord) => Boolean = { - - val avroSchemaHelper = new AvroUtils.AvroSchemaHelper( - avroType, catalystType, avroPath, catalystPath, positionalFieldMatch) - - avroSchemaHelper.validateNoExtraCatalystFields(ignoreNullable = true) - // no need to validateNoExtraAvroFields since extra Avro fields are ignored - - val (validFieldIndexes, fieldWriters) = avroSchemaHelper.matchedFields.map { - case AvroMatchedField(catalystField, ordinal, avroField) => - val baseWriter = newWriter(avroField.schema(), catalystField.dataType, - avroPath :+ avroField.name, catalystPath :+ catalystField.name) - val fieldWriter = (fieldUpdater: CatalystDataUpdater, value: Any) => { - if (value == null) { - fieldUpdater.setNullAt(ordinal) - } else { - baseWriter(fieldUpdater, ordinal, value) - } - } - (avroField.pos(), fieldWriter) - }.toArray.unzip - - (fieldUpdater, record) => { - var i = 0 - var skipRow = false - while (i < validFieldIndexes.length && !skipRow) { - fieldWriters(i)(fieldUpdater, record.get(validFieldIndexes(i))) - skipRow = applyFilters(i) - i += 1 - } - skipRow - } - } - - private def createArrayData(elementType: DataType, length: Int): ArrayData = elementType match { - case BooleanType => UnsafeArrayData.fromPrimitiveArray(new Array[Boolean](length)) - case ByteType => UnsafeArrayData.fromPrimitiveArray(new Array[Byte](length)) - case ShortType => UnsafeArrayData.fromPrimitiveArray(new Array[Short](length)) - case IntegerType => UnsafeArrayData.fromPrimitiveArray(new Array[Int](length)) - case LongType => UnsafeArrayData.fromPrimitiveArray(new Array[Long](length)) - case FloatType => UnsafeArrayData.fromPrimitiveArray(new Array[Float](length)) - case DoubleType => UnsafeArrayData.fromPrimitiveArray(new Array[Double](length)) - case _ => new GenericArrayData(new Array[Any](length)) - } - - /** - * A base interface for updating values inside catalyst data structure like `InternalRow` and - * `ArrayData`. - */ - sealed trait CatalystDataUpdater { - def set(ordinal: Int, value: Any): Unit - - def setNullAt(ordinal: Int): Unit = set(ordinal, null) - def setBoolean(ordinal: Int, value: Boolean): Unit = set(ordinal, value) - def setByte(ordinal: Int, value: Byte): Unit = set(ordinal, value) - def setShort(ordinal: Int, value: Short): Unit = set(ordinal, value) - def setInt(ordinal: Int, value: Int): Unit = set(ordinal, value) - def setLong(ordinal: Int, value: Long): Unit = set(ordinal, value) - def setDouble(ordinal: Int, value: Double): Unit = set(ordinal, value) - def setFloat(ordinal: Int, value: Float): Unit = set(ordinal, value) - def setDecimal(ordinal: Int, value: Decimal): Unit = set(ordinal, value) - } - - final class RowUpdater(row: InternalRow) extends CatalystDataUpdater { - override def set(ordinal: Int, value: Any): Unit = row.update(ordinal, value) - - override def setNullAt(ordinal: Int): Unit = row.setNullAt(ordinal) - override def setBoolean(ordinal: Int, value: Boolean): Unit = row.setBoolean(ordinal, value) - override def setByte(ordinal: Int, value: Byte): Unit = row.setByte(ordinal, value) - override def setShort(ordinal: Int, value: Short): Unit = row.setShort(ordinal, value) - override def setInt(ordinal: Int, value: Int): Unit = row.setInt(ordinal, value) - override def setLong(ordinal: Int, value: Long): Unit = row.setLong(ordinal, value) - override def setDouble(ordinal: Int, value: Double): Unit = row.setDouble(ordinal, value) - override def setFloat(ordinal: Int, value: Float): Unit = row.setFloat(ordinal, value) - override def setDecimal(ordinal: Int, value: Decimal): Unit = - row.setDecimal(ordinal, value, value.precision) - } - - final class ArrayDataUpdater(array: ArrayData) extends CatalystDataUpdater { - override def set(ordinal: Int, value: Any): Unit = array.update(ordinal, value) - - override def setNullAt(ordinal: Int): Unit = array.setNullAt(ordinal) - override def setBoolean(ordinal: Int, value: Boolean): Unit = array.setBoolean(ordinal, value) - override def setByte(ordinal: Int, value: Byte): Unit = array.setByte(ordinal, value) - override def setShort(ordinal: Int, value: Short): Unit = array.setShort(ordinal, value) - override def setInt(ordinal: Int, value: Int): Unit = array.setInt(ordinal, value) - override def setLong(ordinal: Int, value: Long): Unit = array.setLong(ordinal, value) - override def setDouble(ordinal: Int, value: Double): Unit = array.setDouble(ordinal, value) - override def setFloat(ordinal: Int, value: Float): Unit = array.setFloat(ordinal, value) - override def setDecimal(ordinal: Int, value: Decimal): Unit = array.update(ordinal, value) - } -} - -object AvroDeserializer { - - // NOTE: Following methods have been renamed in Spark 3.2.1 [1] making [[AvroDeserializer]] implementation - // (which relies on it) be only compatible with the exact same version of [[DataSourceUtils]]. - // To make sure this implementation is compatible w/ all Spark versions w/in Spark 3.2.x branch, - // we're preemptively cloned those methods to make sure Hudi is compatible w/ Spark 3.2.0 as well as - // w/ Spark >= 3.2.1 - // - // [1] https://github.com/apache/spark/pull/34978 - - // Specification of rebase operation including `mode` and the time zone in which it is performed - case class RebaseSpec(mode: LegacyBehaviorPolicy.Value, originTimeZone: Option[String] = None) { - // Use the default JVM time zone for backward compatibility - def timeZone: String = originTimeZone.getOrElse(TimeZone.getDefault.getID) - } - - def createDateRebaseFuncInRead(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Int => Int = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => days: Int => - if (days < RebaseDateTime.lastSwitchJulianDay) { - throw DataSourceUtils.newRebaseExceptionInRead(format) - } - days - case LegacyBehaviorPolicy.LEGACY => RebaseDateTime.rebaseJulianToGregorianDays - case LegacyBehaviorPolicy.CORRECTED => identity[Int] - } - - def createTimestampRebaseFuncInRead(rebaseSpec: RebaseSpec, - format: String): Long => Long = rebaseSpec.mode match { - case LegacyBehaviorPolicy.EXCEPTION => micros: Long => - if (micros < RebaseDateTime.lastSwitchJulianTs) { - throw DataSourceUtils.newRebaseExceptionInRead(format) - } - micros - case LegacyBehaviorPolicy.LEGACY => micros: Long => - RebaseDateTime.rebaseJulianToGregorianMicros(TimeZone.getTimeZone(rebaseSpec.timeZone), micros) - case LegacyBehaviorPolicy.CORRECTED => identity[Long] - } -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala deleted file mode 100644 index a1241b72e58bc..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala +++ /dev/null @@ -1,490 +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.spark.sql.avro - -import org.apache.hudi.common.schema.HoodieSchema -import org.apache.hudi.common.schema.HoodieSchema.VectorLogicalType - -import org.apache.avro.{LogicalTypes, Schema} -import org.apache.avro.Conversions.DecimalConversion -import org.apache.avro.LogicalTypes.{LocalTimestampMicros, LocalTimestampMillis, TimestampMicros, TimestampMillis} -import org.apache.avro.Schema.Type -import org.apache.avro.Schema.Type._ -import org.apache.avro.generic.GenericData.{EnumSymbol, Fixed, Record} -import org.apache.avro.util.Utf8 -import org.apache.spark.internal.Logging -import org.apache.spark.sql.avro.AvroSerializer.{createDateRebaseFuncInWrite, createTimestampRebaseFuncInWrite} -import org.apache.spark.sql.avro.AvroUtils.{toFieldStr, AvroMatchedField} -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{SpecializedGetters, SpecificInternalRow} -import org.apache.spark.sql.catalyst.util.{DateTimeUtils, RebaseDateTime} -import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy -import org.apache.spark.sql.types._ - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.TimeZone - -import scala.collection.JavaConverters._ - -/** - * A serializer to serialize data in catalyst format to data in avro format. - * - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * NOTE: THIS IMPLEMENTATION HAS BEEN MODIFIED FROM ITS ORIGINAL VERSION WITH THE MODIFICATION - * BEING EXPLICITLY ANNOTATED INLINE. PLEASE MAKE SURE TO UNDERSTAND PROPERLY ALL THE - * MODIFICATIONS. - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] class AvroSerializer(rootCatalystType: DataType, - rootAvroType: Schema, - nullable: Boolean, - positionalFieldMatch: Boolean, - datetimeRebaseMode: LegacyBehaviorPolicy.Value) extends Logging { - - def this(rootCatalystType: DataType, rootAvroType: Schema, nullable: Boolean) = { - this(rootCatalystType, rootAvroType, nullable, positionalFieldMatch = false, - LegacyBehaviorPolicy.withName(SQLConf.get.getConf(SQLConf.AVRO_REBASE_MODE_IN_WRITE, - LegacyBehaviorPolicy.CORRECTED.toString))) - } - - def serialize(catalystData: Any): Any = { - converter.apply(catalystData) - } - - private val dateRebaseFunc = createDateRebaseFuncInWrite( - datetimeRebaseMode, "Avro") - - private val timestampRebaseFunc = createTimestampRebaseFuncInWrite( - datetimeRebaseMode, "Avro") - - private val converter: Any => Any = { - val actualAvroType = resolveNullableType(rootAvroType, nullable) - val baseConverter = try { - rootCatalystType match { - case st: StructType => - newStructConverter(st, actualAvroType, Nil, Nil).asInstanceOf[Any => Any] - case _ => - val tmpRow = new SpecificInternalRow(Seq(rootCatalystType)) - val converter = newConverter(rootCatalystType, actualAvroType, Nil, Nil) - (data: Any) => - tmpRow.update(0, data) - converter.apply(tmpRow, 0) - } - } catch { - case ise: IncompatibleSchemaException => throw new IncompatibleSchemaException( - s"Cannot convert SQL type ${rootCatalystType.sql} to Avro type $rootAvroType.", ise) - } - if (nullable) { - (data: Any) => - if (data == null) { - null - } else { - baseConverter.apply(data) - } - } else { - baseConverter - } - } - - private type Converter = (SpecializedGetters, Int) => Any - - private lazy val decimalConversions = new DecimalConversion() - - private def newConverter(catalystType: DataType, - avroType: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): Converter = { - val errorPrefix = s"Cannot convert SQL ${toFieldStr(catalystPath)} " + - s"to Avro ${toFieldStr(avroPath)} because " - (catalystType, avroType.getType) match { - case (NullType, NULL) => - (getter, ordinal) => null - case (BooleanType, BOOLEAN) => - (getter, ordinal) => getter.getBoolean(ordinal) - case (ByteType, INT) => - (getter, ordinal) => getter.getByte(ordinal).toInt - case (ShortType, INT) => - (getter, ordinal) => getter.getShort(ordinal).toInt - case (IntegerType, INT) => - (getter, ordinal) => getter.getInt(ordinal) - case (LongType, LONG) => - (getter, ordinal) => getter.getLong(ordinal) - case (FloatType, FLOAT) => - (getter, ordinal) => getter.getFloat(ordinal) - case (DoubleType, DOUBLE) => - (getter, ordinal) => getter.getDouble(ordinal) - case (d: DecimalType, FIXED) - if avroType.getLogicalType == LogicalTypes.decimal(d.precision, d.scale) => - (getter, ordinal) => - val decimal = getter.getDecimal(ordinal, d.precision, d.scale) - decimalConversions.toFixed(decimal.toJavaBigDecimal, avroType, - LogicalTypes.decimal(d.precision, d.scale)) - - case (d: DecimalType, BYTES) - if avroType.getLogicalType == LogicalTypes.decimal(d.precision, d.scale) => - (getter, ordinal) => - val decimal = getter.getDecimal(ordinal, d.precision, d.scale) - decimalConversions.toBytes(decimal.toJavaBigDecimal, avroType, - LogicalTypes.decimal(d.precision, d.scale)) - - // Handle VECTOR logical type (FLOAT, DOUBLE, INT8) - case (ArrayType(elementType, false), FIXED) => avroType.getLogicalType match { - case vectorLogicalType: VectorLogicalType => - val dimension = vectorLogicalType.getDimension - val vecElementType = HoodieSchema.Vector.VectorElementType.fromString(vectorLogicalType.getElementType) - val bufferSize = Math.multiplyExact(dimension, vecElementType.getElementSize) - (getter, ordinal) => { - val arrayData = getter.getArray(ordinal) - if (arrayData.numElements() != dimension) { - throw new IncompatibleSchemaException( - s"VECTOR dimension mismatch at ${toFieldStr(catalystPath)}: " + - s"expected=$dimension, actual=${arrayData.numElements()}") - } - elementType match { - case FloatType => - val buffer = ByteBuffer.allocate(bufferSize).order(VectorLogicalType.VECTOR_BYTE_ORDER) - var i = 0; while (i < dimension) { buffer.putFloat(arrayData.getFloat(i)); i += 1 } - new Fixed(avroType, buffer.array()) - case DoubleType => - val buffer = ByteBuffer.allocate(bufferSize).order(VectorLogicalType.VECTOR_BYTE_ORDER) - var i = 0; while (i < dimension) { buffer.putDouble(arrayData.getDouble(i)); i += 1 } - new Fixed(avroType, buffer.array()) - case ByteType => - val bytes = new Array[Byte](dimension) - var i = 0; while (i < dimension) { bytes(i) = arrayData.getByte(i); i += 1 } - new Fixed(avroType, bytes) - case _ => throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - } - case _ => throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - - case (StringType, ENUM) => - val enumSymbols: Set[String] = avroType.getEnumSymbols.asScala.toSet - (getter, ordinal) => - val data = getter.getUTF8String(ordinal).toString - if (!enumSymbols.contains(data)) { - throw new IncompatibleSchemaException(errorPrefix + - s""""$data" cannot be written since it's not defined in enum """ + - enumSymbols.mkString("\"", "\", \"", "\"")) - } - new EnumSymbol(avroType, data) - - case (StringType, STRING) => - (getter, ordinal) => new Utf8(getter.getUTF8String(ordinal).getBytes) - - case (BinaryType, FIXED) => - val size = avroType.getFixedSize - (getter, ordinal) => - val data: Array[Byte] = getter.getBinary(ordinal) - if (data.length != size) { - def len2str(len: Int): String = s"$len ${if (len > 1) "bytes" else "byte"}" - - throw new IncompatibleSchemaException(errorPrefix + len2str(data.length) + - " of binary data cannot be written into FIXED type with size of " + len2str(size)) - } - new Fixed(avroType, data) - - case (BinaryType, BYTES) => - (getter, ordinal) => ByteBuffer.wrap(getter.getBinary(ordinal)) - - case (DateType, INT) => - (getter, ordinal) => dateRebaseFunc(getter.getInt(ordinal)) - - case (TimestampType, LONG) => avroType.getLogicalType match { - // For backward compatibility, if the Avro type is Long and it is not logical type - // (the `null` case), output the timestamp value as with millisecond precision. - case null | _: TimestampMillis => (getter, ordinal) => - DateTimeUtils.microsToMillis(timestampRebaseFunc(getter.getLong(ordinal))) - case _: TimestampMicros => (getter, ordinal) => - timestampRebaseFunc(getter.getLong(ordinal)) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"SQL type ${TimestampType.sql} cannot be converted to Avro logical type $other") - } - - case (TimestampNTZType, LONG) => avroType.getLogicalType match { - // To keep consistent with TimestampType, if the Avro type is Long and it is not - // logical type (the `null` case), output the TimestampNTZ as long value - // in millisecond precision. - case null | _: LocalTimestampMillis => (getter, ordinal) => - DateTimeUtils.microsToMillis(getter.getLong(ordinal)) - case _: LocalTimestampMicros => (getter, ordinal) => - getter.getLong(ordinal) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"SQL type ${TimestampNTZType.sql} cannot be converted to Avro logical type $other") - } - - case (ArrayType(et, containsNull), ARRAY) => - val elementConverter = newConverter( - et, resolveNullableType(avroType.getElementType, containsNull), - catalystPath :+ "element", avroPath :+ "element") - (getter, ordinal) => { - val arrayData = getter.getArray(ordinal) - val len = arrayData.numElements() - val result = new Array[Any](len) - var i = 0 - while (i < len) { - if (containsNull && arrayData.isNullAt(i)) { - result(i) = null - } else { - result(i) = elementConverter(arrayData, i) - } - i += 1 - } - // avro writer is expecting a Java Collection, so we convert it into - // `ArrayList` backed by the specified array without data copying. - java.util.Arrays.asList(result: _*) - } - - case (st: StructType, RECORD) => - val structConverter = newStructConverter(st, avroType, catalystPath, avroPath) - val numFields = st.length - (getter, ordinal) => structConverter(getter.getStruct(ordinal, numFields)) - - //////////////////////////////////////////////////////////////////////////////////////////// - // Following section is amended to the original (Spark's) implementation - // >>> BEGINS - //////////////////////////////////////////////////////////////////////////////////////////// - - case (st: StructType, UNION) => - val unionConverter = newUnionConverter(st, avroType, catalystPath, avroPath) - val numFields = st.length - (getter, ordinal) => unionConverter(getter.getStruct(ordinal, numFields)) - - //////////////////////////////////////////////////////////////////////////////////////////// - // <<< ENDS - //////////////////////////////////////////////////////////////////////////////////////////// - - case (MapType(kt, vt, valueContainsNull), MAP) if kt == StringType => - val valueConverter = newConverter( - vt, resolveNullableType(avroType.getValueType, valueContainsNull), - catalystPath :+ "value", avroPath :+ "value") - (getter, ordinal) => - val mapData = getter.getMap(ordinal) - val len = mapData.numElements() - val result = new java.util.HashMap[String, Any](len) - val keyArray = mapData.keyArray() - val valueArray = mapData.valueArray() - var i = 0 - while (i < len) { - val key = keyArray.getUTF8String(i).toString - if (valueContainsNull && valueArray.isNullAt(i)) { - result.put(key, null) - } else { - result.put(key, valueConverter(valueArray, i)) - } - i += 1 - } - result - - case (_: YearMonthIntervalType, INT) => - (getter, ordinal) => getter.getInt(ordinal) - - case (_: DayTimeIntervalType, LONG) => - (getter, ordinal) => getter.getLong(ordinal) - - case _ => - throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - } - - private def newStructConverter(catalystStruct: StructType, - avroStruct: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): InternalRow => Record = { - - val avroSchemaHelper = new AvroUtils.AvroSchemaHelper( - avroStruct, catalystStruct, avroPath, catalystPath, positionalFieldMatch) - - avroSchemaHelper.validateNoExtraCatalystFields(ignoreNullable = false) - avroSchemaHelper.validateNoExtraRequiredAvroFields() - - val (avroIndices, fieldConverters) = avroSchemaHelper.matchedFields.map { - case AvroMatchedField(catalystField, _, avroField) => - val converter = newConverter(catalystField.dataType, - resolveNullableType(avroField.schema(), catalystField.nullable), - catalystPath :+ catalystField.name, avroPath :+ avroField.name) - (avroField.pos(), converter) - }.toArray.unzip - - val numFields = catalystStruct.length - row: InternalRow => - val result = new Record(avroStruct) - var i = 0 - while (i < numFields) { - if (row.isNullAt(i)) { - result.put(avroIndices(i), null) - } else { - result.put(avroIndices(i), fieldConverters(i).apply(row, i)) - } - i += 1 - } - result - } - - //////////////////////////////////////////////////////////////////////////////////////////// - // Following section is amended to the original (Spark's) implementation - // >>> BEGINS - //////////////////////////////////////////////////////////////////////////////////////////// - - private def newUnionConverter(catalystStruct: StructType, - avroUnion: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): InternalRow => Any = { - if (avroUnion.getType != UNION || !canMapUnion(catalystStruct, avroUnion)) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst type $catalystStruct to " + - s"Avro type $avroUnion.") - } - val nullable = avroUnion.getTypes.size() > 0 && avroUnion.getTypes.get(0).getType == Type.NULL - val avroInnerTypes = if (nullable) { - avroUnion.getTypes.asScala.tail - } else { - avroUnion.getTypes.asScala - } - val fieldConverters = catalystStruct.zip(avroInnerTypes).map { - case (f1, f2) => newConverter(f1.dataType, f2, catalystPath, avroPath) - } - val numFields = catalystStruct.length - (row: InternalRow) => - var i = 0 - var result: Any = null - while (i < numFields) { - if (!row.isNullAt(i)) { - if (result != null) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst record $catalystStruct to " + - s"Avro union $avroUnion. Record has more than one optional values set") - } - result = fieldConverters(i).apply(row, i) - } - i += 1 - } - if (!nullable && result == null) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst record $catalystStruct to " + - s"Avro union $avroUnion. Record has no values set, while should have exactly one") - } - result - } - - private def canMapUnion(catalystStruct: StructType, avroStruct: Schema): Boolean = { - (avroStruct.getTypes.size() > 0 && - avroStruct.getTypes.get(0).getType == Type.NULL && - avroStruct.getTypes.size() - 1 == catalystStruct.length) || avroStruct.getTypes.size() == catalystStruct.length - } - - //////////////////////////////////////////////////////////////////////////////////////////// - // <<< ENDS - //////////////////////////////////////////////////////////////////////////////////////////// - - - /** - * Resolve a possibly nullable Avro Type. - * - * An Avro type is nullable when it is a [[UNION]] of two types: one null type and another - * non-null type. This method will check the nullability of the input Avro type and return the - * non-null type within when it is nullable. Otherwise it will return the input Avro type - * unchanged. It will throw an [[UnsupportedAvroTypeException]] when the input Avro type is an - * unsupported nullable type. - * - * It will also log a warning message if the nullability for Avro and catalyst types are - * different. - */ - private def resolveNullableType(avroType: Schema, nullable: Boolean): Schema = { - val (avroNullable, resolvedAvroType) = resolveAvroType(avroType) - warnNullabilityDifference(avroNullable, nullable) - resolvedAvroType - } - - /** - * Check the nullability of the input Avro type and resolve it when it is nullable. The first - * return value is a [[Boolean]] indicating if the input Avro type is nullable. The second - * return value is the possibly resolved type. - */ - private def resolveAvroType(avroType: Schema): (Boolean, Schema) = { - if (avroType.getType == Type.UNION) { - val fields = avroType.getTypes.asScala - val actualType = fields.filter(_.getType != Type.NULL) - if (fields.length == 2 && actualType.length == 1) { - (true, actualType.head) - } else { - // This is just a normal union, not used to designate nullability - (false, avroType) - } - } else { - (false, avroType) - } - } - - /** - * log a warning message if the nullability for Avro and catalyst types are different. - */ - private def warnNullabilityDifference(avroNullable: Boolean, catalystNullable: Boolean): Unit = { - if (avroNullable && !catalystNullable) { - logWarning("Writing Avro files with nullable Avro schema and non-nullable catalyst schema.") - } - if (!avroNullable && catalystNullable) { - logWarning("Writing Avro files with non-nullable Avro schema and nullable catalyst " + - "schema will throw runtime exception if there is a record with null value.") - } - } -} - -object AvroSerializer { - - // NOTE: Following methods have been renamed in Spark 3.2.1 [1] making [[AvroSerializer]] implementation - // (which relies on it) be only compatible with the exact same version of [[DataSourceUtils]]. - // To make sure this implementation is compatible w/ all Spark versions w/in Spark 3.2.x branch, - // we're preemptively cloned those methods to make sure Hudi is compatible w/ Spark 3.2.0 as well as - // w/ Spark >= 3.2.1 - // - // [1] https://github.com/apache/spark/pull/34978 - - def createDateRebaseFuncInWrite(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Int => Int = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => days: Int => - if (days < RebaseDateTime.lastSwitchGregorianDay) { - throw DataSourceUtils.newRebaseExceptionInWrite(format) - } - days - case LegacyBehaviorPolicy.LEGACY => RebaseDateTime.rebaseGregorianToJulianDays - case LegacyBehaviorPolicy.CORRECTED => identity[Int] - } - - def createTimestampRebaseFuncInWrite(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Long => Long = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => micros: Long => - if (micros < RebaseDateTime.lastSwitchGregorianTs) { - throw DataSourceUtils.newRebaseExceptionInWrite(format) - } - micros - case LegacyBehaviorPolicy.LEGACY => - val timeZone = SQLConf.get.sessionLocalTimeZone - RebaseDateTime.rebaseGregorianToJulianMicros(TimeZone.getTimeZone(timeZone), _) - case LegacyBehaviorPolicy.CORRECTED => identity[Long] - } - -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala deleted file mode 100644 index 8aae6b442f8a1..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala +++ /dev/null @@ -1,227 +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.spark.sql.avro - -import org.apache.avro.Schema -import org.apache.avro.file. FileReader -import org.apache.avro.generic.GenericRecord -import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types._ - -import java.util.Locale - -import scala.collection.JavaConverters._ - -/** - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] object AvroUtils extends Logging { - - def supportsDataType(dataType: DataType): Boolean = dataType match { - case _: AtomicType => true - - case st: StructType => st.forall { f => supportsDataType(f.dataType) } - - case ArrayType(elementType, _) => supportsDataType(elementType) - - case MapType(keyType, valueType, _) => - supportsDataType(keyType) && supportsDataType(valueType) - - case udt: UserDefinedType[_] => supportsDataType(udt.sqlType) - - case _: NullType => true - - case _ => false - } - - // The trait provides iterator-like interface for reading records from an Avro file, - // deserializing and returning them as internal rows. - trait RowReader { - protected val fileReader: FileReader[GenericRecord] - protected val deserializer: AvroDeserializer - protected val stopPosition: Long - - private[this] var completed = false - private[this] var currentRow: Option[InternalRow] = None - - def hasNextRow: Boolean = { - while (!completed && currentRow.isEmpty) { - val r = fileReader.hasNext && !fileReader.pastSync(stopPosition) - if (!r) { - fileReader.close() - completed = true - currentRow = None - } else { - val record = fileReader.next() - // the row must be deserialized in hasNextRow, because AvroDeserializer#deserialize - // potentially filters rows - currentRow = deserializer.deserialize(record).asInstanceOf[Option[InternalRow]] - } - } - currentRow.isDefined - } - - def nextRow: InternalRow = { - if (currentRow.isEmpty) { - hasNextRow - } - val returnRow = currentRow - currentRow = None // free up hasNextRow to consume more Avro records, if not exhausted - returnRow.getOrElse { - throw new NoSuchElementException("next on empty iterator") - } - } - } - - /** Wrapper for a pair of matched fields, one Catalyst and one corresponding Avro field. */ - private[sql] case class AvroMatchedField( - catalystField: StructField, - catalystPosition: Int, - avroField: Schema.Field) - - /** - * Helper class to perform field lookup/matching on Avro schemas. - * - * This will match `avroSchema` against `catalystSchema`, attempting to find a matching field in - * the Avro schema for each field in the Catalyst schema and vice-versa, respecting settings for - * case sensitivity. The match results can be accessed using the getter methods. - * - * @param avroSchema The schema in which to search for fields. Must be of type RECORD. - * @param catalystSchema The Catalyst schema to use for matching. - * @param avroPath The seq of parent field names leading to `avroSchema`. - * @param catalystPath The seq of parent field names leading to `catalystSchema`. - * @param positionalFieldMatch If true, perform field matching in a positional fashion - * (structural comparison between schemas, ignoring names); - * otherwise, perform field matching using field names. - */ - class AvroSchemaHelper( - avroSchema: Schema, - catalystSchema: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - positionalFieldMatch: Boolean) { - if (avroSchema.getType != Schema.Type.RECORD) { - throw new IncompatibleSchemaException( - s"Attempting to treat ${avroSchema.getName} as a RECORD, but it was: ${avroSchema.getType}") - } - - private[this] val avroFieldArray = avroSchema.getFields.asScala.toArray - private[this] val fieldMap = avroSchema.getFields.asScala - .groupBy(_.name.toLowerCase(Locale.ROOT)) - .mapValues(_.toSeq) // toSeq needed for scala 2.13 - - /** The fields which have matching equivalents in both Avro and Catalyst schemas. */ - val matchedFields: Seq[AvroMatchedField] = catalystSchema.zipWithIndex.flatMap { - case (sqlField, sqlPos) => - getAvroField(sqlField.name, sqlPos).map(AvroMatchedField(sqlField, sqlPos, _)) - } - - /** - * Validate that there are no Catalyst fields which don't have a matching Avro field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. If `ignoreNullable` is false, - * consider nullable Catalyst fields to be eligible to be an extra field; otherwise, - * ignore nullable Catalyst fields when checking for extras. - */ - def validateNoExtraCatalystFields(ignoreNullable: Boolean): Unit = - catalystSchema.zipWithIndex.foreach { case (sqlField, sqlPos) => - if (getAvroField(sqlField.name, sqlPos).isEmpty && - (!ignoreNullable || !sqlField.nullable)) { - if (positionalFieldMatch) { - throw new IncompatibleSchemaException("Cannot find field at position " + - s"$sqlPos of ${toFieldStr(avroPath)} from Avro schema (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Cannot find ${toFieldStr(catalystPath :+ sqlField.name)} in Avro schema") - } - } - } - - /** - * Validate that there are no Avro fields which don't have a matching Catalyst field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. Only required (non-nullable) - * fields are checked; nullable fields are ignored. - */ - def validateNoExtraRequiredAvroFields(): Unit = { - val extraFields = avroFieldArray.toSet -- matchedFields.map(_.avroField) - extraFields.filterNot(isNullable).foreach { extraField => - if (positionalFieldMatch) { - throw new IncompatibleSchemaException(s"Found field '${extraField.name()}' at position " + - s"${extraField.pos()} of ${toFieldStr(avroPath)} from Avro schema but there is no " + - s"match in the SQL schema at ${toFieldStr(catalystPath)} (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Found ${toFieldStr(avroPath :+ extraField.name())} in Avro schema but there is no " + - "match in the SQL schema") - } - } - } - - /** - * Extract a single field from the contained avro schema which has the desired field name, - * performing the matching with proper case sensitivity according to SQLConf.resolver. - * - * @param name The name of the field to search for. - * @return `Some(match)` if a matching Avro field is found, otherwise `None`. - */ - private[avro] def getFieldByName(name: String): Option[Schema.Field] = { - - // get candidates, ignoring case of field name - val candidates = fieldMap.getOrElse(name.toLowerCase(Locale.ROOT), Seq.empty) - - // search candidates, taking into account case sensitivity settings - candidates.filter(f => SQLConf.get.resolver(f.name(), name)) match { - case Seq(avroField) => Some(avroField) - case Seq() => None - case matches => throw new IncompatibleSchemaException(s"Searching for '$name' in Avro " + - s"schema at ${toFieldStr(avroPath)} gave ${matches.size} matches. Candidates: " + - matches.map(_.name()).mkString("[", ", ", "]") - ) - } - } - - /** Get the Avro field corresponding to the provided Catalyst field name/position, if any. */ - def getAvroField(fieldName: String, catalystPos: Int): Option[Schema.Field] = { - if (positionalFieldMatch) { - avroFieldArray.lift(catalystPos) - } else { - getFieldByName(fieldName) - } - } - } - - /** - * Convert a sequence of hierarchical field names (like `Seq(foo, bar)`) into a human-readable - * string representing the field, like "field 'foo.bar'". If `names` is empty, the string - * "top-level record" is returned. - */ - private[avro] def toFieldStr(names: Seq[String]): String = names match { - case Seq() => "top-level record" - case n => s"field '${n.mkString(".")}'" - } - - /** Return true iff `avroField` is nullable, i.e. `UNION` type and has `NULL` as an option. */ - private[avro] def isNullable(avroField: Schema.Field): Boolean = - avroField.schema().getType == Schema.Type.UNION && - avroField.schema().getTypes.asScala.exists(_.getType == Schema.Type.NULL) -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark34NestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark34NestedSchemaPruning.scala deleted file mode 100644 index 6f3e6d12e23e8..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark34NestedSchemaPruning.scala +++ /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.spark.sql.execution.datasources - -import org.apache.hudi.HoodieBaseRelation - -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.catalyst.planning.PhysicalOperation -import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan -import org.apache.spark.sql.sources.BaseRelation -import org.apache.spark.sql.types.StructType - -class Spark34NestedSchemaPruning extends BaseHoodieNestedSchemaPruning { - - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], - requiredSchema: StructType): Seq[AttributeReference] = { - // We need to replace the expression ids of the pruned relation output attributes - // with the expression ids of the original relation output attributes so that - // references to the original relation's output are not broken - val outputIdMap = output.map(att => (att.name, att.exprId)).toMap - requiredSchema - .toAttributes - .map { - case att if outputIdMap.contains(att.name) => - att.withExprId(outputIdMap(att.name)) - case att => att - } - } - - override protected def apply0(plan: LogicalPlan): LogicalPlan = - plan transformDown { - case op @ PhysicalOperation(projects, filters, - // NOTE: This is modified to accommodate for Hudi's custom relations, given that original - // [[NestedSchemaPruning]] rule is tightly coupled w/ [[HadoopFsRelation]] - // TODO generalize to any file-based relation - l @ LogicalRelation(relation: HoodieBaseRelation, _, _, _)) - if relation.canPruneRelationSchema => - - prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, - prunedDataSchema => { - val prunedRelation = - relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) - buildPrunedRelation(l, prunedRelation) - }).getOrElse(op) - } -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark34OrcReader.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark34OrcReader.scala deleted file mode 100644 index a1463aa01aaf3..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark34OrcReader.scala +++ /dev/null @@ -1,91 +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.spark.sql.execution.datasources.orc - -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.Path -import org.apache.spark.memory.MemoryMode -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile} -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType - -class Spark34OrcReader(enableVectorizedReader: Boolean, - memoryMode: MemoryMode, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean, - capacity: Int) extends SparkOrcReaderBase(enableVectorizedReader, dataSchema, orcFilterPushDown, isCaseSensitive) { - - override def partitionedFileToPath(file: PartitionedFile): Path = { - file.toPath - } - - override def buildReader(): OrcColumnarBatchReader = { - new OrcColumnarBatchReader(capacity, memoryMode) - } - - override def structTypeToAttributes(schema: StructType): Seq[Attribute] = { - schema.toAttributes - } -} - -object Spark34OrcReader { - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @return ORC file reader - */ - def build(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): Spark34OrcReader = { - //set hadoopconf - hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) - hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) - - val memoryMode = if (sqlConf.offHeapColumnVectorEnabled) { - MemoryMode.OFF_HEAP - } else { - MemoryMode.ON_HEAP - } - - val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && - options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, - throw new IllegalArgumentException( - "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + - "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) - .equals("true") - - new Spark34OrcReader( - enableVectorizedReader = enableVectorizedReader && vectorized, - memoryMode = memoryMode, - isCaseSensitive = sqlConf.caseSensitiveAnalysis, - capacity = sqlConf.orcVectorizedReaderBatchSize, - orcFilterPushDown = sqlConf.orcFilterPushDown, - dataSchema = dataSchema) - } -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34DataSourceUtils.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34DataSourceUtils.scala deleted file mode 100644 index d404bc8c24b53..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34DataSourceUtils.scala +++ /dev/null @@ -1,77 +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.spark.sql.execution.datasources.parquet - -import org.apache.spark.sql.SPARK_VERSION_METADATA_KEY -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.internal.SQLConf.LegacyBehaviorPolicy -import org.apache.spark.util.Utils - -object Spark34DataSourceUtils { - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def int96RebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 3.0 and earlier follow the legacy hybrid calendar and we need to - // rebase the INT96 timestamp values. - // Files written by Spark 3.1 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.1.0" || lookupFileMeta("org.apache.spark.legacyINT96") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def datetimeRebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 2.4 and earlier follow the legacy hybrid calendar and we need to - // rebase the datetime values. - // Files written by Spark 3.0 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.0.0" || lookupFileMeta("org.apache.spark.legacyDateTime") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - -} diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34LegacyHoodieParquetFileFormat.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34LegacyHoodieParquetFileFormat.scala index adca01edf06bf..abcb6d5c8b90a 100644 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34LegacyHoodieParquetFileFormat.scala +++ b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34LegacyHoodieParquetFileFormat.scala @@ -17,54 +17,21 @@ package org.apache.spark.sql.execution.datasources.parquet -import org.apache.hudi.client.utils.SparkInternalSchemaConverter -import org.apache.hudi.common.fs.FSUtils -import org.apache.hudi.common.table.timeline.TimelineLayout -import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion -import org.apache.hudi.common.util.InternalSchemaCache -import org.apache.hudi.common.util.StringUtils.isNullOrEmpty -import org.apache.hudi.common.util.collection.Pair -import org.apache.hudi.hadoop.fs.HadoopFSUtils -import org.apache.hudi.internal.schema.InternalSchema -import org.apache.hudi.internal.schema.action.InternalSchemaMerger -import org.apache.hudi.internal.schema.utils.{InternalSchemaUtils, SerDeHelper} -import org.apache.hudi.storage.HoodieStorageUtils - import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.mapred.FileSplit -import org.apache.hadoop.mapreduce.{JobID, TaskAttemptID, TaskID, TaskType} -import org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl -import org.apache.parquet.filter2.compat.FilterCompat -import org.apache.parquet.filter2.predicate.FilterApi -import org.apache.parquet.format.converter.ParquetMetadataConverter.SKIP_ROW_GROUPS -import org.apache.parquet.hadoop.{ParquetInputFormat, ParquetRecordReader} -import org.apache.spark.TaskContext +import org.apache.hadoop.fs.Path import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{Cast, JoinedRow} -import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection -import org.apache.spark.sql.catalyst.util.DateTimeUtils +import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.execution.WholeStageCodegenExec -import org.apache.spark.sql.execution.datasources.{DataSourceUtils, PartitionedFile, RecordReaderIterator} -import org.apache.spark.sql.execution.datasources.parquet.Spark34LegacyHoodieParquetFileFormat._ +import org.apache.spark.sql.execution.datasources.PartitionedFile import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.sources._ -import org.apache.spark.sql.types.{AtomicType, DataType, StructField, StructType} -import org.apache.spark.util.SerializableConfiguration - -import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` +import org.apache.spark.sql.types.StructType /** - * This class is an extension of [[ParquetFileFormat]] overriding Spark-specific behavior - * that's not possible to customize in any other way - * - * NOTE: This is a version of [[AvroDeserializer]] impl from Spark 3.2.1 w/ w/ the following changes applied to it: - *
      - *
    1. Avoiding appending partition values to the rows read from the data file
    2. - *
    3. Schema on-read
    4. - *
    + * Spark 3.4 concrete implementation of [[Spark3LegacyHoodieParquetFileFormat]]. It only overrides + * the version-specific hooks; the shared reader logic lives in the base class. */ -class Spark34LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValues: Boolean) extends ParquetFileFormat { +class Spark34LegacyHoodieParquetFileFormat(appendPartitionValues: Boolean) + extends Spark3LegacyHoodieParquetFileFormat(appendPartitionValues) { def supportsColumnar(sparkSession: SparkSession, schema: StructType): Boolean = { val conf = sparkSession.sessionState.conf @@ -75,39 +42,25 @@ class Spark34LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValu supportBatch(sparkSession, schema) } - override def buildReaderWithPartitionValues(sparkSession: SparkSession, - dataSchema: StructType, - partitionSchema: StructType, - requiredSchema: StructType, - filters: Seq[Filter], - options: Map[String, String], - hadoopConf: Configuration): PartitionedFile => Iterator[InternalRow] = { - hadoopConf.set(ParquetInputFormat.READ_SUPPORT_CLASS, classOf[ParquetReadSupport].getName) - hadoopConf.set( - ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, - requiredSchema.json) - hadoopConf.set( - ParquetWriteSupport.SPARK_ROW_SCHEMA, - requiredSchema.json) - hadoopConf.set( - SQLConf.SESSION_LOCAL_TIMEZONE.key, - sparkSession.sessionState.conf.sessionLocalTimeZone) - hadoopConf.setBoolean( - SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, - sparkSession.sessionState.conf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean( - SQLConf.CASE_SENSITIVE.key, - sparkSession.sessionState.conf.caseSensitiveAnalysis) + override protected def toAttributes(structType: StructType): Seq[Attribute] = + structType.toAttributes - ParquetWriteSupport.setSchema(requiredSchema, hadoopConf) + override protected def getFilePath(file: PartitionedFile): Path = + file.filePath.toPath - // Sets flags for `ParquetToSparkSchemaConverter` - hadoopConf.setBoolean( - SQLConf.PARQUET_BINARY_AS_STRING.key, - sparkSession.sessionState.conf.isParquetBinaryAsString) - hadoopConf.setBoolean( - SQLConf.PARQUET_INT96_AS_TIMESTAMP.key, - sparkSession.sessionState.conf.isParquetINT96AsTimestamp) + override protected def isVectorizedReaderEnabled(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + supportBatch(sparkSession, resultSchema) + + override protected def getPushDownStringPredicate(sqlConf: SQLConf): Boolean = + sqlConf.parquetFilterPushDownStringPredicate + + override protected def getReturningBatch(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + sparkSession.sessionState.conf.parquetVectorizedReaderEnabled && + supportsColumnar(sparkSession, resultSchema).toString.equals("true") + + override protected def setParquetTimeConfs(hadoopConf: Configuration, sparkSession: SparkSession): Unit = { // Using string value of this conf to preserve compatibility across spark versions. hadoopConf.setBoolean( SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key, @@ -117,346 +70,5 @@ class Spark34LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValu ) hadoopConf.setBoolean(SQLConf.PARQUET_INFER_TIMESTAMP_NTZ_ENABLED.key, sparkSession.sessionState.conf.parquetInferTimestampNTZEnabled) hadoopConf.setBoolean(SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key, sparkSession.sessionState.conf.legacyParquetNanosAsLong) - val internalSchemaStr = hadoopConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // For Spark DataSource v1, there's no Physical Plan projection/schema pruning w/in Spark itself, - // therefore it's safe to do schema projection here - if (!isNullOrEmpty(internalSchemaStr)) { - val prunedInternalSchemaStr = - pruneInternalSchema(internalSchemaStr, requiredSchema) - hadoopConf.set(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA, prunedInternalSchemaStr) - } - - val broadcastedHadoopConf = - sparkSession.sparkContext.broadcast(new SerializableConfiguration(hadoopConf)) - - // TODO: if you move this into the closure it reverts to the default values. - // If true, enable using the custom RecordReader for parquet. This only works for - // a subset of the types (no complex types). - val resultSchema = StructType(partitionSchema.fields ++ requiredSchema.fields) - val sqlConf = sparkSession.sessionState.conf - val enableOffHeapColumnVector = sqlConf.offHeapColumnVectorEnabled - val enableVectorizedReader: Boolean = supportBatch(sparkSession, resultSchema) - val enableRecordFilter: Boolean = sqlConf.parquetRecordFilterEnabled - val timestampConversion: Boolean = sqlConf.isParquetINT96TimestampConversion - val capacity = sqlConf.parquetVectorizedReaderBatchSize - val enableParquetFilterPushDown: Boolean = sqlConf.parquetFilterPushDown - val pushDownDate = sqlConf.parquetFilterPushDownDate - val pushDownTimestamp = sqlConf.parquetFilterPushDownTimestamp - val pushDownDecimal = sqlConf.parquetFilterPushDownDecimal - val pushDownStringStartWith = sqlConf.parquetFilterPushDownStringPredicate - val pushDownInFilterThreshold = sqlConf.parquetFilterPushDownInFilterThreshold - val isCaseSensitive = sqlConf.caseSensitiveAnalysis - val parquetOptions = new ParquetOptions(options, sparkSession.sessionState.conf) - val datetimeRebaseModeInRead = parquetOptions.datetimeRebaseModeInRead - val int96RebaseModeInRead = parquetOptions.int96RebaseModeInRead - val timeZoneId = Option(sqlConf.sessionLocalTimeZone) - // Should always be set by FileSourceScanExec creating this. - // Check conf before checking option, to allow working around an issue by changing conf. - val returningBatch = sparkSession.sessionState.conf.parquetVectorizedReaderEnabled && - supportsColumnar(sparkSession, resultSchema).toString.equals("true") - - - (file: PartitionedFile) => { - assert(!shouldAppendPartitionValues || file.partitionValues.numFields == partitionSchema.size) - - val filePath = file.filePath.toPath - val split = new FileSplit(filePath, file.start, file.length, Array.empty[String]) - - val sharedConf = broadcastedHadoopConf.value.value - - // Fetch internal schema - val internalSchemaStr = sharedConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // Internal schema has to be pruned at this point - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - - var shouldUseInternalSchema = !isNullOrEmpty(internalSchemaStr) && querySchemaOption.isPresent - - val tablePath = sharedConf.get(SparkInternalSchemaConverter.HOODIE_TABLE_PATH) - val fileSchema = if (shouldUseInternalSchema) { - val commitInstantTime = FSUtils.getCommitTime(filePath.getName).toLong; - val validCommits = sharedConf.get(SparkInternalSchemaConverter.HOODIE_VALID_COMMITS_LIST) - //TODO: HARDCODED TIMELINE OBJECT - val layout = TimelineLayout.fromVersion(TimelineLayoutVersion.CURR_LAYOUT_VERSION) - val storage = HoodieStorageUtils.getStorage(tablePath, HadoopFSUtils.getStorageConf(sharedConf)) - InternalSchemaCache.getInternalSchemaByVersionId(commitInstantTime, tablePath, storage, - if (validCommits == null) "" else validCommits, - layout) - } else { - null - } - - lazy val footerFileMetaData = - ParquetFooterReader.readFooter(sharedConf, filePath, SKIP_ROW_GROUPS).getFileMetaData - // Try to push down filters when filter push-down is enabled. - val pushed = if (enableParquetFilterPushDown) { - val parquetSchema = footerFileMetaData.getSchema - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val parquetFilters = new ParquetFilters( - parquetSchema, - pushDownDate, - pushDownTimestamp, - pushDownDecimal, - pushDownStringStartWith, - pushDownInFilterThreshold, - isCaseSensitive, - datetimeRebaseSpec) - filters.map(rebuildFilterFromParquet(_, fileSchema, querySchemaOption.orElse(null))) - // Collects all converted Parquet filter predicates. Notice that not all predicates can be - // converted (`ParquetFilters.createFilter` returns an `Option`). That's why a `flatMap` - // is used here. - .flatMap(parquetFilters.createFilter) - .reduceOption(FilterApi.and) - } else { - None - } - - // PARQUET_INT96_TIMESTAMP_CONVERSION says to apply timezone conversions to int96 timestamps' - // *only* if the file was created by something other than "parquet-mr", so check the actual - // writer here for this file. We have to do this per-file, as each file in the table may - // have different writers. - // Define isCreatedByParquetMr as function to avoid unnecessary parquet footer reads. - def isCreatedByParquetMr: Boolean = - footerFileMetaData.getCreatedBy().startsWith("parquet-mr") - - val convertTz = - if (timestampConversion && !isCreatedByParquetMr) { - Some(DateTimeUtils.getZoneId(sharedConf.get(SQLConf.SESSION_LOCAL_TIMEZONE.key))) - } else { - None - } - - val attemptId = new TaskAttemptID(new TaskID(new JobID(), TaskType.MAP, 0), 0) - - // Clone new conf - val hadoopAttemptConf = new Configuration(broadcastedHadoopConf.value.value) - val typeChangeInfos: java.util.Map[Integer, Pair[DataType, DataType]] = if (shouldUseInternalSchema) { - val mergedInternalSchema = new InternalSchemaMerger(fileSchema, querySchemaOption.get(), true, true).mergeSchema() - val mergedSchema = SparkInternalSchemaConverter.constructSparkSchemaFromInternalSchema(mergedInternalSchema) - - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, mergedSchema.json) - - SparkInternalSchemaConverter.collectTypeChangedCols(querySchemaOption.get(), mergedInternalSchema) - } else { - val (implicitTypeChangeInfo, sparkRequestSchema) = HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo(hadoopAttemptConf, footerFileMetaData, requiredSchema) - if (!implicitTypeChangeInfo.isEmpty) { - shouldUseInternalSchema = true - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, sparkRequestSchema.json) - } - implicitTypeChangeInfo - } - - if (enableVectorizedReader && shouldUseInternalSchema && - !typeChangeInfos.values().forall(_.getLeft.isInstanceOf[AtomicType])) { - throw new IllegalArgumentException( - "Nested types with type changes(implicit or explicit) cannot be read in vectorized mode. " + - "To workaround this issue, set spark.sql.parquet.enableVectorizedReader=false.") - } - - val hadoopAttemptContext = - new TaskAttemptContextImpl(hadoopAttemptConf, attemptId) - - // Try to push down filters when filter push-down is enabled. - // Notice: This push-down is RowGroups level, not individual records. - if (pushed.isDefined) { - ParquetInputFormat.setFilterPredicate(hadoopAttemptContext.getConfiguration, pushed.get) - } - val taskContext = Option(TaskContext.get()) - if (enableVectorizedReader) { - val vectorizedReader = - if (shouldUseInternalSchema) { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new HoodieVectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity, - typeChangeInfos) - } else { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new VectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity) - } - - // SPARK-37089: We cannot register a task completion listener to close this iterator here - // because downstream exec nodes have already registered their listeners. Since listeners - // are executed in reverse order of registration, a listener registered here would close the - // iterator while downstream exec nodes are still running. When off-heap column vectors are - // enabled, this can cause a use-after-free bug leading to a segfault. - // - // Instead, we use FileScanRDD's task completion listener to close this iterator. - val iter = new RecordReaderIterator(vectorizedReader) - try { - vectorizedReader.initialize(split, hadoopAttemptContext) - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (shouldAppendPartitionValues) { - logDebug(s"Appending $partitionSchema ${file.partitionValues}") - vectorizedReader.initBatch(partitionSchema, file.partitionValues) - } else { - vectorizedReader.initBatch(StructType(Nil), InternalRow.empty) - } - - if (returningBatch) { - vectorizedReader.enableReturningBatches() - } - - // UnsafeRowParquetRecordReader appends the columns internally to avoid another copy. - iter.asInstanceOf[Iterator[InternalRow]] - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } else { - logDebug(s"Falling back to parquet-mr") - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val readSupport = new HoodieParquetReadSupport( - convertTz, - enableVectorizedReader = false, - enableTimestampFieldRepair = true, - datetimeRebaseSpec, - int96RebaseSpec) - - val reader = if (pushed.isDefined && enableRecordFilter) { - val parquetFilter = FilterCompat.get(pushed.get, null) - new ParquetRecordReader[InternalRow](readSupport, parquetFilter) - } else { - new ParquetRecordReader[InternalRow](readSupport) - } - val iter = new RecordReaderIterator[InternalRow](reader) - try { - reader.initialize(split, hadoopAttemptContext) - - val fullSchema = requiredSchema.toAttributes ++ partitionSchema.toAttributes - val unsafeProjection = if (typeChangeInfos.isEmpty) { - GenerateUnsafeProjection.generate(fullSchema, fullSchema) - } else { - // find type changed. - val newFullSchema = new StructType(requiredSchema.fields.zipWithIndex.map { case (f, i) => - if (typeChangeInfos.containsKey(i)) { - StructField(f.name, typeChangeInfos.get(i).getRight, f.nullable, f.metadata) - } else f - }).toAttributes ++ partitionSchema.toAttributes - val castSchema = newFullSchema.zipWithIndex.map { case (attr, i) => - if (typeChangeInfos.containsKey(i)) { - val srcType = typeChangeInfos.get(i).getRight - val dstType = typeChangeInfos.get(i).getLeft - val needTimeZone = Cast.needsTimeZone(srcType, dstType) - Cast(attr, dstType, if (needTimeZone) timeZoneId else None) - } else attr - } - GenerateUnsafeProjection.generate(castSchema, newFullSchema) - } - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (!shouldAppendPartitionValues || partitionSchema.length == 0) { - // There is no partition columns - iter.map(unsafeProjection) - } else { - val joinedRow = new JoinedRow() - iter.map(d => unsafeProjection(joinedRow(d, file.partitionValues))) - } - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } - } - } -} - -object Spark34LegacyHoodieParquetFileFormat { - - def pruneInternalSchema(internalSchemaStr: String, requiredSchema: StructType): String = { - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - if (querySchemaOption.isPresent && requiredSchema.nonEmpty) { - val prunedSchema = SparkInternalSchemaConverter.convertAndPruneStructTypeToInternalSchema(requiredSchema, querySchemaOption.get()) - SerDeHelper.toJson(prunedSchema) - } else { - internalSchemaStr - } - } - - private def rebuildFilterFromParquet(oldFilter: Filter, fileSchema: InternalSchema, querySchema: InternalSchema): Filter = { - if (fileSchema == null || querySchema == null) { - oldFilter - } else { - oldFilter match { - case eq: EqualTo => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eq.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eq.copy(attribute = newAttribute) - case eqs: EqualNullSafe => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eqs.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eqs.copy(attribute = newAttribute) - case gt: GreaterThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gt.copy(attribute = newAttribute) - case gtr: GreaterThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gtr.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gtr.copy(attribute = newAttribute) - case lt: LessThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lt.copy(attribute = newAttribute) - case lte: LessThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lte.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lte.copy(attribute = newAttribute) - case i: In => - val newAttribute = InternalSchemaUtils.reBuildFilterName(i.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else i.copy(attribute = newAttribute) - case isn: IsNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isn.copy(attribute = newAttribute) - case isnn: IsNotNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isnn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isnn.copy(attribute = newAttribute) - case And(left, right) => - And(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Or(left, right) => - Or(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Not(child) => - Not(rebuildFilterFromParquet(child, fileSchema, querySchema)) - case ssw: StringStartsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ssw.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ssw.copy(attribute = newAttribute) - case ses: StringEndsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ses.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ses.copy(attribute = newAttribute) - case sc: StringContains => - val newAttribute = InternalSchemaUtils.reBuildFilterName(sc.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else sc.copy(attribute = newAttribute) - case AlwaysTrue => - AlwaysTrue - case AlwaysFalse => - AlwaysFalse - case _ => - AlwaysTrue - } - } } } diff --git a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/hudi/Spark34ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/hudi/Spark34ResolveHudiAlterTableCommand.scala deleted file mode 100644 index 31d2f93efbdec..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/hudi/Spark34ResolveHudiAlterTableCommand.scala +++ /dev/null @@ -1,67 +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.spark.sql.hudi - -import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.analysis.ResolvedTable -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table -import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} - -/** - * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. - * for alter table column commands. - */ -class Spark34ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { - - def apply(plan: LogicalPlan): LogicalPlan = { - if (ProvidesHoodieConfig.isSchemaEvolutionEnabled(sparkSession)) { - plan.resolveOperatorsUp { - case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => - HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) - case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => - HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) - case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => - HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) - case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => - HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) - case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => - HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) - case alter@AlterColumn(ResolvedHoodieV2TablePlan(t), _, _, _, _, _, _) if alter.resolved => - HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) - case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => - HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) - } - } else { - plan - } - } - - object ResolvedHoodieV2TablePlan { - def unapply(plan: LogicalPlan): Option[HoodieInternalV2Table] = { - plan match { - case ResolvedTable(_, _, v2Table: HoodieInternalV2Table, _) => Some(v2Table) - case _ => None - } - } - } -} - diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/hudi/Spark35HoodieFileScanRDD.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/hudi/Spark35HoodieFileScanRDD.scala deleted file mode 100644 index 9ab3c04605d5f..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/hudi/Spark35HoodieFileScanRDD.scala +++ /dev/null @@ -1,36 +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.hudi - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.execution.datasources.{FilePartition, FileScanRDD, PartitionedFile} -import org.apache.spark.sql.types.StructType - -class Spark35HoodieFileScanRDD(@transient private val sparkSession: SparkSession, - read: PartitionedFile => Iterator[InternalRow], - @transient filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty) - extends FileScanRDD(sparkSession, read, filePartitions, readDataSchema, metadataColumns) - with HoodieUnsafeRDD { - - override final def collect(): Array[InternalRow] = super[HoodieUnsafeRDD].collect() -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystExpressionUtils.scala index 6f1456972a52a..8039e6b968155 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystExpressionUtils.scala @@ -17,26 +17,16 @@ package org.apache.spark.sql -import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder -import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, EvalMode, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, ParseToDate, ParseToTimestamp, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} -import org.apache.spark.sql.execution.datasources.DataSourceStrategy +import org.apache.spark.sql.catalyst.expressions.{Cast, EvalMode, Expression, ParseToDate, ParseToTimestamp} import org.apache.spark.sql.types.{DataType, StructType} -object HoodieSpark35CatalystExpressionUtils extends HoodieSpark3CatalystExpressionUtils with PredicateHelper { +object HoodieSpark35CatalystExpressionUtils extends BaseHoodieCatalystExpressionUtils { override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { ExpressionEncoder.apply(schema).resolveAndBind() } - override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = { - DataSourceStrategy.normalizeExprs(exprs, attributes) - } - - override def extractPredicatesWithinOutputSet(condition: Expression, outputSet: AttributeSet): Option[Expression] = { - super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) - } - override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = { expr match { case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) @@ -44,16 +34,6 @@ object HoodieSpark35CatalystExpressionUtils extends HoodieSpark3CatalystExpressi } } - override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { - expr match { - case OrderPreservingTransformation(attrRef) => Some(attrRef) - case _ => None - } - } - - def canUpCast(fromType: DataType, toType: DataType): Boolean = - Cast.canUpCast(fromType, toType) - override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = expr match { case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => @@ -61,57 +41,10 @@ object HoodieSpark35CatalystExpressionUtils extends HoodieSpark3CatalystExpressi case _ => None } - private object OrderPreservingTransformation { - def unapply(expr: Expression): Option[AttributeReference] = { - expr match { - // Date/Time Expressions - case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ParseToDate(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ParseToTimestamp(OrderPreservingTransformation(attrRef), _, _, _, _) => Some(attrRef) - case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // String Expressions - case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Left API change: Improve RuntimeReplaceable - // https://issues.apache.org/jira/browse/SPARK-38240 - case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Math Expressions - // Binary - case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Unary - case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Other - case cast @ Cast(OrderPreservingTransformation(attrRef), _, _, _) - if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) - - // Identity transformation - case attrRef: AttributeReference => Some(attrRef) - // No match - case _ => None - } + override protected def unapplyOrderPreservingDateParsing(expr: Expression): Option[Expression] = + expr match { + case ParseToDate(child, _, _, _) => Some(child) + case ParseToTimestamp(child, _, _, _, _) => Some(child) + case _ => None } - } } diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystPlanUtils.scala index db480f4fb42e0..534d8696b2c41 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35CatalystPlanUtils.scala @@ -18,25 +18,14 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.{AnalysisErrorAt, ResolvedTable} -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} +import org.apache.spark.sql.catalyst.analysis.AnalysisErrorAt +import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression} import org.apache.spark.sql.catalyst.planning.ScanOperation -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} -import org.apache.spark.sql.execution.command.RepairTableCommand +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, MergeIntoTable} import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} -import org.apache.spark.sql.execution.streaming.SerializedOffset -import org.apache.spark.sql.types.StructType -object HoodieSpark35CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { - - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = - plan match { - case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) - case _ => None - } +object HoodieSpark35CatalystPlanUtils extends HoodieSpark3CatalystPlanUtils { override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { plan match { @@ -57,22 +46,6 @@ object HoodieSpark35CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { } } - override def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = - ProjectionOverSchema(schema, output) - - override def isRepairTable(plan: LogicalPlan): Boolean = { - plan.isInstanceOf[RepairTableCommand] - } - - override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { - plan match { - case rtc: RepairTableCommand => - Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) - case _ => - None - } - } - override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { a.failAnalysis( errorClass = "UNRESOLVED_COLUMN.WITH_SUGGESTION", @@ -86,72 +59,4 @@ object HoodieSpark35CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { errorClass = "TABLE_OR_VIEW_NOT_FOUND", messageParameters = Map("relationName" -> s"`$tableName`")) } - - override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { - plan match { - case ci@CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => - Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) - case _ => - None - } - } - - override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { - plan match { - case ci@DropIndex(table, indexName, ignoreIfNotExists) => - Some((table, indexName, ignoreIfNotExists)) - case _ => - None - } - } - - override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { - plan match { - case ci@HoodieShowIndexes(table, output) => - Some((table, output)) - case _ => - None - } - } - - override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { - plan match { - case ci@RefreshIndex(table, indexName) => - Some((table, indexName)) - case _ => - None - } - } - - override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { - plan match { - case insert: InsertIntoStatement => - Some((insert.table, insert.userSpecifiedCols, insert.partitionSpec, insert.query, insert.overwrite, insert.ifPartitionNotExists)) - case _ => - None - } - } - - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { - plan match { - case insert: InsertIntoStatement => - Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) - case _ => - None - } - } - - override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { - mergeAction match { - case UpdateAction(condition, assignments) => Some((condition, assignments)) - case _ => None - } - } - - override def extractJsonFromSerializedOffset(offset: Any): Option[String] = { - offset match { - case SerializedOffset(json) => Some(json) - case _ => None - } - } } diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35SchemaUtils.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35SchemaUtils.scala index 708abd69446bb..5193450599605 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35SchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/HoodieSpark35SchemaUtils.scala @@ -21,17 +21,13 @@ package org.apache.spark.sql import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils -import org.apache.spark.sql.jdbc.JdbcDialect import org.apache.spark.sql.types.StructType import org.apache.spark.sql.util.SchemaUtils -import java.sql.{Connection, ResultSet} - /** - * Utils on schema for Spark 3.4+. + * Utils on schema for Spark 3.5. */ -object HoodieSpark35SchemaUtils extends HoodieSchemaUtils { +object HoodieSpark35SchemaUtils extends HoodieSpark3SchemaUtils { override def checkColumnNameDuplication(columnNames: Seq[String], colType: String, caseSensitiveAnalysis: Boolean): Unit = { @@ -41,12 +37,4 @@ object HoodieSpark35SchemaUtils extends HoodieSchemaUtils { override def toAttributes(struct: StructType): Seq[Attribute] = { DataTypeUtils.toAttributes(struct) } - - override def getSchema(conn: Connection, - resultSet: ResultSet, - dialect: JdbcDialect, - alwaysNullable: Boolean = false, - isTimestampNTZ: Boolean = false): StructType = { - JdbcUtils.getSchema(resultSet, dialect, alwaysNullable) - } } diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_5Adapter.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_5Adapter.scala index a2dfe3e7be60c..3fd01ae7085b1 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_5Adapter.scala +++ b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/adapter/Spark3_5Adapter.scala @@ -17,7 +17,6 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.Spark35HoodieFileScanRDD import org.apache.hudi.common.schema.HoodieSchema import org.apache.hudi.storage.StorageConfiguration @@ -30,7 +29,7 @@ import org.apache.spark.sql.avro._ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, ResolvedTable} import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression} +import org.apache.spark.sql.catalyst.expressions.{Expression} import org.apache.spark.sql.catalyst.parser.ParserInterface import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical._ @@ -38,7 +37,7 @@ import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, RebaseDateTime import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback} import org.apache.spark.sql.execution.datasources._ import org.apache.spark.sql.execution.datasources.lance.SparkLanceReaderBase -import org.apache.spark.sql.execution.datasources.orc.Spark35OrcReader +import org.apache.spark.sql.execution.datasources.orc.{OrcColumnarBatchReader, SparkOrcReaderBase} import org.apache.spark.sql.execution.datasources.parquet.{ParquetFileFormat, ParquetFilters, Spark35LegacyHoodieParquetFileFormat, Spark35ParquetReader} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.hudi.analysis.TableValuedFunctions @@ -103,14 +102,6 @@ class Spark3_5Adapter extends BaseSpark3Adapter { Some(new Spark35LegacyHoodieParquetFileFormat(appendPartitionValues)) } - override def createHoodieFileScanRDD(sparkSession: SparkSession, - readFunction: PartitionedFile => Iterator[InternalRow], - filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { - new Spark35HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) - } - override def extractDeleteCondition(deleteFromTable: Command): Expression = { deleteFromTable.asInstanceOf[DeleteFromTable].condition } @@ -179,7 +170,8 @@ class Spark3_5Adapter extends BaseSpark3Adapter { options: Map[String, String], hadoopConf: Configuration, dataSchema: StructType): SparkColumnarFileReader = { - Spark35OrcReader.build(vectorized, sqlConf, options, hadoopConf, dataSchema) + SparkOrcReaderBase.build(vectorized, sqlConf, options, hadoopConf, dataSchema, + (capacity, memoryMode) => new OrcColumnarBatchReader(capacity, memoryMode)) } override def createLanceFileReader(vectorized: Boolean, diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala deleted file mode 100644 index d10ff1edfe879..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala +++ /dev/null @@ -1,531 +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.spark.sql.avro - -import org.apache.hudi.common.schema.HoodieSchema -import org.apache.hudi.common.schema.HoodieSchema.VectorLogicalType - -import org.apache.avro.{LogicalTypes, Schema, SchemaBuilder} -import org.apache.avro.Conversions.DecimalConversion -import org.apache.avro.LogicalTypes.{LocalTimestampMicros, LocalTimestampMillis, TimestampMicros, TimestampMillis} -import org.apache.avro.Schema.Type._ -import org.apache.avro.generic._ -import org.apache.avro.util.Utf8 -import org.apache.spark.sql.avro.AvroDeserializer.{createDateRebaseFuncInRead, createTimestampRebaseFuncInRead, RebaseSpec} -import org.apache.spark.sql.avro.AvroUtils.{toFieldStr, AvroMatchedField} -import org.apache.spark.sql.catalyst.{InternalRow, NoopFilters, StructFilters} -import org.apache.spark.sql.catalyst.expressions.{SpecificInternalRow, UnsafeArrayData} -import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData, DateTimeUtils, GenericArrayData, RebaseDateTime} -import org.apache.spark.sql.catalyst.util.DateTimeConstants.MILLIS_PER_DAY -import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.LegacyBehaviorPolicy -import org.apache.spark.sql.types._ -import org.apache.spark.unsafe.types.UTF8String - -import java.math.BigDecimal -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.TimeZone - -import scala.collection.JavaConverters._ - -/** - * A deserializer to deserialize data in avro format to data in catalyst format. - * - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] class AvroDeserializer(rootAvroType: Schema, - rootCatalystType: DataType, - positionalFieldMatch: Boolean, - datetimeRebaseSpec: RebaseSpec, - filters: StructFilters) { - - def this(rootAvroType: Schema, - rootCatalystType: DataType, - datetimeRebaseMode: String) = { - this( - rootAvroType, - rootCatalystType, - positionalFieldMatch = false, - RebaseSpec(LegacyBehaviorPolicy.withName(datetimeRebaseMode)), - new NoopFilters) - } - - private lazy val decimalConversions = new DecimalConversion() - - private val dateRebaseFunc = createDateRebaseFuncInRead(datetimeRebaseSpec.mode, "Avro") - - private val timestampRebaseFunc = createTimestampRebaseFuncInRead(datetimeRebaseSpec, "Avro") - - private val converter: Any => Option[Any] = try { - rootCatalystType match { - // A shortcut for empty schema. - case st: StructType if st.isEmpty => - (_: Any) => Some(InternalRow.empty) - - case st: StructType => - val resultRow = new SpecificInternalRow(st.map(_.dataType)) - val fieldUpdater = new RowUpdater(resultRow) - val applyFilters = filters.skipRow(resultRow, _) - val writer = getRecordWriter(rootAvroType, st, Nil, Nil, applyFilters) - (data: Any) => { - val record = data.asInstanceOf[GenericRecord] - val skipRow = writer(fieldUpdater, record) - if (skipRow) None else Some(resultRow) - } - - case _ => - val tmpRow = new SpecificInternalRow(Seq(rootCatalystType)) - val fieldUpdater = new RowUpdater(tmpRow) - val writer = newWriter(rootAvroType, rootCatalystType, Nil, Nil) - (data: Any) => { - writer(fieldUpdater, 0, data) - Some(tmpRow.get(0, rootCatalystType)) - } - } - } catch { - case ise: IncompatibleSchemaException => throw new IncompatibleSchemaException( - s"Cannot convert Avro type $rootAvroType to SQL type ${rootCatalystType.sql}.", ise) - } - - def deserialize(data: Any): Option[Any] = converter(data) - - /** - * Creates a writer to write avro values to Catalyst values at the given ordinal with the given - * updater. - */ - private def newWriter(avroType: Schema, - catalystType: DataType, - avroPath: Seq[String], - catalystPath: Seq[String]): (CatalystDataUpdater, Int, Any) => Unit = { - val errorPrefix = s"Cannot convert Avro ${toFieldStr(avroPath)} to " + - s"SQL ${toFieldStr(catalystPath)} because " - val incompatibleMsg = errorPrefix + - s"schema is incompatible (avroType = $avroType, sqlType = ${catalystType.sql})" - - (avroType.getType, catalystType) match { - case (NULL, NullType) => (updater, ordinal, _) => - updater.setNullAt(ordinal) - - // TODO: we can avoid boxing if future version of avro provide primitive accessors. - case (BOOLEAN, BooleanType) => (updater, ordinal, value) => - updater.setBoolean(ordinal, value.asInstanceOf[Boolean]) - - case (INT, IntegerType) => (updater, ordinal, value) => - updater.setInt(ordinal, value.asInstanceOf[Int]) - - case (INT, DateType) => (updater, ordinal, value) => - updater.setInt(ordinal, dateRebaseFunc(value.asInstanceOf[Int])) - - case (LONG, LongType) => (updater, ordinal, value) => - updater.setLong(ordinal, value.asInstanceOf[Long]) - - case (LONG, TimestampType) => avroType.getLogicalType match { - // For backward compatibility, if the Avro type is Long and it is not logical type - // (the `null` case), the value is processed as timestamp type with millisecond precision. - case null | _: TimestampMillis => (updater, ordinal, value) => - val millis = value.asInstanceOf[Long] - val micros = DateTimeUtils.millisToMicros(millis) - updater.setLong(ordinal, timestampRebaseFunc(micros)) - case _: TimestampMicros => (updater, ordinal, value) => - val micros = value.asInstanceOf[Long] - updater.setLong(ordinal, timestampRebaseFunc(micros)) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"Avro logical type $other cannot be converted to SQL type ${TimestampType.sql}.") - } - - case (LONG, TimestampNTZType) => avroType.getLogicalType match { - // To keep consistent with TimestampType, if the Avro type is Long and it is not - // logical type (the `null` case), the value is processed as TimestampNTZ - // with millisecond precision. - case null | _: LocalTimestampMillis => (updater, ordinal, value) => - val millis = value.asInstanceOf[Long] - val micros = DateTimeUtils.millisToMicros(millis) - updater.setLong(ordinal, micros) - case _: LocalTimestampMicros => (updater, ordinal, value) => - val micros = value.asInstanceOf[Long] - updater.setLong(ordinal, micros) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"Avro logical type $other cannot be converted to SQL type ${TimestampNTZType.sql}.") - } - - // Handle VECTOR logical type (FLOAT, DOUBLE, INT8) - case (FIXED, ArrayType(elementType, false)) => avroType.getLogicalType match { - case vectorLogicalType: VectorLogicalType => - val dimension = vectorLogicalType.getDimension - val vecElementType = HoodieSchema.Vector.VectorElementType.fromString(vectorLogicalType.getElementType) - val elementSize = vecElementType.getElementSize - (updater, ordinal, value) => { - val bytes = value.asInstanceOf[GenericData.Fixed].bytes() - val expectedSize = Math.multiplyExact(dimension, elementSize) - if (bytes.length != expectedSize) { - throw new IncompatibleSchemaException( - s"VECTOR byte size mismatch: expected=$expectedSize, actual=${bytes.length}") - } - elementType match { - case FloatType => - val buffer = ByteBuffer.wrap(bytes).order(VectorLogicalType.VECTOR_BYTE_ORDER) - val floats = new Array[Float](dimension) - var i = 0; while (i < dimension) { floats(i) = buffer.getFloat(); i += 1 } - updater.set(ordinal, ArrayData.toArrayData(floats)) - case DoubleType => - val buffer = ByteBuffer.wrap(bytes).order(VectorLogicalType.VECTOR_BYTE_ORDER) - val doubles = new Array[Double](dimension) - var i = 0; while (i < dimension) { doubles(i) = buffer.getDouble(); i += 1 } - updater.set(ordinal, ArrayData.toArrayData(doubles)) - case ByteType => - updater.set(ordinal, ArrayData.toArrayData(bytes.clone())) - } - } - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - - // Before we upgrade Avro to 1.8 for logical type support, spark-avro converts Long to Date. - // For backward compatibility, we still keep this conversion. - case (LONG, DateType) => (updater, ordinal, value) => - updater.setInt(ordinal, (value.asInstanceOf[Long] / MILLIS_PER_DAY).toInt) - - case (FLOAT, FloatType) => (updater, ordinal, value) => - updater.setFloat(ordinal, value.asInstanceOf[Float]) - - case (DOUBLE, DoubleType) => (updater, ordinal, value) => - updater.setDouble(ordinal, value.asInstanceOf[Double]) - - case (STRING, StringType) => (updater, ordinal, value) => - val str = value match { - case s: String => UTF8String.fromString(s) - case s: Utf8 => - val bytes = new Array[Byte](s.getByteLength) - System.arraycopy(s.getBytes, 0, bytes, 0, s.getByteLength) - UTF8String.fromBytes(bytes) - case s: GenericData.EnumSymbol => UTF8String.fromString(s.toString) - } - updater.set(ordinal, str) - - case (ENUM, StringType) => (updater, ordinal, value) => - updater.set(ordinal, UTF8String.fromString(value.toString)) - - case (FIXED, BinaryType) => (updater, ordinal, value) => - updater.set(ordinal, value.asInstanceOf[GenericFixed].bytes().clone()) - - case (BYTES, BinaryType) => (updater, ordinal, value) => - val bytes = value match { - case b: ByteBuffer => - val bytes = new Array[Byte](b.remaining) - b.get(bytes) - // Do not forget to reset the position - b.rewind() - bytes - case b: Array[Byte] => b - case other => - throw new RuntimeException(errorPrefix + s"$other is not a valid avro binary.") - } - updater.set(ordinal, bytes) - - case (FIXED, _: DecimalType) => (updater, ordinal, value) => - val d = avroType.getLogicalType.asInstanceOf[LogicalTypes.Decimal] - val bigDecimal = decimalConversions.fromFixed(value.asInstanceOf[GenericFixed], avroType, d) - val decimal = createDecimal(bigDecimal, d.getPrecision, d.getScale) - updater.setDecimal(ordinal, decimal) - - case (BYTES, _: DecimalType) => (updater, ordinal, value) => - val d = avroType.getLogicalType.asInstanceOf[LogicalTypes.Decimal] - val bigDecimal = decimalConversions.fromBytes(value.asInstanceOf[ByteBuffer], avroType, d) - val decimal = createDecimal(bigDecimal, d.getPrecision, d.getScale) - updater.setDecimal(ordinal, decimal) - - case (RECORD, st: StructType) => - // Avro datasource doesn't accept filters with nested attributes. See SPARK-32328. - // We can always return `false` from `applyFilters` for nested records. - val writeRecord = - getRecordWriter(avroType, st, avroPath, catalystPath, applyFilters = _ => false) - (updater, ordinal, value) => - val row = new SpecificInternalRow(st) - writeRecord(new RowUpdater(row), value.asInstanceOf[GenericRecord]) - updater.set(ordinal, row) - - case (ARRAY, ArrayType(elementType, containsNull)) => - val avroElementPath = avroPath :+ "element" - val elementWriter = newWriter(avroType.getElementType, elementType, - avroElementPath, catalystPath :+ "element") - (updater, ordinal, value) => - val collection = value.asInstanceOf[java.util.Collection[Any]] - val result = createArrayData(elementType, collection.size()) - val elementUpdater = new ArrayDataUpdater(result) - - var i = 0 - val iter = collection.iterator() - while (iter.hasNext) { - val element = iter.next() - if (element == null) { - if (!containsNull) { - throw new RuntimeException( - s"Array value at path ${toFieldStr(avroElementPath)} is not allowed to be null") - } else { - elementUpdater.setNullAt(i) - } - } else { - elementWriter(elementUpdater, i, element) - } - i += 1 - } - - updater.set(ordinal, result) - - case (MAP, MapType(keyType, valueType, valueContainsNull)) if keyType == StringType => - val keyWriter = newWriter(SchemaBuilder.builder().stringType(), StringType, - avroPath :+ "key", catalystPath :+ "key") - val valueWriter = newWriter(avroType.getValueType, valueType, - avroPath :+ "value", catalystPath :+ "value") - (updater, ordinal, value) => - val map = value.asInstanceOf[java.util.Map[AnyRef, AnyRef]] - val keyArray = createArrayData(keyType, map.size()) - val keyUpdater = new ArrayDataUpdater(keyArray) - val valueArray = createArrayData(valueType, map.size()) - val valueUpdater = new ArrayDataUpdater(valueArray) - val iter = map.entrySet().iterator() - var i = 0 - while (iter.hasNext) { - val entry = iter.next() - assert(entry.getKey != null) - keyWriter(keyUpdater, i, entry.getKey) - if (entry.getValue == null) { - if (!valueContainsNull) { - throw new RuntimeException( - s"Map value at path ${toFieldStr(avroPath :+ "value")} is not allowed to be null") - } else { - valueUpdater.setNullAt(i) - } - } else { - valueWriter(valueUpdater, i, entry.getValue) - } - i += 1 - } - - // The Avro map will never have null or duplicated map keys, it's safe to create a - // ArrayBasedMapData directly here. - updater.set(ordinal, new ArrayBasedMapData(keyArray, valueArray)) - - case (UNION, _) => - val allTypes = avroType.getTypes.asScala - val nonNullTypes = allTypes.filter(_.getType != NULL) - val nonNullAvroType = Schema.createUnion(nonNullTypes.asJava) - if (nonNullTypes.nonEmpty) { - if (nonNullTypes.length == 1) { - newWriter(nonNullTypes.head, catalystType, avroPath, catalystPath) - } else { - nonNullTypes.map(_.getType).toSeq match { - case Seq(a, b) if Set(a, b) == Set(INT, LONG) && catalystType == LongType => - (updater, ordinal, value) => value match { - case null => updater.setNullAt(ordinal) - case l: java.lang.Long => updater.setLong(ordinal, l) - case i: java.lang.Integer => updater.setLong(ordinal, i.longValue()) - } - - case Seq(a, b) if Set(a, b) == Set(FLOAT, DOUBLE) && catalystType == DoubleType => - (updater, ordinal, value) => value match { - case null => updater.setNullAt(ordinal) - case d: java.lang.Double => updater.setDouble(ordinal, d) - case f: java.lang.Float => updater.setDouble(ordinal, f.doubleValue()) - } - - case _ => - catalystType match { - case st: StructType if st.length == nonNullTypes.size => - val fieldWriters = nonNullTypes.zip(st.fields).map { - case (schema, field) => - newWriter(schema, field.dataType, avroPath, catalystPath :+ field.name) - }.toArray - (updater, ordinal, value) => { - val row = new SpecificInternalRow(st) - val fieldUpdater = new RowUpdater(row) - val i = GenericData.get().resolveUnion(nonNullAvroType, value) - fieldWriters(i)(fieldUpdater, i, value) - updater.set(ordinal, row) - } - - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - } - } - } else { - (updater, ordinal, _) => updater.setNullAt(ordinal) - } - - case (INT, _: YearMonthIntervalType) => (updater, ordinal, value) => - updater.setInt(ordinal, value.asInstanceOf[Int]) - - case (LONG, _: DayTimeIntervalType) => (updater, ordinal, value) => - updater.setLong(ordinal, value.asInstanceOf[Long]) - - case _ => throw new IncompatibleSchemaException(incompatibleMsg) - } - } - - // TODO: move the following method in Decimal object on creating Decimal from BigDecimal? - private def createDecimal(decimal: BigDecimal, precision: Int, scale: Int): Decimal = { - if (precision <= Decimal.MAX_LONG_DIGITS) { - // Constructs a `Decimal` with an unscaled `Long` value if possible. - Decimal(decimal.unscaledValue().longValue(), precision, scale) - } else { - // Otherwise, resorts to an unscaled `BigInteger` instead. - Decimal(decimal, precision, scale) - } - } - - private def getRecordWriter( - avroType: Schema, - catalystType: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - applyFilters: Int => Boolean): (CatalystDataUpdater, GenericRecord) => Boolean = { - - val avroSchemaHelper = new AvroUtils.AvroSchemaHelper( - avroType, catalystType, avroPath, catalystPath, positionalFieldMatch) - - avroSchemaHelper.validateNoExtraCatalystFields(ignoreNullable = true) - // no need to validateNoExtraAvroFields since extra Avro fields are ignored - - val (validFieldIndexes, fieldWriters) = avroSchemaHelper.matchedFields.map { - case AvroMatchedField(catalystField, ordinal, avroField) => - val baseWriter = newWriter(avroField.schema(), catalystField.dataType, - avroPath :+ avroField.name, catalystPath :+ catalystField.name) - val fieldWriter = (fieldUpdater: CatalystDataUpdater, value: Any) => { - if (value == null) { - fieldUpdater.setNullAt(ordinal) - } else { - baseWriter(fieldUpdater, ordinal, value) - } - } - (avroField.pos(), fieldWriter) - }.toArray.unzip - - (fieldUpdater, record) => { - var i = 0 - var skipRow = false - while (i < validFieldIndexes.length && !skipRow) { - fieldWriters(i)(fieldUpdater, record.get(validFieldIndexes(i))) - skipRow = applyFilters(i) - i += 1 - } - skipRow - } - } - - private def createArrayData(elementType: DataType, length: Int): ArrayData = elementType match { - case BooleanType => UnsafeArrayData.fromPrimitiveArray(new Array[Boolean](length)) - case ByteType => UnsafeArrayData.fromPrimitiveArray(new Array[Byte](length)) - case ShortType => UnsafeArrayData.fromPrimitiveArray(new Array[Short](length)) - case IntegerType => UnsafeArrayData.fromPrimitiveArray(new Array[Int](length)) - case LongType => UnsafeArrayData.fromPrimitiveArray(new Array[Long](length)) - case FloatType => UnsafeArrayData.fromPrimitiveArray(new Array[Float](length)) - case DoubleType => UnsafeArrayData.fromPrimitiveArray(new Array[Double](length)) - case _ => new GenericArrayData(new Array[Any](length)) - } - - /** - * A base interface for updating values inside catalyst data structure like `InternalRow` and - * `ArrayData`. - */ - sealed trait CatalystDataUpdater { - def set(ordinal: Int, value: Any): Unit - - def setNullAt(ordinal: Int): Unit = set(ordinal, null) - def setBoolean(ordinal: Int, value: Boolean): Unit = set(ordinal, value) - def setByte(ordinal: Int, value: Byte): Unit = set(ordinal, value) - def setShort(ordinal: Int, value: Short): Unit = set(ordinal, value) - def setInt(ordinal: Int, value: Int): Unit = set(ordinal, value) - def setLong(ordinal: Int, value: Long): Unit = set(ordinal, value) - def setDouble(ordinal: Int, value: Double): Unit = set(ordinal, value) - def setFloat(ordinal: Int, value: Float): Unit = set(ordinal, value) - def setDecimal(ordinal: Int, value: Decimal): Unit = set(ordinal, value) - } - - final class RowUpdater(row: InternalRow) extends CatalystDataUpdater { - override def set(ordinal: Int, value: Any): Unit = row.update(ordinal, value) - - override def setNullAt(ordinal: Int): Unit = row.setNullAt(ordinal) - override def setBoolean(ordinal: Int, value: Boolean): Unit = row.setBoolean(ordinal, value) - override def setByte(ordinal: Int, value: Byte): Unit = row.setByte(ordinal, value) - override def setShort(ordinal: Int, value: Short): Unit = row.setShort(ordinal, value) - override def setInt(ordinal: Int, value: Int): Unit = row.setInt(ordinal, value) - override def setLong(ordinal: Int, value: Long): Unit = row.setLong(ordinal, value) - override def setDouble(ordinal: Int, value: Double): Unit = row.setDouble(ordinal, value) - override def setFloat(ordinal: Int, value: Float): Unit = row.setFloat(ordinal, value) - override def setDecimal(ordinal: Int, value: Decimal): Unit = - row.setDecimal(ordinal, value, value.precision) - } - - final class ArrayDataUpdater(array: ArrayData) extends CatalystDataUpdater { - override def set(ordinal: Int, value: Any): Unit = array.update(ordinal, value) - - override def setNullAt(ordinal: Int): Unit = array.setNullAt(ordinal) - override def setBoolean(ordinal: Int, value: Boolean): Unit = array.setBoolean(ordinal, value) - override def setByte(ordinal: Int, value: Byte): Unit = array.setByte(ordinal, value) - override def setShort(ordinal: Int, value: Short): Unit = array.setShort(ordinal, value) - override def setInt(ordinal: Int, value: Int): Unit = array.setInt(ordinal, value) - override def setLong(ordinal: Int, value: Long): Unit = array.setLong(ordinal, value) - override def setDouble(ordinal: Int, value: Double): Unit = array.setDouble(ordinal, value) - override def setFloat(ordinal: Int, value: Float): Unit = array.setFloat(ordinal, value) - override def setDecimal(ordinal: Int, value: Decimal): Unit = array.update(ordinal, value) - } -} - -object AvroDeserializer { - - // NOTE: Following methods have been renamed in Spark 3.2.1 [1] making [[AvroDeserializer]] implementation - // (which relies on it) be only compatible with the exact same version of [[DataSourceUtils]]. - // To make sure this implementation is compatible w/ all Spark versions w/in Spark 3.2.x branch, - // we're preemptively cloned those methods to make sure Hudi is compatible w/ Spark 3.2.0 as well as - // w/ Spark >= 3.2.1 - // - // [1] https://github.com/apache/spark/pull/34978 - - // Specification of rebase operation including `mode` and the time zone in which it is performed - case class RebaseSpec(mode: LegacyBehaviorPolicy.Value, originTimeZone: Option[String] = None) { - // Use the default JVM time zone for backward compatibility - def timeZone: String = originTimeZone.getOrElse(TimeZone.getDefault.getID) - } - - def createDateRebaseFuncInRead(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Int => Int = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => days: Int => - if (days < RebaseDateTime.lastSwitchJulianDay) { - throw DataSourceUtils.newRebaseExceptionInRead(format) - } - days - case LegacyBehaviorPolicy.LEGACY => RebaseDateTime.rebaseJulianToGregorianDays - case LegacyBehaviorPolicy.CORRECTED => identity[Int] - } - - def createTimestampRebaseFuncInRead(rebaseSpec: RebaseSpec, - format: String): Long => Long = rebaseSpec.mode match { - case LegacyBehaviorPolicy.EXCEPTION => micros: Long => - if (micros < RebaseDateTime.lastSwitchJulianTs) { - throw DataSourceUtils.newRebaseExceptionInRead(format) - } - micros - case LegacyBehaviorPolicy.LEGACY => micros: Long => - RebaseDateTime.rebaseJulianToGregorianMicros(TimeZone.getTimeZone(rebaseSpec.timeZone), micros) - case LegacyBehaviorPolicy.CORRECTED => identity[Long] - } -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala deleted file mode 100644 index 756ef82a55d21..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroSerializer.scala +++ /dev/null @@ -1,489 +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.spark.sql.avro - -import org.apache.hudi.common.schema.HoodieSchema -import org.apache.hudi.common.schema.HoodieSchema.VectorLogicalType - -import org.apache.avro.{LogicalTypes, Schema} -import org.apache.avro.Conversions.DecimalConversion -import org.apache.avro.LogicalTypes.{LocalTimestampMicros, LocalTimestampMillis, TimestampMicros, TimestampMillis} -import org.apache.avro.Schema.Type -import org.apache.avro.Schema.Type._ -import org.apache.avro.generic.GenericData.{EnumSymbol, Fixed, Record} -import org.apache.avro.util.Utf8 -import org.apache.spark.internal.Logging -import org.apache.spark.sql.avro.AvroSerializer.{createDateRebaseFuncInWrite, createTimestampRebaseFuncInWrite} -import org.apache.spark.sql.avro.AvroUtils.{toFieldStr, AvroMatchedField} -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{SpecializedGetters, SpecificInternalRow} -import org.apache.spark.sql.catalyst.util.{DateTimeUtils, RebaseDateTime} -import org.apache.spark.sql.execution.datasources.DataSourceUtils -import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf} -import org.apache.spark.sql.types._ - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.TimeZone - -import scala.collection.JavaConverters._ - -/** - * A serializer to serialize data in catalyst format to data in avro format. - * - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * NOTE: THIS IMPLEMENTATION HAS BEEN MODIFIED FROM ITS ORIGINAL VERSION WITH THE MODIFICATION - * BEING EXPLICITLY ANNOTATED INLINE. PLEASE MAKE SURE TO UNDERSTAND PROPERLY ALL THE - * MODIFICATIONS. - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] class AvroSerializer(rootCatalystType: DataType, - rootAvroType: Schema, - nullable: Boolean, - positionalFieldMatch: Boolean, - datetimeRebaseMode: LegacyBehaviorPolicy.Value) extends Logging { - - def this(rootCatalystType: DataType, rootAvroType: Schema, nullable: Boolean) = { - this(rootCatalystType, rootAvroType, nullable, positionalFieldMatch = false, - LegacyBehaviorPolicy.withName(SQLConf.get.getConf(SQLConf.AVRO_REBASE_MODE_IN_WRITE, - LegacyBehaviorPolicy.CORRECTED.toString))) - } - - def serialize(catalystData: Any): Any = { - converter.apply(catalystData) - } - - private val dateRebaseFunc = createDateRebaseFuncInWrite( - datetimeRebaseMode, "Avro") - - private val timestampRebaseFunc = createTimestampRebaseFuncInWrite( - datetimeRebaseMode, "Avro") - - private val converter: Any => Any = { - val actualAvroType = resolveNullableType(rootAvroType, nullable) - val baseConverter = try { - rootCatalystType match { - case st: StructType => - newStructConverter(st, actualAvroType, Nil, Nil).asInstanceOf[Any => Any] - case _ => - val tmpRow = new SpecificInternalRow(Seq(rootCatalystType)) - val converter = newConverter(rootCatalystType, actualAvroType, Nil, Nil) - (data: Any) => - tmpRow.update(0, data) - converter.apply(tmpRow, 0) - } - } catch { - case ise: IncompatibleSchemaException => throw new IncompatibleSchemaException( - s"Cannot convert SQL type ${rootCatalystType.sql} to Avro type $rootAvroType.", ise) - } - if (nullable) { - (data: Any) => - if (data == null) { - null - } else { - baseConverter.apply(data) - } - } else { - baseConverter - } - } - - private type Converter = (SpecializedGetters, Int) => Any - - private lazy val decimalConversions = new DecimalConversion() - - private def newConverter(catalystType: DataType, - avroType: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): Converter = { - val errorPrefix = s"Cannot convert SQL ${toFieldStr(catalystPath)} " + - s"to Avro ${toFieldStr(avroPath)} because " - (catalystType, avroType.getType) match { - case (NullType, NULL) => - (getter, ordinal) => null - case (BooleanType, BOOLEAN) => - (getter, ordinal) => getter.getBoolean(ordinal) - case (ByteType, INT) => - (getter, ordinal) => getter.getByte(ordinal).toInt - case (ShortType, INT) => - (getter, ordinal) => getter.getShort(ordinal).toInt - case (IntegerType, INT) => - (getter, ordinal) => getter.getInt(ordinal) - case (LongType, LONG) => - (getter, ordinal) => getter.getLong(ordinal) - case (FloatType, FLOAT) => - (getter, ordinal) => getter.getFloat(ordinal) - case (DoubleType, DOUBLE) => - (getter, ordinal) => getter.getDouble(ordinal) - case (d: DecimalType, FIXED) - if avroType.getLogicalType == LogicalTypes.decimal(d.precision, d.scale) => - (getter, ordinal) => - val decimal = getter.getDecimal(ordinal, d.precision, d.scale) - decimalConversions.toFixed(decimal.toJavaBigDecimal, avroType, - LogicalTypes.decimal(d.precision, d.scale)) - - case (d: DecimalType, BYTES) - if avroType.getLogicalType == LogicalTypes.decimal(d.precision, d.scale) => - (getter, ordinal) => - val decimal = getter.getDecimal(ordinal, d.precision, d.scale) - decimalConversions.toBytes(decimal.toJavaBigDecimal, avroType, - LogicalTypes.decimal(d.precision, d.scale)) - - // Handle VECTOR logical type (FLOAT, DOUBLE, INT8) - case (ArrayType(elementType, false), FIXED) => avroType.getLogicalType match { - case vectorLogicalType: VectorLogicalType => - val dimension = vectorLogicalType.getDimension - val vecElementType = HoodieSchema.Vector.VectorElementType.fromString(vectorLogicalType.getElementType) - val bufferSize = Math.multiplyExact(dimension, vecElementType.getElementSize) - (getter, ordinal) => { - val arrayData = getter.getArray(ordinal) - if (arrayData.numElements() != dimension) { - throw new IncompatibleSchemaException( - s"VECTOR dimension mismatch at ${toFieldStr(catalystPath)}: " + - s"expected=$dimension, actual=${arrayData.numElements()}") - } - elementType match { - case FloatType => - val buffer = ByteBuffer.allocate(bufferSize).order(VectorLogicalType.VECTOR_BYTE_ORDER) - var i = 0; while (i < dimension) { buffer.putFloat(arrayData.getFloat(i)); i += 1 } - new Fixed(avroType, buffer.array()) - case DoubleType => - val buffer = ByteBuffer.allocate(bufferSize).order(VectorLogicalType.VECTOR_BYTE_ORDER) - var i = 0; while (i < dimension) { buffer.putDouble(arrayData.getDouble(i)); i += 1 } - new Fixed(avroType, buffer.array()) - case ByteType => - val bytes = new Array[Byte](dimension) - var i = 0; while (i < dimension) { bytes(i) = arrayData.getByte(i); i += 1 } - new Fixed(avroType, bytes) - case _ => throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - } - case _ => throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - - case (StringType, ENUM) => - val enumSymbols: Set[String] = avroType.getEnumSymbols.asScala.toSet - (getter, ordinal) => - val data = getter.getUTF8String(ordinal).toString - if (!enumSymbols.contains(data)) { - throw new IncompatibleSchemaException(errorPrefix + - s""""$data" cannot be written since it's not defined in enum """ + - enumSymbols.mkString("\"", "\", \"", "\"")) - } - new EnumSymbol(avroType, data) - - case (StringType, STRING) => - (getter, ordinal) => new Utf8(getter.getUTF8String(ordinal).getBytes) - - case (BinaryType, FIXED) => - val size = avroType.getFixedSize - (getter, ordinal) => - val data: Array[Byte] = getter.getBinary(ordinal) - if (data.length != size) { - def len2str(len: Int): String = s"$len ${if (len > 1) "bytes" else "byte"}" - - throw new IncompatibleSchemaException(errorPrefix + len2str(data.length) + - " of binary data cannot be written into FIXED type with size of " + len2str(size)) - } - new Fixed(avroType, data) - - case (BinaryType, BYTES) => - (getter, ordinal) => ByteBuffer.wrap(getter.getBinary(ordinal)) - - case (DateType, INT) => - (getter, ordinal) => dateRebaseFunc(getter.getInt(ordinal)) - - case (TimestampType, LONG) => avroType.getLogicalType match { - // For backward compatibility, if the Avro type is Long and it is not logical type - // (the `null` case), output the timestamp value as with millisecond precision. - case null | _: TimestampMillis => (getter, ordinal) => - DateTimeUtils.microsToMillis(timestampRebaseFunc(getter.getLong(ordinal))) - case _: TimestampMicros => (getter, ordinal) => - timestampRebaseFunc(getter.getLong(ordinal)) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"SQL type ${TimestampType.sql} cannot be converted to Avro logical type $other") - } - - case (TimestampNTZType, LONG) => avroType.getLogicalType match { - // To keep consistent with TimestampType, if the Avro type is Long and it is not - // logical type (the `null` case), output the TimestampNTZ as long value - // in millisecond precision. - case null | _: LocalTimestampMillis => (getter, ordinal) => - DateTimeUtils.microsToMillis(getter.getLong(ordinal)) - case _: LocalTimestampMicros => (getter, ordinal) => - getter.getLong(ordinal) - case other => throw new IncompatibleSchemaException(errorPrefix + - s"SQL type ${TimestampNTZType.sql} cannot be converted to Avro logical type $other") - } - - case (ArrayType(et, containsNull), ARRAY) => - val elementConverter = newConverter( - et, resolveNullableType(avroType.getElementType, containsNull), - catalystPath :+ "element", avroPath :+ "element") - (getter, ordinal) => { - val arrayData = getter.getArray(ordinal) - val len = arrayData.numElements() - val result = new Array[Any](len) - var i = 0 - while (i < len) { - if (containsNull && arrayData.isNullAt(i)) { - result(i) = null - } else { - result(i) = elementConverter(arrayData, i) - } - i += 1 - } - // avro writer is expecting a Java Collection, so we convert it into - // `ArrayList` backed by the specified array without data copying. - java.util.Arrays.asList(result: _*) - } - - case (st: StructType, RECORD) => - val structConverter = newStructConverter(st, avroType, catalystPath, avroPath) - val numFields = st.length - (getter, ordinal) => structConverter(getter.getStruct(ordinal, numFields)) - - //////////////////////////////////////////////////////////////////////////////////////////// - // Following section is amended to the original (Spark's) implementation - // >>> BEGINS - //////////////////////////////////////////////////////////////////////////////////////////// - - case (st: StructType, UNION) => - val unionConverter = newUnionConverter(st, avroType, catalystPath, avroPath) - val numFields = st.length - (getter, ordinal) => unionConverter(getter.getStruct(ordinal, numFields)) - - //////////////////////////////////////////////////////////////////////////////////////////// - // <<< ENDS - //////////////////////////////////////////////////////////////////////////////////////////// - - case (MapType(kt, vt, valueContainsNull), MAP) if kt == StringType => - val valueConverter = newConverter( - vt, resolveNullableType(avroType.getValueType, valueContainsNull), - catalystPath :+ "value", avroPath :+ "value") - (getter, ordinal) => - val mapData = getter.getMap(ordinal) - val len = mapData.numElements() - val result = new java.util.HashMap[String, Any](len) - val keyArray = mapData.keyArray() - val valueArray = mapData.valueArray() - var i = 0 - while (i < len) { - val key = keyArray.getUTF8String(i).toString - if (valueContainsNull && valueArray.isNullAt(i)) { - result.put(key, null) - } else { - result.put(key, valueConverter(valueArray, i)) - } - i += 1 - } - result - - case (_: YearMonthIntervalType, INT) => - (getter, ordinal) => getter.getInt(ordinal) - - case (_: DayTimeIntervalType, LONG) => - (getter, ordinal) => getter.getLong(ordinal) - - case _ => - throw new IncompatibleSchemaException(errorPrefix + - s"schema is incompatible (sqlType = ${catalystType.sql}, avroType = $avroType)") - } - } - - private def newStructConverter(catalystStruct: StructType, - avroStruct: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): InternalRow => Record = { - - val avroSchemaHelper = new AvroUtils.AvroSchemaHelper( - avroStruct, catalystStruct, avroPath, catalystPath, positionalFieldMatch) - - avroSchemaHelper.validateNoExtraCatalystFields(ignoreNullable = false) - avroSchemaHelper.validateNoExtraRequiredAvroFields() - - val (avroIndices, fieldConverters) = avroSchemaHelper.matchedFields.map { - case AvroMatchedField(catalystField, _, avroField) => - val converter = newConverter(catalystField.dataType, - resolveNullableType(avroField.schema(), catalystField.nullable), - catalystPath :+ catalystField.name, avroPath :+ avroField.name) - (avroField.pos(), converter) - }.toArray.unzip - - val numFields = catalystStruct.length - row: InternalRow => - val result = new Record(avroStruct) - var i = 0 - while (i < numFields) { - if (row.isNullAt(i)) { - result.put(avroIndices(i), null) - } else { - result.put(avroIndices(i), fieldConverters(i).apply(row, i)) - } - i += 1 - } - result - } - - //////////////////////////////////////////////////////////////////////////////////////////// - // Following section is amended to the original (Spark's) implementation - // >>> BEGINS - //////////////////////////////////////////////////////////////////////////////////////////// - - private def newUnionConverter(catalystStruct: StructType, - avroUnion: Schema, - catalystPath: Seq[String], - avroPath: Seq[String]): InternalRow => Any = { - if (avroUnion.getType != UNION || !canMapUnion(catalystStruct, avroUnion)) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst type $catalystStruct to " + - s"Avro type $avroUnion.") - } - val nullable = avroUnion.getTypes.size() > 0 && avroUnion.getTypes.get(0).getType == Type.NULL - val avroInnerTypes = if (nullable) { - avroUnion.getTypes.asScala.tail - } else { - avroUnion.getTypes.asScala - } - val fieldConverters = catalystStruct.zip(avroInnerTypes).map { - case (f1, f2) => newConverter(f1.dataType, f2, catalystPath, avroPath) - } - val numFields = catalystStruct.length - (row: InternalRow) => - var i = 0 - var result: Any = null - while (i < numFields) { - if (!row.isNullAt(i)) { - if (result != null) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst record $catalystStruct to " + - s"Avro union $avroUnion. Record has more than one optional values set") - } - result = fieldConverters(i).apply(row, i) - } - i += 1 - } - if (!nullable && result == null) { - throw new IncompatibleSchemaException(s"Cannot convert Catalyst record $catalystStruct to " + - s"Avro union $avroUnion. Record has no values set, while should have exactly one") - } - result - } - - private def canMapUnion(catalystStruct: StructType, avroStruct: Schema): Boolean = { - (avroStruct.getTypes.size() > 0 && - avroStruct.getTypes.get(0).getType == Type.NULL && - avroStruct.getTypes.size() - 1 == catalystStruct.length) || avroStruct.getTypes.size() == catalystStruct.length - } - - //////////////////////////////////////////////////////////////////////////////////////////// - // <<< ENDS - //////////////////////////////////////////////////////////////////////////////////////////// - - - /** - * Resolve a possibly nullable Avro Type. - * - * An Avro type is nullable when it is a [[UNION]] of two types: one null type and another - * non-null type. This method will check the nullability of the input Avro type and return the - * non-null type within when it is nullable. Otherwise it will return the input Avro type - * unchanged. It will throw an [[UnsupportedAvroTypeException]] when the input Avro type is an - * unsupported nullable type. - * - * It will also log a warning message if the nullability for Avro and catalyst types are - * different. - */ - private def resolveNullableType(avroType: Schema, nullable: Boolean): Schema = { - val (avroNullable, resolvedAvroType) = resolveAvroType(avroType) - warnNullabilityDifference(avroNullable, nullable) - resolvedAvroType - } - - /** - * Check the nullability of the input Avro type and resolve it when it is nullable. The first - * return value is a [[Boolean]] indicating if the input Avro type is nullable. The second - * return value is the possibly resolved type. - */ - private def resolveAvroType(avroType: Schema): (Boolean, Schema) = { - if (avroType.getType == Type.UNION) { - val fields = avroType.getTypes.asScala - val actualType = fields.filter(_.getType != Type.NULL) - if (fields.length == 2 && actualType.length == 1) { - (true, actualType.head) - } else { - // This is just a normal union, not used to designate nullability - (false, avroType) - } - } else { - (false, avroType) - } - } - - /** - * log a warning message if the nullability for Avro and catalyst types are different. - */ - private def warnNullabilityDifference(avroNullable: Boolean, catalystNullable: Boolean): Unit = { - if (avroNullable && !catalystNullable) { - logWarning("Writing Avro files with nullable Avro schema and non-nullable catalyst schema.") - } - if (!avroNullable && catalystNullable) { - logWarning("Writing Avro files with non-nullable Avro schema and nullable catalyst " + - "schema will throw runtime exception if there is a record with null value.") - } - } -} - -object AvroSerializer { - - // NOTE: Following methods have been renamed in Spark 3.2.1 [1] making [[AvroSerializer]] implementation - // (which relies on it) be only compatible with the exact same version of [[DataSourceUtils]]. - // To make sure this implementation is compatible w/ all Spark versions w/in Spark 3.2.x branch, - // we're preemptively cloned those methods to make sure Hudi is compatible w/ Spark 3.2.0 as well as - // w/ Spark >= 3.2.1 - // - // [1] https://github.com/apache/spark/pull/34978 - - def createDateRebaseFuncInWrite(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Int => Int = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => days: Int => - if (days < RebaseDateTime.lastSwitchGregorianDay) { - throw DataSourceUtils.newRebaseExceptionInWrite(format) - } - days - case LegacyBehaviorPolicy.LEGACY => RebaseDateTime.rebaseGregorianToJulianDays - case LegacyBehaviorPolicy.CORRECTED => identity[Int] - } - - def createTimestampRebaseFuncInWrite(rebaseMode: LegacyBehaviorPolicy.Value, - format: String): Long => Long = rebaseMode match { - case LegacyBehaviorPolicy.EXCEPTION => micros: Long => - if (micros < RebaseDateTime.lastSwitchGregorianTs) { - throw DataSourceUtils.newRebaseExceptionInWrite(format) - } - micros - case LegacyBehaviorPolicy.LEGACY => - val timeZone = SQLConf.get.sessionLocalTimeZone - RebaseDateTime.rebaseGregorianToJulianMicros(TimeZone.getTimeZone(timeZone), _) - case LegacyBehaviorPolicy.CORRECTED => identity[Long] - } - -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala deleted file mode 100644 index 8aae6b442f8a1..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala +++ /dev/null @@ -1,227 +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.spark.sql.avro - -import org.apache.avro.Schema -import org.apache.avro.file. FileReader -import org.apache.avro.generic.GenericRecord -import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types._ - -import java.util.Locale - -import scala.collection.JavaConverters._ - -/** - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] object AvroUtils extends Logging { - - def supportsDataType(dataType: DataType): Boolean = dataType match { - case _: AtomicType => true - - case st: StructType => st.forall { f => supportsDataType(f.dataType) } - - case ArrayType(elementType, _) => supportsDataType(elementType) - - case MapType(keyType, valueType, _) => - supportsDataType(keyType) && supportsDataType(valueType) - - case udt: UserDefinedType[_] => supportsDataType(udt.sqlType) - - case _: NullType => true - - case _ => false - } - - // The trait provides iterator-like interface for reading records from an Avro file, - // deserializing and returning them as internal rows. - trait RowReader { - protected val fileReader: FileReader[GenericRecord] - protected val deserializer: AvroDeserializer - protected val stopPosition: Long - - private[this] var completed = false - private[this] var currentRow: Option[InternalRow] = None - - def hasNextRow: Boolean = { - while (!completed && currentRow.isEmpty) { - val r = fileReader.hasNext && !fileReader.pastSync(stopPosition) - if (!r) { - fileReader.close() - completed = true - currentRow = None - } else { - val record = fileReader.next() - // the row must be deserialized in hasNextRow, because AvroDeserializer#deserialize - // potentially filters rows - currentRow = deserializer.deserialize(record).asInstanceOf[Option[InternalRow]] - } - } - currentRow.isDefined - } - - def nextRow: InternalRow = { - if (currentRow.isEmpty) { - hasNextRow - } - val returnRow = currentRow - currentRow = None // free up hasNextRow to consume more Avro records, if not exhausted - returnRow.getOrElse { - throw new NoSuchElementException("next on empty iterator") - } - } - } - - /** Wrapper for a pair of matched fields, one Catalyst and one corresponding Avro field. */ - private[sql] case class AvroMatchedField( - catalystField: StructField, - catalystPosition: Int, - avroField: Schema.Field) - - /** - * Helper class to perform field lookup/matching on Avro schemas. - * - * This will match `avroSchema` against `catalystSchema`, attempting to find a matching field in - * the Avro schema for each field in the Catalyst schema and vice-versa, respecting settings for - * case sensitivity. The match results can be accessed using the getter methods. - * - * @param avroSchema The schema in which to search for fields. Must be of type RECORD. - * @param catalystSchema The Catalyst schema to use for matching. - * @param avroPath The seq of parent field names leading to `avroSchema`. - * @param catalystPath The seq of parent field names leading to `catalystSchema`. - * @param positionalFieldMatch If true, perform field matching in a positional fashion - * (structural comparison between schemas, ignoring names); - * otherwise, perform field matching using field names. - */ - class AvroSchemaHelper( - avroSchema: Schema, - catalystSchema: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - positionalFieldMatch: Boolean) { - if (avroSchema.getType != Schema.Type.RECORD) { - throw new IncompatibleSchemaException( - s"Attempting to treat ${avroSchema.getName} as a RECORD, but it was: ${avroSchema.getType}") - } - - private[this] val avroFieldArray = avroSchema.getFields.asScala.toArray - private[this] val fieldMap = avroSchema.getFields.asScala - .groupBy(_.name.toLowerCase(Locale.ROOT)) - .mapValues(_.toSeq) // toSeq needed for scala 2.13 - - /** The fields which have matching equivalents in both Avro and Catalyst schemas. */ - val matchedFields: Seq[AvroMatchedField] = catalystSchema.zipWithIndex.flatMap { - case (sqlField, sqlPos) => - getAvroField(sqlField.name, sqlPos).map(AvroMatchedField(sqlField, sqlPos, _)) - } - - /** - * Validate that there are no Catalyst fields which don't have a matching Avro field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. If `ignoreNullable` is false, - * consider nullable Catalyst fields to be eligible to be an extra field; otherwise, - * ignore nullable Catalyst fields when checking for extras. - */ - def validateNoExtraCatalystFields(ignoreNullable: Boolean): Unit = - catalystSchema.zipWithIndex.foreach { case (sqlField, sqlPos) => - if (getAvroField(sqlField.name, sqlPos).isEmpty && - (!ignoreNullable || !sqlField.nullable)) { - if (positionalFieldMatch) { - throw new IncompatibleSchemaException("Cannot find field at position " + - s"$sqlPos of ${toFieldStr(avroPath)} from Avro schema (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Cannot find ${toFieldStr(catalystPath :+ sqlField.name)} in Avro schema") - } - } - } - - /** - * Validate that there are no Avro fields which don't have a matching Catalyst field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. Only required (non-nullable) - * fields are checked; nullable fields are ignored. - */ - def validateNoExtraRequiredAvroFields(): Unit = { - val extraFields = avroFieldArray.toSet -- matchedFields.map(_.avroField) - extraFields.filterNot(isNullable).foreach { extraField => - if (positionalFieldMatch) { - throw new IncompatibleSchemaException(s"Found field '${extraField.name()}' at position " + - s"${extraField.pos()} of ${toFieldStr(avroPath)} from Avro schema but there is no " + - s"match in the SQL schema at ${toFieldStr(catalystPath)} (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Found ${toFieldStr(avroPath :+ extraField.name())} in Avro schema but there is no " + - "match in the SQL schema") - } - } - } - - /** - * Extract a single field from the contained avro schema which has the desired field name, - * performing the matching with proper case sensitivity according to SQLConf.resolver. - * - * @param name The name of the field to search for. - * @return `Some(match)` if a matching Avro field is found, otherwise `None`. - */ - private[avro] def getFieldByName(name: String): Option[Schema.Field] = { - - // get candidates, ignoring case of field name - val candidates = fieldMap.getOrElse(name.toLowerCase(Locale.ROOT), Seq.empty) - - // search candidates, taking into account case sensitivity settings - candidates.filter(f => SQLConf.get.resolver(f.name(), name)) match { - case Seq(avroField) => Some(avroField) - case Seq() => None - case matches => throw new IncompatibleSchemaException(s"Searching for '$name' in Avro " + - s"schema at ${toFieldStr(avroPath)} gave ${matches.size} matches. Candidates: " + - matches.map(_.name()).mkString("[", ", ", "]") - ) - } - } - - /** Get the Avro field corresponding to the provided Catalyst field name/position, if any. */ - def getAvroField(fieldName: String, catalystPos: Int): Option[Schema.Field] = { - if (positionalFieldMatch) { - avroFieldArray.lift(catalystPos) - } else { - getFieldByName(fieldName) - } - } - } - - /** - * Convert a sequence of hierarchical field names (like `Seq(foo, bar)`) into a human-readable - * string representing the field, like "field 'foo.bar'". If `names` is empty, the string - * "top-level record" is returned. - */ - private[avro] def toFieldStr(names: Seq[String]): String = names match { - case Seq() => "top-level record" - case n => s"field '${n.mkString(".")}'" - } - - /** Return true iff `avroField` is nullable, i.e. `UNION` type and has `NULL` as an option. */ - private[avro] def isNullable(avroField: Schema.Field): Boolean = - avroField.schema().getType == Schema.Type.UNION && - avroField.schema().getTypes.asScala.exists(_.getType == Schema.Type.NULL) -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark35NestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark35NestedSchemaPruning.scala deleted file mode 100644 index 24d07b085af92..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark35NestedSchemaPruning.scala +++ /dev/null @@ -1,61 +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.spark.sql.execution.datasources - -import org.apache.hudi.HoodieBaseRelation - -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.catalyst.planning.PhysicalOperation -import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan -import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.types.StructType - -class Spark35NestedSchemaPruning extends BaseHoodieNestedSchemaPruning { - - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], - requiredSchema: StructType): Seq[AttributeReference] = { - // We need to replace the expression ids of the pruned relation output attributes - // with the expression ids of the original relation output attributes so that - // references to the original relation's output are not broken - val outputIdMap = output.map(att => (att.name, att.exprId)).toMap - DataTypeUtils.toAttributes(requiredSchema) - .map { - case att if outputIdMap.contains(att.name) => - att.withExprId(outputIdMap(att.name)) - case att => att - } - } - - override protected def apply0(plan: LogicalPlan): LogicalPlan = - plan transformDown { - case op @ PhysicalOperation(projects, filters, - // NOTE: This is modified to accommodate for Hudi's custom relations, given that original - // [[NestedSchemaPruning]] rule is tightly coupled w/ [[HadoopFsRelation]] - // TODO generalize to any file-based relation - l @ LogicalRelation(relation: HoodieBaseRelation, _, _, _)) - if relation.canPruneRelationSchema => - - prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, - prunedDataSchema => { - val prunedRelation = - relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) - buildPrunedRelation(l, prunedRelation) - }).getOrElse(op) - } -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark35OrcReader.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark35OrcReader.scala deleted file mode 100644 index badd76c6a8851..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark35OrcReader.scala +++ /dev/null @@ -1,92 +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.spark.sql.execution.datasources.orc - -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.Path -import org.apache.spark.memory.MemoryMode -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes -import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile} -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType - -class Spark35OrcReader(enableVectorizedReader: Boolean, - memoryMode: MemoryMode, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean, - capacity: Int) extends SparkOrcReaderBase(enableVectorizedReader, dataSchema, orcFilterPushDown, isCaseSensitive) { - - override def partitionedFileToPath(file: PartitionedFile): Path = { - file.toPath - } - - override def buildReader(): OrcColumnarBatchReader = { - new OrcColumnarBatchReader(capacity, memoryMode) - } - - override def structTypeToAttributes(schema: StructType): Seq[Attribute] = { - toAttributes(schema) - } -} - -object Spark35OrcReader { - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @return ORC file reader - */ - def build(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): Spark35OrcReader = { - //set hadoopconf - hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) - hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) - - val memoryMode = if (sqlConf.offHeapColumnVectorEnabled) { - MemoryMode.OFF_HEAP - } else { - MemoryMode.ON_HEAP - } - - val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && - options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, - throw new IllegalArgumentException( - "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + - "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) - .equals("true") - - new Spark35OrcReader( - enableVectorizedReader = enableVectorizedReader && vectorized, - memoryMode = memoryMode, - isCaseSensitive = sqlConf.caseSensitiveAnalysis, - capacity = sqlConf.orcVectorizedReaderBatchSize, - orcFilterPushDown = sqlConf.orcFilterPushDown, - dataSchema = dataSchema) - } -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35DataSourceUtils.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35DataSourceUtils.scala deleted file mode 100644 index 9e3c63529b481..0000000000000 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35DataSourceUtils.scala +++ /dev/null @@ -1,76 +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.spark.sql.execution.datasources.parquet - -import org.apache.spark.sql.SPARK_VERSION_METADATA_KEY -import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf} -import org.apache.spark.util.Utils - -object Spark35DataSourceUtils { - - /** - * NOTE: This method was copied from [[Spark32PlusDataSourceUtils]], and is required to maintain runtime - * compatibility against Spark 3.5.0 - */ - // scalastyle:off - def int96RebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 3.0 and earlier follow the legacy hybrid calendar and we need to - // rebase the INT96 timestamp values. - // Files written by Spark 3.1 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.1.0" || lookupFileMeta("org.apache.spark.legacyINT96") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def datetimeRebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 2.4 and earlier follow the legacy hybrid calendar and we need to - // rebase the datetime values. - // Files written by Spark 3.0 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.0.0" || lookupFileMeta("org.apache.spark.legacyDateTime") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - -} diff --git a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35LegacyHoodieParquetFileFormat.scala b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35LegacyHoodieParquetFileFormat.scala index 6e062b1319bfb..b6887c8814960 100644 --- a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35LegacyHoodieParquetFileFormat.scala +++ b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35LegacyHoodieParquetFileFormat.scala @@ -17,55 +17,22 @@ package org.apache.spark.sql.execution.datasources.parquet -import org.apache.hudi.client.utils.SparkInternalSchemaConverter -import org.apache.hudi.common.fs.FSUtils -import org.apache.hudi.common.table.timeline.TimelineLayout -import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion -import org.apache.hudi.common.util.InternalSchemaCache -import org.apache.hudi.common.util.StringUtils.isNullOrEmpty -import org.apache.hudi.common.util.collection.Pair -import org.apache.hudi.hadoop.fs.HadoopFSUtils -import org.apache.hudi.internal.schema.InternalSchema -import org.apache.hudi.internal.schema.action.InternalSchemaMerger -import org.apache.hudi.internal.schema.utils.{InternalSchemaUtils, SerDeHelper} -import org.apache.hudi.storage.HoodieStorageUtils - import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.mapred.FileSplit -import org.apache.hadoop.mapreduce.{JobID, TaskAttemptID, TaskID, TaskType} -import org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl -import org.apache.parquet.filter2.compat.FilterCompat -import org.apache.parquet.filter2.predicate.FilterApi -import org.apache.parquet.format.converter.ParquetMetadataConverter.SKIP_ROW_GROUPS -import org.apache.parquet.hadoop.{ParquetInputFormat, ParquetRecordReader} -import org.apache.spark.TaskContext +import org.apache.hadoop.fs.Path import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{Cast, JoinedRow} -import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection +import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.catalyst.util.DateTimeUtils import org.apache.spark.sql.execution.WholeStageCodegenExec -import org.apache.spark.sql.execution.datasources.{DataSourceUtils, PartitionedFile, RecordReaderIterator} -import org.apache.spark.sql.execution.datasources.parquet.Spark35LegacyHoodieParquetFileFormat._ +import org.apache.spark.sql.execution.datasources.PartitionedFile import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.sources._ -import org.apache.spark.sql.types.{AtomicType, DataType, StructField, StructType} -import org.apache.spark.util.SerializableConfiguration - -import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` +import org.apache.spark.sql.types.StructType /** - * This class is an extension of [[ParquetFileFormat]] overriding Spark-specific behavior - * that's not possible to customize in any other way - * - * NOTE: This is a version of [[AvroDeserializer]] impl from Spark 3.2.1 w/ w/ the following changes applied to it: - *
      - *
    1. Avoiding appending partition values to the rows read from the data file
    2. - *
    3. Schema on-read
    4. - *
    + * Spark 3.5 concrete implementation of [[Spark3LegacyHoodieParquetFileFormat]]. It only overrides + * the version-specific hooks; the shared reader logic lives in the base class. */ -class Spark35LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValues: Boolean) extends ParquetFileFormat { +class Spark35LegacyHoodieParquetFileFormat(appendPartitionValues: Boolean) + extends Spark3LegacyHoodieParquetFileFormat(appendPartitionValues) { def supportsColumnar(sparkSession: SparkSession, schema: StructType): Boolean = { val conf = sparkSession.sessionState.conf @@ -76,39 +43,25 @@ class Spark35LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValu supportBatch(sparkSession, schema) } - override def buildReaderWithPartitionValues(sparkSession: SparkSession, - dataSchema: StructType, - partitionSchema: StructType, - requiredSchema: StructType, - filters: Seq[Filter], - options: Map[String, String], - hadoopConf: Configuration): PartitionedFile => Iterator[InternalRow] = { - hadoopConf.set(ParquetInputFormat.READ_SUPPORT_CLASS, classOf[ParquetReadSupport].getName) - hadoopConf.set( - ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, - requiredSchema.json) - hadoopConf.set( - ParquetWriteSupport.SPARK_ROW_SCHEMA, - requiredSchema.json) - hadoopConf.set( - SQLConf.SESSION_LOCAL_TIMEZONE.key, - sparkSession.sessionState.conf.sessionLocalTimeZone) - hadoopConf.setBoolean( - SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, - sparkSession.sessionState.conf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean( - SQLConf.CASE_SENSITIVE.key, - sparkSession.sessionState.conf.caseSensitiveAnalysis) + override protected def toAttributes(structType: StructType): Seq[Attribute] = + DataTypeUtils.toAttributes(structType) - ParquetWriteSupport.setSchema(requiredSchema, hadoopConf) + override protected def getFilePath(file: PartitionedFile): Path = + file.filePath.toPath - // Sets flags for `ParquetToSparkSchemaConverter` - hadoopConf.setBoolean( - SQLConf.PARQUET_BINARY_AS_STRING.key, - sparkSession.sessionState.conf.isParquetBinaryAsString) - hadoopConf.setBoolean( - SQLConf.PARQUET_INT96_AS_TIMESTAMP.key, - sparkSession.sessionState.conf.isParquetINT96AsTimestamp) + override protected def isVectorizedReaderEnabled(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + supportBatch(sparkSession, resultSchema) + + override protected def getPushDownStringPredicate(sqlConf: SQLConf): Boolean = + sqlConf.parquetFilterPushDownStringPredicate + + override protected def getReturningBatch(sparkSession: SparkSession, + resultSchema: StructType): Boolean = + sparkSession.sessionState.conf.parquetVectorizedReaderEnabled && + supportsColumnar(sparkSession, resultSchema).toString.equals("true") + + override protected def setParquetTimeConfs(hadoopConf: Configuration, sparkSession: SparkSession): Unit = { // Using string value of this conf to preserve compatibility across spark versions. hadoopConf.setBoolean( SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key, @@ -118,347 +71,5 @@ class Spark35LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValu ) hadoopConf.setBoolean(SQLConf.PARQUET_INFER_TIMESTAMP_NTZ_ENABLED.key, sparkSession.sessionState.conf.parquetInferTimestampNTZEnabled) hadoopConf.setBoolean(SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key, sparkSession.sessionState.conf.legacyParquetNanosAsLong) - val internalSchemaStr = hadoopConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // For Spark DataSource v1, there's no Physical Plan projection/schema pruning w/in Spark itself, - // therefore it's safe to do schema projection here - if (!isNullOrEmpty(internalSchemaStr)) { - val prunedInternalSchemaStr = - pruneInternalSchema(internalSchemaStr, requiredSchema) - hadoopConf.set(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA, prunedInternalSchemaStr) - } - - val broadcastedHadoopConf = - sparkSession.sparkContext.broadcast(new SerializableConfiguration(hadoopConf)) - - // TODO: if you move this into the closure it reverts to the default values. - // If true, enable using the custom RecordReader for parquet. This only works for - // a subset of the types (no complex types). - val resultSchema = StructType(partitionSchema.fields ++ requiredSchema.fields) - val sqlConf = sparkSession.sessionState.conf - val enableOffHeapColumnVector = sqlConf.offHeapColumnVectorEnabled - val enableVectorizedReader: Boolean = supportBatch(sparkSession, resultSchema) - val enableRecordFilter: Boolean = sqlConf.parquetRecordFilterEnabled - val timestampConversion: Boolean = sqlConf.isParquetINT96TimestampConversion - val capacity = sqlConf.parquetVectorizedReaderBatchSize - val enableParquetFilterPushDown: Boolean = sqlConf.parquetFilterPushDown - val pushDownDate = sqlConf.parquetFilterPushDownDate - val pushDownTimestamp = sqlConf.parquetFilterPushDownTimestamp - val pushDownDecimal = sqlConf.parquetFilterPushDownDecimal - val pushDownStringStartWith = sqlConf.parquetFilterPushDownStringPredicate - val pushDownInFilterThreshold = sqlConf.parquetFilterPushDownInFilterThreshold - val isCaseSensitive = sqlConf.caseSensitiveAnalysis - val parquetOptions = new ParquetOptions(options, sparkSession.sessionState.conf) - val datetimeRebaseModeInRead = parquetOptions.datetimeRebaseModeInRead - val int96RebaseModeInRead = parquetOptions.int96RebaseModeInRead - val timeZoneId = Option(sqlConf.sessionLocalTimeZone) - // Should always be set by FileSourceScanExec creating this. - // Check conf before checking option, to allow working around an issue by changing conf. - val returningBatch = sparkSession.sessionState.conf.parquetVectorizedReaderEnabled && - supportsColumnar(sparkSession, resultSchema).toString.equals("true") - - - (file: PartitionedFile) => { - assert(!shouldAppendPartitionValues || file.partitionValues.numFields == partitionSchema.size) - - val filePath = file.filePath.toPath - val split = new FileSplit(filePath, file.start, file.length, Array.empty[String]) - - val sharedConf = broadcastedHadoopConf.value.value - - // Fetch internal schema - val internalSchemaStr = sharedConf.get(SparkInternalSchemaConverter.HOODIE_QUERY_SCHEMA) - // Internal schema has to be pruned at this point - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - - var shouldUseInternalSchema = !isNullOrEmpty(internalSchemaStr) && querySchemaOption.isPresent - - val tablePath = sharedConf.get(SparkInternalSchemaConverter.HOODIE_TABLE_PATH) - val fileSchema = if (shouldUseInternalSchema) { - val commitInstantTime = FSUtils.getCommitTime(filePath.getName).toLong; - val validCommits = sharedConf.get(SparkInternalSchemaConverter.HOODIE_VALID_COMMITS_LIST) - val storage = HoodieStorageUtils.getStorage(tablePath, HadoopFSUtils.getStorageConf(sharedConf)) - //TODO: HARDCODED TIMELINE OBJECT - val layout = TimelineLayout.fromVersion(TimelineLayoutVersion.CURR_LAYOUT_VERSION) - InternalSchemaCache.getInternalSchemaByVersionId( - commitInstantTime, tablePath, storage, if (validCommits == null) "" else validCommits, - layout) - } else { - null - } - - lazy val footerFileMetaData = - ParquetFooterReader.readFooter(sharedConf, filePath, SKIP_ROW_GROUPS).getFileMetaData - // Try to push down filters when filter push-down is enabled. - val pushed = if (enableParquetFilterPushDown) { - val parquetSchema = footerFileMetaData.getSchema - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val parquetFilters = new ParquetFilters( - parquetSchema, - pushDownDate, - pushDownTimestamp, - pushDownDecimal, - pushDownStringStartWith, - pushDownInFilterThreshold, - isCaseSensitive, - datetimeRebaseSpec) - filters.map(rebuildFilterFromParquet(_, fileSchema, querySchemaOption.orElse(null))) - // Collects all converted Parquet filter predicates. Notice that not all predicates can be - // converted (`ParquetFilters.createFilter` returns an `Option`). That's why a `flatMap` - // is used here. - .flatMap(parquetFilters.createFilter) - .reduceOption(FilterApi.and) - } else { - None - } - - // PARQUET_INT96_TIMESTAMP_CONVERSION says to apply timezone conversions to int96 timestamps' - // *only* if the file was created by something other than "parquet-mr", so check the actual - // writer here for this file. We have to do this per-file, as each file in the table may - // have different writers. - // Define isCreatedByParquetMr as function to avoid unnecessary parquet footer reads. - def isCreatedByParquetMr: Boolean = - footerFileMetaData.getCreatedBy().startsWith("parquet-mr") - - val convertTz = - if (timestampConversion && !isCreatedByParquetMr) { - Some(DateTimeUtils.getZoneId(sharedConf.get(SQLConf.SESSION_LOCAL_TIMEZONE.key))) - } else { - None - } - - val attemptId = new TaskAttemptID(new TaskID(new JobID(), TaskType.MAP, 0), 0) - - // Clone new conf - val hadoopAttemptConf = new Configuration(broadcastedHadoopConf.value.value) - val typeChangeInfos: java.util.Map[Integer, Pair[DataType, DataType]] = if (shouldUseInternalSchema) { - val mergedInternalSchema = new InternalSchemaMerger(fileSchema, querySchemaOption.get(), true, true).mergeSchema() - val mergedSchema = SparkInternalSchemaConverter.constructSparkSchemaFromInternalSchema(mergedInternalSchema) - - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, mergedSchema.json) - - SparkInternalSchemaConverter.collectTypeChangedCols(querySchemaOption.get(), mergedInternalSchema) - } else { - val (implicitTypeChangeInfo, sparkRequestSchema) = HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo(hadoopAttemptConf, footerFileMetaData, requiredSchema) - if (!implicitTypeChangeInfo.isEmpty) { - shouldUseInternalSchema = true - hadoopAttemptConf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, sparkRequestSchema.json) - } - implicitTypeChangeInfo - } - - if (enableVectorizedReader && shouldUseInternalSchema && - !typeChangeInfos.values().forall(_.getLeft.isInstanceOf[AtomicType])) { - throw new IllegalArgumentException( - "Nested types with type changes(implicit or explicit) cannot be read in vectorized mode. " + - "To workaround this issue, set spark.sql.parquet.enableVectorizedReader=false.") - } - - val hadoopAttemptContext = - new TaskAttemptContextImpl(hadoopAttemptConf, attemptId) - - // Try to push down filters when filter push-down is enabled. - // Notice: This push-down is RowGroups level, not individual records. - if (pushed.isDefined) { - ParquetInputFormat.setFilterPredicate(hadoopAttemptContext.getConfiguration, pushed.get) - } - val taskContext = Option(TaskContext.get()) - if (enableVectorizedReader) { - val vectorizedReader = - if (shouldUseInternalSchema) { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new HoodieVectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity, - typeChangeInfos) - } else { - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - new VectorizedParquetRecordReader( - convertTz.orNull, - datetimeRebaseSpec.mode.toString, - datetimeRebaseSpec.timeZone, - int96RebaseSpec.mode.toString, - int96RebaseSpec.timeZone, - enableOffHeapColumnVector && taskContext.isDefined, - capacity) - } - - // SPARK-37089: We cannot register a task completion listener to close this iterator here - // because downstream exec nodes have already registered their listeners. Since listeners - // are executed in reverse order of registration, a listener registered here would close the - // iterator while downstream exec nodes are still running. When off-heap column vectors are - // enabled, this can cause a use-after-free bug leading to a segfault. - // - // Instead, we use FileScanRDD's task completion listener to close this iterator. - val iter = new RecordReaderIterator(vectorizedReader) - try { - vectorizedReader.initialize(split, hadoopAttemptContext) - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (shouldAppendPartitionValues) { - logDebug(s"Appending $partitionSchema ${file.partitionValues}") - vectorizedReader.initBatch(partitionSchema, file.partitionValues) - } else { - vectorizedReader.initBatch(StructType(Nil), InternalRow.empty) - } - - if (returningBatch) { - vectorizedReader.enableReturningBatches() - } - - // UnsafeRowParquetRecordReader appends the columns internally to avoid another copy. - iter.asInstanceOf[Iterator[InternalRow]] - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } else { - logDebug(s"Falling back to parquet-mr") - val int96RebaseSpec = - DataSourceUtils.int96RebaseSpec(footerFileMetaData.getKeyValueMetaData.get, int96RebaseModeInRead) - val datetimeRebaseSpec = - DataSourceUtils.datetimeRebaseSpec(footerFileMetaData.getKeyValueMetaData.get, datetimeRebaseModeInRead) - val readSupport = new HoodieParquetReadSupport( - convertTz, - enableVectorizedReader = false, - enableTimestampFieldRepair = true, - datetimeRebaseSpec, - int96RebaseSpec) - - val reader = if (pushed.isDefined && enableRecordFilter) { - val parquetFilter = FilterCompat.get(pushed.get, null) - new ParquetRecordReader[InternalRow](readSupport, parquetFilter) - } else { - new ParquetRecordReader[InternalRow](readSupport) - } - val iter = new RecordReaderIterator[InternalRow](reader) - try { - reader.initialize(split, hadoopAttemptContext) - - val fullSchema = DataTypeUtils.toAttributes(requiredSchema) ++ DataTypeUtils.toAttributes(partitionSchema) - val unsafeProjection = if (typeChangeInfos.isEmpty) { - GenerateUnsafeProjection.generate(fullSchema, fullSchema) - } else { - // find type changed. - val newSchema = new StructType(requiredSchema.fields.zipWithIndex.map { case (f, i) => - if (typeChangeInfos.containsKey(i)) { - StructField(f.name, typeChangeInfos.get(i).getRight, f.nullable, f.metadata) - } else f - }) - val newFullSchema = DataTypeUtils.toAttributes(newSchema) ++ DataTypeUtils.toAttributes(partitionSchema) - val castSchema = newFullSchema.zipWithIndex.map { case (attr, i) => - if (typeChangeInfos.containsKey(i)) { - val srcType = typeChangeInfos.get(i).getRight - val dstType = typeChangeInfos.get(i).getLeft - val needTimeZone = Cast.needsTimeZone(srcType, dstType) - Cast(attr, dstType, if (needTimeZone) timeZoneId else None) - } else attr - } - GenerateUnsafeProjection.generate(castSchema, newFullSchema) - } - - // NOTE: We're making appending of the partitioned values to the rows read from the - // data file configurable - if (!shouldAppendPartitionValues || partitionSchema.length == 0) { - // There is no partition columns - iter.map(unsafeProjection) - } else { - val joinedRow = new JoinedRow() - iter.map(d => unsafeProjection(joinedRow(d, file.partitionValues))) - } - } catch { - case e: Throwable => - // SPARK-23457: In case there is an exception in initialization, close the iterator to - // avoid leaking resources. - iter.close() - throw e - } - } - } - } -} - -object Spark35LegacyHoodieParquetFileFormat { - - def pruneInternalSchema(internalSchemaStr: String, requiredSchema: StructType): String = { - val querySchemaOption = SerDeHelper.fromJson(internalSchemaStr) - if (querySchemaOption.isPresent && requiredSchema.nonEmpty) { - val prunedSchema = SparkInternalSchemaConverter.convertAndPruneStructTypeToInternalSchema(requiredSchema, querySchemaOption.get()) - SerDeHelper.toJson(prunedSchema) - } else { - internalSchemaStr - } - } - - private def rebuildFilterFromParquet(oldFilter: Filter, fileSchema: InternalSchema, querySchema: InternalSchema): Filter = { - if (fileSchema == null || querySchema == null) { - oldFilter - } else { - oldFilter match { - case eq: EqualTo => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eq.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eq.copy(attribute = newAttribute) - case eqs: EqualNullSafe => - val newAttribute = InternalSchemaUtils.reBuildFilterName(eqs.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else eqs.copy(attribute = newAttribute) - case gt: GreaterThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gt.copy(attribute = newAttribute) - case gtr: GreaterThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(gtr.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else gtr.copy(attribute = newAttribute) - case lt: LessThan => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lt.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lt.copy(attribute = newAttribute) - case lte: LessThanOrEqual => - val newAttribute = InternalSchemaUtils.reBuildFilterName(lte.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else lte.copy(attribute = newAttribute) - case i: In => - val newAttribute = InternalSchemaUtils.reBuildFilterName(i.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else i.copy(attribute = newAttribute) - case isn: IsNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isn.copy(attribute = newAttribute) - case isnn: IsNotNull => - val newAttribute = InternalSchemaUtils.reBuildFilterName(isnn.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else isnn.copy(attribute = newAttribute) - case And(left, right) => - And(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Or(left, right) => - Or(rebuildFilterFromParquet(left, fileSchema, querySchema), rebuildFilterFromParquet(right, fileSchema, querySchema)) - case Not(child) => - Not(rebuildFilterFromParquet(child, fileSchema, querySchema)) - case ssw: StringStartsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ssw.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ssw.copy(attribute = newAttribute) - case ses: StringEndsWith => - val newAttribute = InternalSchemaUtils.reBuildFilterName(ses.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else ses.copy(attribute = newAttribute) - case sc: StringContains => - val newAttribute = InternalSchemaUtils.reBuildFilterName(sc.attribute, fileSchema, querySchema) - if (newAttribute.isEmpty) AlwaysTrue else sc.copy(attribute = newAttribute) - case AlwaysTrue => - AlwaysTrue - case AlwaysFalse => - AlwaysFalse - case _ => - AlwaysTrue - } - } } } diff --git a/docker/demo/trino-batch1.commands b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionCDCFileGroupMapping.scala similarity index 62% rename from docker/demo/trino-batch1.commands rename to hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionCDCFileGroupMapping.scala index d89c19b0bf0bf..428ad6a141124 100644 --- a/docker/demo/trino-batch1.commands +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionCDCFileGroupMapping.scala @@ -17,7 +17,19 @@ * under the License. */ -select symbol, max(ts) from stock_ticks_cow group by symbol HAVING symbol = 'GOOG'; -select symbol, max(ts) from stock_ticks_mor_ro group by symbol HAVING symbol = 'GOOG'; -select symbol, ts, volume, open, close from stock_ticks_cow where symbol = 'GOOG'; -select symbol, ts, volume, open, close from stock_ticks_mor_ro where symbol = 'GOOG'; +package org.apache.hudi + +import org.apache.hudi.common.table.cdc.HoodieCDCFileSplit + +/** + * Implementation of [[HoodiePartitionCDCFileGroupMapping]] shared by all Spark 4.x + * versions, mixed into the version-specific partition values classes. + */ +trait Spark4HoodiePartitionCDCFileGroupMapping extends HoodiePartitionCDCFileGroupMapping { + + protected def fileSplits: List[HoodieCDCFileSplit] + + override def getFileSplits(): List[HoodieCDCFileSplit] = { + fileSplits + } +} diff --git a/docker/demo/trino-batch2-after-compaction.commands b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionFileSliceMapping.scala similarity index 57% rename from docker/demo/trino-batch2-after-compaction.commands rename to hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionFileSliceMapping.scala index da42b4728252d..3046a0ddbf2e5 100644 --- a/docker/demo/trino-batch2-after-compaction.commands +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionFileSliceMapping.scala @@ -17,5 +17,25 @@ * under the License. */ -select symbol, max(ts) from stock_ticks_mor_ro group by symbol HAVING symbol = 'GOOG'; -select symbol, ts, volume, open, close from stock_ticks_mor_ro where symbol = 'GOOG'; +package org.apache.hudi + +import org.apache.hudi.common.model.FileSlice + +import org.apache.spark.sql.catalyst.InternalRow + +/** + * Implementation of [[HoodiePartitionFileSliceMapping]] shared by all Spark 4.x + * versions, mixed into the version-specific partition values classes. + */ +trait Spark4HoodiePartitionFileSliceMapping extends HoodiePartitionFileSliceMapping { + + def values: InternalRow + + protected def slices: Map[String, FileSlice] + + override def getSlice(fileId: String): Option[FileSlice] = { + slices.get(fileId) + } + + override def getPartitionValues: InternalRow = values +} diff --git a/docker/demo/trino-table-check.commands b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionValues.scala similarity index 56% rename from docker/demo/trino-table-check.commands rename to hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionValues.scala index 4362d79fe770c..a729c6a0574a8 100644 --- a/docker/demo/trino-table-check.commands +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/Spark4HoodiePartitionValues.scala @@ -17,4 +17,21 @@ * under the License. */ -show tables; +package org.apache.hudi + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.unsafe.types.VariantVal + +/** + * Base class for Spark 4.x HoodiePartitionValues implementations. + * Adds the delegation logic available in all Spark 4.x versions on top of + * [[BaseHoodiePartitionValues]]. Version-specific subclasses only implement + * `copy()` and the getters introduced by a newer Spark version. + */ +abstract class Spark4HoodiePartitionValues(values: InternalRow) + extends BaseHoodiePartitionValues(values) { + + override def getVariant(ordinal: Int): VariantVal = { + values.getVariant(ordinal) + } +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/client/model/Spark4HoodieInternalRow.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/client/model/Spark4HoodieInternalRow.scala new file mode 100644 index 0000000000000..dd3b7cd0e3bc6 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/hudi/client/model/Spark4HoodieInternalRow.scala @@ -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.hudi.client.model + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.unsafe.types.{UTF8String, VariantVal} + +/** + * Base class for Spark 4.x HoodieInternalRow implementations. + * Adds the delegation logic available in all Spark 4.x versions on top of + * [[HoodieInternalRow]]. Version-specific subclasses only implement + * [[newInternalRow]] and the getters introduced by a newer Spark version. + */ +abstract class Spark4HoodieInternalRow( + metaFields: Array[UTF8String], + sourceRow: InternalRow, + sourceContainsMetaFields: Boolean) + extends HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) { + + override def getVariant(ordinal: Int): VariantVal = { + ruleOutMetaFieldsAccess(ordinal, classOf[VariantVal]) + sourceRow.getVariant(rebaseOrdinal(ordinal)) + } + + override def copy(): InternalRow = { + val copyMetaFields = metaFields.map(f => if (f != null) f.copy() else null) + newInternalRow( + copyMetaFields, + if (sourceRow == null) null else sourceRow.copy(), + sourceContainsMetaFields) + } + + /** + * Creates a new instance of the version-specific row type, used by [[copy]]. + */ + protected def newInternalRow(metaFields: Array[UTF8String], + sourceRow: InternalRow, + sourceContainsMetaFields: Boolean): Spark4HoodieInternalRow +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystExpressionUtils.scala index 3da62104db734..22272b2b031c4 100644 --- a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystExpressionUtils.scala @@ -17,22 +17,37 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression} +import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder +import org.apache.spark.sql.catalyst.expressions.{Cast, EvalMode, Expression, ParseToDate, ParseToTimestamp} +import org.apache.spark.sql.types.{DataType, StructType} -abstract class HoodieSpark4CatalystExpressionUtils extends HoodieCatalystExpressionUtils { +/** + * Implementation of [[HoodieCatalystExpressionUtils]] shared by all supported Spark 4.x versions + */ +abstract class HoodieSpark4CatalystExpressionUtils extends BaseHoodieCatalystExpressionUtils { + + override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { + ExpressionEncoder.apply(schema).resolveAndBind() + } + + override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = { + expr match { + case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) + case _ => None + } + } - /** - * The attribute name may differ from the one in the schema if the query analyzer - * is case insensitive. We should change attribute names to match the ones in the schema, - * so we do not need to worry about case sensitivity anymore - */ - def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] + override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = + expr match { + case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => + Some((castedExpr, dataType, timeZoneId, if (ansiEnabled == EvalMode.ANSI) true else false)) + case _ => None + } - /** - * Returns a filter that its reference is a subset of `outputSet` and it contains the maximum - * constraints from `condition`. This is used for predicate push-down - * When there is no such filter, `None` is returned. - */ - def extractPredicatesWithinOutputSet(condition: Expression, - outputSet: AttributeSet): Option[Expression] + override protected def unapplyOrderPreservingDateParsing(expr: Expression): Option[Expression] = + expr match { + case ParseToDate(child, _, _, _) => Some(child) + case ParseToTimestamp(child, _, _, _, _) => Some(child) + case _ => None + } } diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystPlanUtils.scala new file mode 100644 index 0000000000000..7af2089ae2d23 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4CatalystPlanUtils.scala @@ -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.spark.sql + +import org.apache.spark.sql.catalyst.analysis.AnalysisErrorAt +import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression} +import org.apache.spark.sql.catalyst.planning.ScanOperation +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, MergeIntoTable} +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} +import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} + +/** + * Implementation of [[HoodieCatalystPlansUtils]] carrying the method bodies shared by all + * supported Spark 4.x versions + */ +abstract class HoodieSpark4CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { + + override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { + plan match { + case MergeIntoTable(targetTable, sourceTable, mergeCondition, _, _, _, _) => + Some((targetTable, sourceTable, mergeCondition)) + case _ => None + } + } + + override def maybeApplyForNewFileFormat(plan: LogicalPlan): LogicalPlan = { + plan match { + case s@ScanOperation(_, _, _, + l@LogicalRelation(fs: HadoopFsRelation, _, _, _, _)) + if fs.fileFormat.isInstanceOf[ParquetFileFormat with HoodieFormatTrait] + && !fs.fileFormat.asInstanceOf[ParquetFileFormat with HoodieFormatTrait].isProjected => + FileFormatUtilsForFileGroupReader.applyNewFileFormatChanges(s, l, fs) + case _ => plan + } + } + + override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { + a.failAnalysis( + errorClass = "UNRESOLVED_COLUMN.WITH_SUGGESTION", + messageParameters = Map( + "objectName" -> a.sql, + "proposal" -> cols)) + } + + override def failTableNotFound(tableName: String): Unit = { + throw new AnalysisException( + errorClass = "TABLE_OR_VIEW_NOT_FOUND", + messageParameters = Map("relationName" -> s"`$tableName`")) + } +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4SchemaUtils.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4SchemaUtils.scala new file mode 100644 index 0000000000000..433394d332d1f --- /dev/null +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/HoodieSpark4SchemaUtils.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 + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.types.DataTypeUtils +import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils +import org.apache.spark.sql.jdbc.JdbcDialect +import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.util.SchemaUtils + +import java.sql.{Connection, ResultSet} + +/** + * Utils on schema shared by all supported Spark 4.x versions. + */ +abstract class HoodieSpark4SchemaUtils extends HoodieSchemaUtils { + override def checkColumnNameDuplication(columnNames: Seq[String], + colType: String, + caseSensitiveAnalysis: Boolean): Unit = { + SchemaUtils.checkColumnNameDuplication(columnNames, caseSensitiveAnalysis) + } + + override def toAttributes(struct: StructType): Seq[Attribute] = { + DataTypeUtils.toAttributes(struct) + } + + override def getSchema(conn: Connection, + resultSet: ResultSet, + dialect: JdbcDialect, + alwaysNullable: Boolean = false, + isTimestampNTZ: Boolean = false): StructType = { + JdbcUtils.getSchema(conn, resultSet, dialect, alwaysNullable) + } +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala index ea6e96943a692..27a835b821f26 100644 --- a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala @@ -17,13 +17,14 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.{AvroConversionUtils, DefaultSource, HoodieSchemaConversionUtils} +import org.apache.hudi.{AvroConversionUtils, DefaultSource, HoodieFileScanRDD, HoodieSchemaConversionUtils} import org.apache.hudi.common.schema.HoodieSchema import org.apache.hudi.common.table.HoodieTableMetaClient import org.apache.hudi.common.util.JsonUtils import org.apache.hudi.spark.internal.ReflectUtil import org.apache.hudi.storage.StorageConfiguration +import org.apache.hadoop.conf.Configuration import org.apache.parquet.schema.{GroupType, MessageType, PrimitiveType, Type, Types} import org.apache.parquet.schema.Type.Repetition import org.apache.spark.api.java.JavaSparkContext @@ -34,7 +35,7 @@ import org.apache.spark.sql.FileFormatUtilsForFileGroupReader.applyFiltersToPlan import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{Expression, InterpretedPredicate, Predicate, SpecializedGetters} +import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression, InterpretedPredicate, Predicate, SpecializedGetters} import org.apache.spark.sql.catalyst.parser.ParseException import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan @@ -42,6 +43,7 @@ import org.apache.spark.sql.catalyst.util.DateFormatter import org.apache.spark.sql.classic.ColumnConversions import org.apache.spark.sql.execution.{PartitionedFileUtil, QueryExecution, SQLExecution} import org.apache.spark.sql.execution.datasources._ +import org.apache.spark.sql.execution.datasources.orc.{OrcColumnarBatchReader, SparkOrcReaderBase} import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFilters, SparkShreddingUtils} import org.apache.spark.sql.hudi.SparkAdapter import org.apache.spark.sql.internal.SQLConf @@ -101,6 +103,23 @@ abstract class BaseSpark4Adapter extends SparkAdapter with Logging { Predicate.createInterpreted(e) } + override def createHoodieFileScanRDD(sparkSession: SparkSession, + readFunction: PartitionedFile => Iterator[InternalRow], + filePartitions: Seq[FilePartition], + readDataSchema: StructType, + metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { + new HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) + } + + override def createOrcFileReader(vectorized: Boolean, + sqlConf: SQLConf, + options: Map[String, String], + hadoopConf: Configuration, + dataSchema: StructType): SparkColumnarFileReader = { + SparkOrcReaderBase.build(vectorized, sqlConf, options, hadoopConf, dataSchema, + (capacity, memoryMode) => new OrcColumnarBatchReader(capacity, memoryMode)) + } + override def createRelation(sqlContext: SQLContext, metaClient: HoodieTableMetaClient, schema: HoodieSchema, @@ -130,6 +149,8 @@ abstract class BaseSpark4Adapter extends SparkAdapter with Logging { override def getUTF8StringFactory: HoodieUTF8StringFactory = Spark4HoodieUTF8StringFactory + override def getSparkPartitionedFileUtils: HoodieSparkPartitionedFileUtils = HoodieSpark4PartitionedFileUtils + override def splitFiles(sparkSession: SparkSession, partitionDirectory: PartitionDirectory, isSplitable: Boolean, diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark41PartitionedFileUtils.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark4PartitionedFileUtils.scala similarity index 96% rename from hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark41PartitionedFileUtils.scala rename to hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark4PartitionedFileUtils.scala index d11ec6baee4f3..824ff9f93080f 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark41PartitionedFileUtils.scala +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark4PartitionedFileUtils.scala @@ -27,9 +27,9 @@ import org.apache.spark.paths.SparkPath import org.apache.spark.sql.catalyst.InternalRow /** - * Utils on Spark [[PartitionedFile]] and [[PartitionDirectory]] for Spark 4.0. + * Utils on Spark [[PartitionedFile]] and [[PartitionDirectory]] for Spark 4.x. */ -object HoodieSpark41PartitionedFileUtils extends HoodieSparkPartitionedFileUtils { +object HoodieSpark4PartitionedFileUtils extends HoodieSparkPartitionedFileUtils { override def getPathFromPartitionedFile(partitionedFile: PartitionedFile): StoragePath = { new StoragePath(partitionedFile.filePath.toUri) } diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/Spark4ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/Spark4ResolveHudiAlterTableCommand.scala new file mode 100644 index 0000000000000..73ada28f7ccd4 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/Spark4ResolveHudiAlterTableCommand.scala @@ -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.hudi + +import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.plans.logical.{AlterColumns, LogicalPlan} +import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} + +/** + * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. + * for alter table column commands. + */ +class Spark4ResolveHudiAlterTableCommand(sparkSession: SparkSession) + extends BaseResolveHudiAlterTableCommand(sparkSession) { + + override protected def resolveAlterColumnCommand: PartialFunction[LogicalPlan, LogicalPlan] = { + case alter@AlterColumns(ResolvedHoodieV2TablePlan(t), _) if alter.resolved => + HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) + } +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark4Analysis.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark4Analysis.scala new file mode 100644 index 0000000000000..8d42660111838 --- /dev/null +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark4Analysis.scala @@ -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.spark.sql.hudi.analysis + +import org.apache.spark.sql.AnalysisException +import org.apache.spark.sql.catalyst.analysis.{ResolveInsertionBase, TableOutputResolver} +import org.apache.spark.sql.catalyst.catalog.CatalogTable +import org.apache.spark.sql.catalyst.plans.logical.InsertIntoStatement +import org.apache.spark.sql.errors.DataTypeErrors.toSQLId +import org.apache.spark.sql.errors.QueryCompilationErrors +import org.apache.spark.sql.execution.datasources.PreprocessTableInsertion +import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.util.PartitioningUtils.normalizePartitionSpec + +/** + * In Spark 3.5, the following Resolution rules are removed, + * [[ResolveUserSpecifiedColumns]] and [[ResolveDefaultColumns]] + * (see code changes in [[org.apache.spark.sql.catalyst.analysis.Analyzer]] + * from https://github.com/apache/spark/pull/41262). + * The same logic of resolving the user specified columns and default values, + * which are required for a subset of columns as user specified compared to the table + * schema to work properly, are deferred to [[PreprocessTableInsertion]] for v1 INSERT. + * + * Note that [[HoodieAnalysis]] intercepts the [[InsertIntoStatement]] after Spark's built-in + * Resolution rules are applies, the logic of resolving the user specified columns and default + * values may no longer be applied. To make INSERT with a subset of columns specified by user + * to work, the custom resolution rules `HoodieSpark4XResolveColumnsForInsertInto` extending + * this base class are added to achieve the same, before converting [[InsertIntoStatement]] + * into [[InsertIntoHoodieTableCommand]]. + * + * The implementation is copied and adapted from [[PreprocessTableInsertion]] + * https://github.com/apache/spark/blob/d061aadf25fd258d2d3e7332a489c9c24a2b5530/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala#L373 + * + * Also note that, the project logic in [[ResolveImplementationsEarly]] for INSERT is still + * needed in the case of INSERT with all columns in a different ordering. + * + * This base class carries the preprocessing logic shared by all supported Spark 4.x versions; + * the per-version subclasses keep only the plan matching that depends on version-specific + * case-class shapes of [[InsertIntoStatement]]. + */ +abstract class HoodieSpark4ResolveColumnsForInsertInto extends ResolveInsertionBase { + + protected def preprocess(insert: InsertIntoStatement, + catalogTable: Option[CatalogTable]): InsertIntoStatement = { + preprocess(insert, catalogTable, catalogTable.map(_.partitionSchema).getOrElse(new StructType())) + } + + protected def preprocess(insert: InsertIntoStatement, + catalogTable: Option[CatalogTable], + partitionSchema: StructType): InsertIntoStatement = { + val tblName = catalogTable.map(_.identifier.quotedString).getOrElse("unknown") + preprocess(insert, tblName, partitionSchema, catalogTable) + } + + // NOTE: this is copied from [[PreprocessTableInsertion]] with additional logic + // to unset user-specified columns at the end + protected def preprocess(insert: InsertIntoStatement, + tblName: String, + partColNames: StructType, + catalogTable: Option[CatalogTable]): InsertIntoStatement = { + + val normalizedPartSpec = normalizePartitionSpec( + insert.partitionSpec, partColNames, tblName, conf.resolver) + + val staticPartCols = normalizedPartSpec.filter(_._2.isDefined).keySet + val expectedColumns = insert.table.output.filterNot(a => staticPartCols.contains(a.name)) + + val partitionsTrackedByCatalog = catalogTable.isDefined && + catalogTable.get.partitionColumnNames.nonEmpty && + catalogTable.get.tracksPartitionsInCatalog + if (partitionsTrackedByCatalog && normalizedPartSpec.nonEmpty) { + // empty partition column value + if (normalizedPartSpec.values.flatten.exists(v => v != null && v.isEmpty)) { + val spec = normalizedPartSpec.map(p => p._1 + "=" + p._2).mkString("[", ", ", "]") + throw QueryCompilationErrors.invalidPartitionSpecError( + s"The spec ($spec) contains an empty partition column value") + } + } + + // Create a project if this INSERT has a user-specified column list. + val hasColumnList = insert.userSpecifiedCols.nonEmpty + val query = if (hasColumnList) { + createProjectForByNameQuery(tblName, insert) + } else { + insert.query + } + val newQuery = try { + TableOutputResolver.resolveOutputColumns( + tblName, + expectedColumns, + query, + byName = hasColumnList || insert.byName, + conf, + supportColDefaultValue = true) + } catch { + case e: AnalysisException if staticPartCols.nonEmpty && + (e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.NOT_ENOUGH_DATA_COLUMNS" || + e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.TOO_MANY_DATA_COLUMNS") => + val newException = e.copy( + errorClass = Some("INSERT_PARTITION_COLUMN_ARITY_MISMATCH"), + messageParameters = e.messageParameters ++ Map( + "tableColumns" -> insert.table.output.map(c => toSQLId(c.name)).mkString(", "), + "staticPartCols" -> staticPartCols.toSeq.sorted.map(c => toSQLId(c)).mkString(", ") + )) + newException.setStackTrace(e.getStackTrace) + throw newException + } + if (normalizedPartSpec.nonEmpty) { + if (normalizedPartSpec.size != partColNames.length) { + throw QueryCompilationErrors.requestedPartitionsMismatchTablePartitionsError( + tblName, normalizedPartSpec, partColNames) + } + + // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] + // and the user specified is no longer need after resolution + // (`userSpecifiedCols = Seq()`) + insert.copy(query = newQuery, partitionSpec = normalizedPartSpec, userSpecifiedCols = Seq()) + } else { + // All partition columns are dynamic because the InsertIntoTable command does + // not explicitly specify partitioning columns. + // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] + // and the user specified is no longer need after resolution + // (`userSpecifiedCols = Seq()`) + insert.copy(query = newQuery, partitionSpec = partColNames.map(_.name).map(_ -> None).toMap, + userSpecifiedCols = Seq()) + } + } +} diff --git a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala index e40cd2e4840be..b05d826fbb1b6 100644 --- a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/hudi/command/DeleteHoodieTableCommand.scala @@ -20,6 +20,7 @@ package org.apache.spark.sql.hudi.command import org.apache.hudi.{HoodieSparkSqlWriter, SparkAdapterSupport} import org.apache.hudi.DataSourceWriteOptions.{SPARK_SQL_OPTIMIZED_WRITES, SPARK_SQL_WRITES_PREPPED_KEY} import org.apache.hudi.common.table.HoodieTableConfig +import org.apache.hudi.keygen.KeyGenUtils import org.apache.spark.sql import org.apache.spark.sql._ @@ -35,6 +36,8 @@ import org.apache.spark.sql.hudi.ProvidesHoodieConfig import org.apache.spark.sql.hudi.command.HoodieCommandMetrics.updateCommitMetrics import org.apache.spark.sql.hudi.command.HoodieLeafRunnableCommand.stripMetaFieldAttributes +import scala.collection.JavaConverters._ + case class DeleteHoodieTableCommand(catalogTable: HoodieCatalogTable, query: LogicalPlan, config: Map[String, String]) extends DataWritingCommand with SparkAdapterSupport with ProvidesHoodieConfig { @@ -81,7 +84,7 @@ object DeleteHoodieTableCommand extends SparkAdapterSupport with ProvidesHoodieC } val recordKeysStr = config.getOrElse(HoodieTableConfig.RECORDKEY_FIELDS.key(), "") - val recordKeys = recordKeysStr.split(",").filter(_.nonEmpty) + val recordKeys = KeyGenUtils.getRecordKeyFields(recordKeysStr).asScala.toSeq // get all columns which are used in condition val conditionColumns = if (condition == null) { diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionCDCFileGroupMapping.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionCDCFileGroupMapping.scala index 58eaac6324632..28fcfaa080b2b 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionCDCFileGroupMapping.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionCDCFileGroupMapping.scala @@ -24,11 +24,6 @@ import org.apache.hudi.common.table.cdc.HoodieCDCFileSplit import org.apache.spark.sql.catalyst.InternalRow class Spark40HoodiePartitionCDCFileGroupMapping(partitionValues: InternalRow, - fileSplits: List[HoodieCDCFileSplit]) - extends Spark40HoodiePartitionValues(partitionValues) - with HoodiePartitionCDCFileGroupMapping { - - override def getFileSplits(): List[HoodieCDCFileSplit] = { - fileSplits - } -} + protected val fileSplits: List[HoodieCDCFileSplit]) + extends Spark40HoodiePartitionValues(partitionValues) + with Spark4HoodiePartitionCDCFileGroupMapping diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionFileSliceMapping.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionFileSliceMapping.scala index 0f769f5bd7ea2..7de6dbf71f95c 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionFileSliceMapping.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionFileSliceMapping.scala @@ -24,13 +24,6 @@ import org.apache.hudi.common.model.FileSlice import org.apache.spark.sql.catalyst.InternalRow class Spark40HoodiePartitionFileSliceMapping(values: InternalRow, - slices: Map[String, FileSlice]) + protected val slices: Map[String, FileSlice]) extends Spark40HoodiePartitionValues(values) - with HoodiePartitionFileSliceMapping { - - override def getSlice(fileId: String): Option[FileSlice] = { - slices.get(fileId) - } - - override def getPartitionValues: InternalRow = values -} + with Spark4HoodiePartitionFileSliceMapping diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionValues.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionValues.scala index db6e3f10341ac..360a6aa8996ea 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionValues.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/Spark40HoodiePartitionValues.scala @@ -20,92 +20,11 @@ package org.apache.hudi import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.util.{ArrayData, MapData} -import org.apache.spark.sql.types.{DataType, Decimal} -import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String, VariantVal} -case class Spark40HoodiePartitionValues(values: InternalRow) extends HoodiePartitionValues { - override def numFields: Int = { - values.numFields - } - - override def setNullAt(i: Int): Unit = { - values.setNullAt(i) - } - - override def update(i: Int, value: Any): Unit = { - values.update(i, value) - } +case class Spark40HoodiePartitionValues(override val values: InternalRow) + extends Spark4HoodiePartitionValues(values) { override def copy(): InternalRow = { Spark40HoodiePartitionValues(values.copy()) } - - override def isNullAt(ordinal: Int): Boolean = { - values.isNullAt(ordinal) - } - - override def getBoolean(ordinal: Int): Boolean = { - values.getBoolean(ordinal) - } - - override def getByte(ordinal: Int): Byte = { - values.getByte(ordinal) - } - - override def getShort(ordinal: Int): Short = { - values.getShort(ordinal) - } - - override def getInt(ordinal: Int): Int = { - values.getInt(ordinal) - } - - override def getLong(ordinal: Int): Long = { - values.getLong(ordinal) - } - - override def getFloat(ordinal: Int): Float = { - values.getFloat(ordinal) - } - - override def getDouble(ordinal: Int): Double = { - values.getDouble(ordinal) - } - - override def getDecimal(ordinal: Int, precision: Int, scale: Int): Decimal = { - values.getDecimal(ordinal, precision, scale) - } - - override def getUTF8String(ordinal: Int): UTF8String = { - values.getUTF8String(ordinal) - } - - override def getBinary(ordinal: Int): Array[Byte] = { - values.getBinary(ordinal) - } - - override def getInterval(ordinal: Int): CalendarInterval = { - values.getInterval(ordinal) - } - - override def getVariant(ordinal: Int): VariantVal = { - values.getVariant(ordinal) - } - - override def getStruct(ordinal: Int, numFields: Int): InternalRow = { - values.getStruct(ordinal, numFields) - } - - override def getArray(ordinal: Int): ArrayData = { - values.getArray(ordinal) - } - - override def getMap(ordinal: Int): MapData = { - values.getMap(ordinal) - } - - override def get(ordinal: Int, dataType: DataType): AnyRef = { - values.get(ordinal, dataType) - } } diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/client/model/Spark40HoodieInternalRow.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/client/model/Spark40HoodieInternalRow.scala index 1da628d03a511..6b0ce70edd456 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/client/model/Spark40HoodieInternalRow.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/hudi/client/model/Spark40HoodieInternalRow.scala @@ -19,24 +19,17 @@ package org.apache.hudi.client.model import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.unsafe.types.{UTF8String, VariantVal} +import org.apache.spark.unsafe.types.UTF8String class Spark40HoodieInternalRow( - metaFields: Array[UTF8String], - sourceRow: InternalRow, - sourceContainsMetaFields: Boolean) - extends HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) { + metaFields: Array[UTF8String], + sourceRow: InternalRow, + sourceContainsMetaFields: Boolean) + extends Spark4HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) { - override def getVariant(ordinal: Int): VariantVal = { - ruleOutMetaFieldsAccess(ordinal, classOf[VariantVal]) - sourceRow.getVariant(rebaseOrdinal(ordinal)) - } - - override def copy(): InternalRow = { - val copyMetaFields = metaFields.map(f => if (f != null) f.copy() else null) - new Spark40HoodieInternalRow( - copyMetaFields, - if (sourceRow == null) null else sourceRow.copy(), - sourceContainsMetaFields) + override protected def newInternalRow(metaFields: Array[UTF8String], + sourceRow: InternalRow, + sourceContainsMetaFields: Boolean): Spark4HoodieInternalRow = { + new Spark40HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) } } diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystExpressionUtils.scala index a183f754483e8..67c6aea40be5a 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystExpressionUtils.scala @@ -17,101 +17,4 @@ package org.apache.spark.sql -import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering -import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder -import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, EvalMode, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, ParseToDate, ParseToTimestamp, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} -import org.apache.spark.sql.execution.datasources.DataSourceStrategy -import org.apache.spark.sql.types.{DataType, StructType} - -object HoodieSpark40CatalystExpressionUtils extends HoodieSpark4CatalystExpressionUtils with PredicateHelper { - - override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { - ExpressionEncoder.apply(schema).resolveAndBind() - } - - override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = { - DataSourceStrategy.normalizeExprs(exprs, attributes) - } - - override def extractPredicatesWithinOutputSet(condition: Expression, outputSet: AttributeSet): Option[Expression] = { - super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) - } - - override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = { - expr match { - case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) - case _ => None - } - } - - override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { - expr match { - case OrderPreservingTransformation(attrRef) => Some(attrRef) - case _ => None - } - } - - def canUpCast(fromType: DataType, toType: DataType): Boolean = - Cast.canUpCast(fromType, toType) - - override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = - expr match { - case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => - Some((castedExpr, dataType, timeZoneId, if (ansiEnabled == EvalMode.ANSI) true else false)) - case _ => None - } - - private object OrderPreservingTransformation { - def unapply(expr: Expression): Option[AttributeReference] = { - expr match { - // Date/Time Expressions - case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ParseToDate(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ParseToTimestamp(OrderPreservingTransformation(attrRef), _, _, _, _) => Some(attrRef) - case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // String Expressions - case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Left API change: Improve RuntimeReplaceable - // https://issues.apache.org/jira/browse/SPARK-38240 - case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Math Expressions - // Binary - case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Unary - case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Other - case cast @ Cast(OrderPreservingTransformation(attrRef), _, _, _) - if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) - - // Identity transformation - case attrRef: AttributeReference => Some(attrRef) - // No match - case _ => None - } - } - } -} +object HoodieSpark40CatalystExpressionUtils extends HoodieSpark4CatalystExpressionUtils diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystPlanUtils.scala index a6641def7e524..f4838b9bc8af1 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40CatalystPlanUtils.scala @@ -18,128 +18,11 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.{AnalysisErrorAt, ResolvedTable} -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} -import org.apache.spark.sql.catalyst.planning.ScanOperation -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} -import org.apache.spark.sql.execution.command.RepairTableCommand -import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} -import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.plans.logical.{Assignment, UpdateAction} import org.apache.spark.sql.execution.streaming.SerializedOffset -import org.apache.spark.sql.types.StructType -object HoodieSpark40CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { - - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = - plan match { - case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) - case _ => None - } - - override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { - plan match { - case MergeIntoTable(targetTable, sourceTable, mergeCondition, _, _, _, _) => - Some((targetTable, sourceTable, mergeCondition)) - case _ => None - } - } - - override def maybeApplyForNewFileFormat(plan: LogicalPlan): LogicalPlan = { - plan match { - case s@ScanOperation(_, _, _, - l@LogicalRelation(fs: HadoopFsRelation, _, _, _, _)) - if fs.fileFormat.isInstanceOf[ParquetFileFormat with HoodieFormatTrait] - && !fs.fileFormat.asInstanceOf[ParquetFileFormat with HoodieFormatTrait].isProjected => - FileFormatUtilsForFileGroupReader.applyNewFileFormatChanges(s, l, fs) - case _ => plan - } - } - - override def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = - ProjectionOverSchema(schema, output) - - override def isRepairTable(plan: LogicalPlan): Boolean = { - plan.isInstanceOf[RepairTableCommand] - } - - override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { - plan match { - case rtc: RepairTableCommand => - Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) - case _ => - None - } - } - - override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { - a.failAnalysis( - errorClass = "UNRESOLVED_COLUMN.WITH_SUGGESTION", - messageParameters = Map( - "objectName" -> a.sql, - "proposal" -> cols)) - } - - override def failTableNotFound(tableName: String): Unit = { - throw new AnalysisException( - errorClass = "TABLE_OR_VIEW_NOT_FOUND", - messageParameters = Map("relationName" -> s"`$tableName`")) - } - - override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { - plan match { - case ci@CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => - Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) - case _ => - None - } - } - - override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { - plan match { - case ci@DropIndex(table, indexName, ignoreIfNotExists) => - Some((table, indexName, ignoreIfNotExists)) - case _ => - None - } - } - - override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { - plan match { - case ci@HoodieShowIndexes(table, output) => - Some((table, output)) - case _ => - None - } - } - - override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { - plan match { - case ci@RefreshIndex(table, indexName) => - Some((table, indexName)) - case _ => - None - } - } - - override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { - plan match { - case insert: InsertIntoStatement => - Some((insert.table, insert.userSpecifiedCols, insert.partitionSpec, insert.query, insert.overwrite, insert.ifPartitionNotExists)) - case _ => - None - } - } - - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { - plan match { - case insert: InsertIntoStatement => - Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) - case _ => - None - } - } +object HoodieSpark40CatalystPlanUtils extends HoodieSpark4CatalystPlanUtils { override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { mergeAction match { diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40SchemaUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40SchemaUtils.scala index bda84f2c1bf3e..44547445db83e 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40SchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/HoodieSpark40SchemaUtils.scala @@ -19,34 +19,7 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils -import org.apache.spark.sql.jdbc.JdbcDialect -import org.apache.spark.sql.types.StructType -import org.apache.spark.sql.util.SchemaUtils - -import java.sql.{Connection, ResultSet} - /** - * Utils on schema for Spark 3.4+. + * Utils on schema for Spark 4.0. */ -object HoodieSpark40SchemaUtils extends HoodieSchemaUtils { - override def checkColumnNameDuplication(columnNames: Seq[String], - colType: String, - caseSensitiveAnalysis: Boolean): Unit = { - SchemaUtils.checkColumnNameDuplication(columnNames, caseSensitiveAnalysis) - } - - override def toAttributes(struct: StructType): Seq[Attribute] = { - DataTypeUtils.toAttributes(struct) - } - - override def getSchema(conn: Connection, - resultSet: ResultSet, - dialect: JdbcDialect, - alwaysNullable: Boolean = false, - isTimestampNTZ: Boolean = false): StructType = { - JdbcUtils.getSchema(conn, resultSet, dialect, alwaysNullable) - } -} +object HoodieSpark40SchemaUtils extends HoodieSpark4SchemaUtils diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_0Adapter.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_0Adapter.scala index 7a3d8bec2403c..519ec97cd7719 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_0Adapter.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_0Adapter.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.{HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, Spark40HoodieFileScanRDD, Spark40HoodiePartitionCDCFileGroupMapping, Spark40HoodiePartitionFileSliceMapping} +import org.apache.hudi.{HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, Spark40HoodiePartitionCDCFileGroupMapping, Spark40HoodiePartitionFileSliceMapping} import org.apache.hudi.client.model.{HoodieInternalRow, Spark40HoodieInternalRow} import org.apache.hudi.common.model.FileSlice import org.apache.hudi.common.schema.HoodieSchema @@ -33,7 +33,7 @@ import org.apache.spark.sql.avro._ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, ResolvedTable} import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression} +import org.apache.spark.sql.catalyst.expressions.{Expression} import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface} import org.apache.spark.sql.catalyst.planning.PhysicalOperation import org.apache.spark.sql.catalyst.plans.logical._ @@ -43,7 +43,6 @@ import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback} import org.apache.spark.sql.execution.datasources._ import org.apache.spark.sql.execution.datasources.lance.SparkLanceReaderBase -import org.apache.spark.sql.execution.datasources.orc.Spark40OrcReader import org.apache.spark.sql.execution.datasources.parquet.{HoodieParquetReadSupport, ParquetFileFormat, Spark40HoodieParquetReadSupport, Spark40LegacyHoodieParquetFileFormat, Spark40ParquetReader} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.execution.streaming.MemoryStream @@ -98,8 +97,6 @@ class Spark4_0Adapter extends BaseSpark4Adapter { override def getSchemaUtils: HoodieSchemaUtils = HoodieSpark40SchemaUtils - override def getSparkPartitionedFileUtils: HoodieSparkPartitionedFileUtils = HoodieSpark40PartitionedFileUtils - override def newParseException(command: Option[String], exception: AnalysisException, start: Origin, @@ -136,14 +133,6 @@ class Spark4_0Adapter extends BaseSpark4Adapter { new Spark40HoodiePartitionFileSliceMapping(values, slices) } - override def createHoodieFileScanRDD(sparkSession: SparkSession, - readFunction: PartitionedFile => Iterator[InternalRow], - filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { - new Spark40HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) - } - override def extractDeleteCondition(deleteFromTable: Command): Expression = { deleteFromTable.asInstanceOf[DeleteFromTable].condition } @@ -209,23 +198,6 @@ class Spark4_0Adapter extends BaseSpark4Adapter { datetimeRebaseSpec, getRebaseSpec("LEGACY"), tableSchemaOpt) } - /** - * TODO - * - * @param vectorized - * @param sqlConf - * @param options - * @param hadoopConf - * @return - */ - override def createOrcFileReader(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): SparkColumnarFileReader = { - Spark40OrcReader.build(vectorized, sqlConf, options, hadoopConf, dataSchema) - } - override def createLanceFileReader(vectorized: Boolean, sqlConf: SQLConf, options: Map[String, String], diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala deleted file mode 100644 index 8aae6b442f8a1..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala +++ /dev/null @@ -1,227 +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.spark.sql.avro - -import org.apache.avro.Schema -import org.apache.avro.file. FileReader -import org.apache.avro.generic.GenericRecord -import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types._ - -import java.util.Locale - -import scala.collection.JavaConverters._ - -/** - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] object AvroUtils extends Logging { - - def supportsDataType(dataType: DataType): Boolean = dataType match { - case _: AtomicType => true - - case st: StructType => st.forall { f => supportsDataType(f.dataType) } - - case ArrayType(elementType, _) => supportsDataType(elementType) - - case MapType(keyType, valueType, _) => - supportsDataType(keyType) && supportsDataType(valueType) - - case udt: UserDefinedType[_] => supportsDataType(udt.sqlType) - - case _: NullType => true - - case _ => false - } - - // The trait provides iterator-like interface for reading records from an Avro file, - // deserializing and returning them as internal rows. - trait RowReader { - protected val fileReader: FileReader[GenericRecord] - protected val deserializer: AvroDeserializer - protected val stopPosition: Long - - private[this] var completed = false - private[this] var currentRow: Option[InternalRow] = None - - def hasNextRow: Boolean = { - while (!completed && currentRow.isEmpty) { - val r = fileReader.hasNext && !fileReader.pastSync(stopPosition) - if (!r) { - fileReader.close() - completed = true - currentRow = None - } else { - val record = fileReader.next() - // the row must be deserialized in hasNextRow, because AvroDeserializer#deserialize - // potentially filters rows - currentRow = deserializer.deserialize(record).asInstanceOf[Option[InternalRow]] - } - } - currentRow.isDefined - } - - def nextRow: InternalRow = { - if (currentRow.isEmpty) { - hasNextRow - } - val returnRow = currentRow - currentRow = None // free up hasNextRow to consume more Avro records, if not exhausted - returnRow.getOrElse { - throw new NoSuchElementException("next on empty iterator") - } - } - } - - /** Wrapper for a pair of matched fields, one Catalyst and one corresponding Avro field. */ - private[sql] case class AvroMatchedField( - catalystField: StructField, - catalystPosition: Int, - avroField: Schema.Field) - - /** - * Helper class to perform field lookup/matching on Avro schemas. - * - * This will match `avroSchema` against `catalystSchema`, attempting to find a matching field in - * the Avro schema for each field in the Catalyst schema and vice-versa, respecting settings for - * case sensitivity. The match results can be accessed using the getter methods. - * - * @param avroSchema The schema in which to search for fields. Must be of type RECORD. - * @param catalystSchema The Catalyst schema to use for matching. - * @param avroPath The seq of parent field names leading to `avroSchema`. - * @param catalystPath The seq of parent field names leading to `catalystSchema`. - * @param positionalFieldMatch If true, perform field matching in a positional fashion - * (structural comparison between schemas, ignoring names); - * otherwise, perform field matching using field names. - */ - class AvroSchemaHelper( - avroSchema: Schema, - catalystSchema: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - positionalFieldMatch: Boolean) { - if (avroSchema.getType != Schema.Type.RECORD) { - throw new IncompatibleSchemaException( - s"Attempting to treat ${avroSchema.getName} as a RECORD, but it was: ${avroSchema.getType}") - } - - private[this] val avroFieldArray = avroSchema.getFields.asScala.toArray - private[this] val fieldMap = avroSchema.getFields.asScala - .groupBy(_.name.toLowerCase(Locale.ROOT)) - .mapValues(_.toSeq) // toSeq needed for scala 2.13 - - /** The fields which have matching equivalents in both Avro and Catalyst schemas. */ - val matchedFields: Seq[AvroMatchedField] = catalystSchema.zipWithIndex.flatMap { - case (sqlField, sqlPos) => - getAvroField(sqlField.name, sqlPos).map(AvroMatchedField(sqlField, sqlPos, _)) - } - - /** - * Validate that there are no Catalyst fields which don't have a matching Avro field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. If `ignoreNullable` is false, - * consider nullable Catalyst fields to be eligible to be an extra field; otherwise, - * ignore nullable Catalyst fields when checking for extras. - */ - def validateNoExtraCatalystFields(ignoreNullable: Boolean): Unit = - catalystSchema.zipWithIndex.foreach { case (sqlField, sqlPos) => - if (getAvroField(sqlField.name, sqlPos).isEmpty && - (!ignoreNullable || !sqlField.nullable)) { - if (positionalFieldMatch) { - throw new IncompatibleSchemaException("Cannot find field at position " + - s"$sqlPos of ${toFieldStr(avroPath)} from Avro schema (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Cannot find ${toFieldStr(catalystPath :+ sqlField.name)} in Avro schema") - } - } - } - - /** - * Validate that there are no Avro fields which don't have a matching Catalyst field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. Only required (non-nullable) - * fields are checked; nullable fields are ignored. - */ - def validateNoExtraRequiredAvroFields(): Unit = { - val extraFields = avroFieldArray.toSet -- matchedFields.map(_.avroField) - extraFields.filterNot(isNullable).foreach { extraField => - if (positionalFieldMatch) { - throw new IncompatibleSchemaException(s"Found field '${extraField.name()}' at position " + - s"${extraField.pos()} of ${toFieldStr(avroPath)} from Avro schema but there is no " + - s"match in the SQL schema at ${toFieldStr(catalystPath)} (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Found ${toFieldStr(avroPath :+ extraField.name())} in Avro schema but there is no " + - "match in the SQL schema") - } - } - } - - /** - * Extract a single field from the contained avro schema which has the desired field name, - * performing the matching with proper case sensitivity according to SQLConf.resolver. - * - * @param name The name of the field to search for. - * @return `Some(match)` if a matching Avro field is found, otherwise `None`. - */ - private[avro] def getFieldByName(name: String): Option[Schema.Field] = { - - // get candidates, ignoring case of field name - val candidates = fieldMap.getOrElse(name.toLowerCase(Locale.ROOT), Seq.empty) - - // search candidates, taking into account case sensitivity settings - candidates.filter(f => SQLConf.get.resolver(f.name(), name)) match { - case Seq(avroField) => Some(avroField) - case Seq() => None - case matches => throw new IncompatibleSchemaException(s"Searching for '$name' in Avro " + - s"schema at ${toFieldStr(avroPath)} gave ${matches.size} matches. Candidates: " + - matches.map(_.name()).mkString("[", ", ", "]") - ) - } - } - - /** Get the Avro field corresponding to the provided Catalyst field name/position, if any. */ - def getAvroField(fieldName: String, catalystPos: Int): Option[Schema.Field] = { - if (positionalFieldMatch) { - avroFieldArray.lift(catalystPos) - } else { - getFieldByName(fieldName) - } - } - } - - /** - * Convert a sequence of hierarchical field names (like `Seq(foo, bar)`) into a human-readable - * string representing the field, like "field 'foo.bar'". If `names` is empty, the string - * "top-level record" is returned. - */ - private[avro] def toFieldStr(names: Seq[String]): String = names match { - case Seq() => "top-level record" - case n => s"field '${n.mkString(".")}'" - } - - /** Return true iff `avroField` is nullable, i.e. `UNION` type and has `NULL` as an option. */ - private[avro] def isNullable(avroField: Schema.Field): Boolean = - avroField.schema().getType == Schema.Type.UNION && - avroField.schema().getTypes.asScala.exists(_.getType == Schema.Type.NULL) -} diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark40PartitionedFileUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark40PartitionedFileUtils.scala deleted file mode 100644 index bc83633383a91..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/HoodieSpark40PartitionedFileUtils.scala +++ /dev/null @@ -1,68 +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.spark.sql.execution.datasources - -import org.apache.hudi.common.util.ReflectionUtils -import org.apache.hudi.storage.StoragePath - -import org.apache.hadoop.fs.FileStatus -import org.apache.spark.paths.SparkPath -import org.apache.spark.sql.catalyst.InternalRow - -/** - * Utils on Spark [[PartitionedFile]] and [[PartitionDirectory]] for Spark 4.0. - */ -object HoodieSpark40PartitionedFileUtils extends HoodieSparkPartitionedFileUtils { - override def getPathFromPartitionedFile(partitionedFile: PartitionedFile): StoragePath = { - new StoragePath(partitionedFile.filePath.toUri) - } - - override def getStringPathFromPartitionedFile(partitionedFile: PartitionedFile): String = { - partitionedFile.filePath.toPath.toString - } - - override def createPartitionedFile(partitionValues: InternalRow, - filePath: StoragePath, - start: Long, - length: Long): PartitionedFile = { - PartitionedFile(partitionValues, SparkPath.fromUri(filePath.toUri), start, length, Array.empty) - } - - override def toFileStatuses(partitionDirs: Seq[PartitionDirectory]): Seq[FileStatus] = { - val files: Seq[FileStatusWithMetadata] = partitionDirs.flatMap(_.files) - try { - files.map(_.fileStatus) - } catch { - case _: NoSuchMethodException | _: NoSuchMethodError | _: IllegalArgumentException => - val methodOpt = ReflectionUtils.getMethod(classOf[FileStatusWithMetadata], "toFileStatus") - if (methodOpt.isPresent) { - val method = methodOpt.get() - files.map(f => method.invoke(f).asInstanceOf[FileStatus]) - } else { - throw new RuntimeException( - "Cannot find toFileStatus method on FileStatusWithMetadata in custom Spark Runtime") - } - } - } - - override def newPartitionDirectory(internalRow: InternalRow, statuses: Seq[FileStatus]): PartitionDirectory = { - PartitionDirectory(internalRow, statuses.toArray) - } -} diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark40NestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark40NestedSchemaPruning.scala deleted file mode 100644 index c10989d89d9fb..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark40NestedSchemaPruning.scala +++ /dev/null @@ -1,61 +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.spark.sql.execution.datasources - -import org.apache.hudi.HoodieBaseRelation - -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.catalyst.planning.PhysicalOperation -import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan -import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.types.StructType - -class Spark40NestedSchemaPruning extends BaseHoodieNestedSchemaPruning { - - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], - requiredSchema: StructType): Seq[AttributeReference] = { - // We need to replace the expression ids of the pruned relation output attributes - // with the expression ids of the original relation output attributes so that - // references to the original relation's output are not broken - val outputIdMap = output.map(att => (att.name, att.exprId)).toMap - DataTypeUtils.toAttributes(requiredSchema) - .map { - case att if outputIdMap.contains(att.name) => - att.withExprId(outputIdMap(att.name)) - case att => att - } - } - - override protected def apply0(plan: LogicalPlan): LogicalPlan = - plan transformDown { - case op @ PhysicalOperation(projects, filters, - // NOTE: This is modified to accommodate for Hudi's custom relations, given that original - // [[NestedSchemaPruning]] rule is tightly coupled w/ [[HadoopFsRelation]] - // TODO generalize to any file-based relation - l @ LogicalRelation(relation: HoodieBaseRelation, _, _, _, _)) - if relation.canPruneRelationSchema => - - prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, - prunedDataSchema => { - val prunedRelation = - relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) - buildPrunedRelation(l, prunedRelation) - }).getOrElse(op) - } -} diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark40OrcReader.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark40OrcReader.scala deleted file mode 100644 index d1da60f5bf085..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark40OrcReader.scala +++ /dev/null @@ -1,92 +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.spark.sql.execution.datasources.orc - -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.Path -import org.apache.spark.memory.MemoryMode -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes -import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile} -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType - -class Spark40OrcReader(enableVectorizedReader: Boolean, - memoryMode: MemoryMode, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean, - capacity: Int) extends SparkOrcReaderBase(enableVectorizedReader, dataSchema, orcFilterPushDown, isCaseSensitive) { - - override def partitionedFileToPath(file: PartitionedFile): Path = { - file.toPath - } - - override def buildReader(): OrcColumnarBatchReader = { - new OrcColumnarBatchReader(capacity, memoryMode) - } - - override def structTypeToAttributes(schema: StructType): Seq[Attribute] = { - toAttributes(schema) - } -} - -object Spark40OrcReader { - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @return ORC file reader - */ - def build(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): Spark40OrcReader = { - //set hadoopconf - hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) - hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) - - val memoryMode = if (sqlConf.offHeapColumnVectorEnabled) { - MemoryMode.OFF_HEAP - } else { - MemoryMode.ON_HEAP - } - - val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && - options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, - throw new IllegalArgumentException( - "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + - "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) - .equals("true") - - new Spark40OrcReader( - enableVectorizedReader = enableVectorizedReader && vectorized, - memoryMode = memoryMode, - isCaseSensitive = sqlConf.caseSensitiveAnalysis, - capacity = sqlConf.orcVectorizedReaderBatchSize, - orcFilterPushDown = sqlConf.orcFilterPushDown, - dataSchema = dataSchema) - } -} diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40DataSourceUtils.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40DataSourceUtils.scala deleted file mode 100644 index 3b84a3e164be1..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40DataSourceUtils.scala +++ /dev/null @@ -1,76 +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.spark.sql.execution.datasources.parquet - -import org.apache.spark.sql.SPARK_VERSION_METADATA_KEY -import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf} -import org.apache.spark.util.Utils - -object Spark40DataSourceUtils { - - /** - * NOTE: This method was copied from [[Spark32PlusDataSourceUtils]], and is required to maintain runtime - * compatibility against Spark 3.5.0 - */ - // scalastyle:off - def int96RebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 3.0 and earlier follow the legacy hybrid calendar and we need to - // rebase the INT96 timestamp values. - // Files written by Spark 3.1 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.1.0" || lookupFileMeta("org.apache.spark.legacyINT96") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def datetimeRebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 2.4 and earlier follow the legacy hybrid calendar and we need to - // rebase the datetime values. - // Files written by Spark 3.0 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.0.0" || lookupFileMeta("org.apache.spark.legacyDateTime") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - -} diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/Spark40ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/Spark40ResolveHudiAlterTableCommand.scala deleted file mode 100644 index e64dae370d4eb..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/Spark40ResolveHudiAlterTableCommand.scala +++ /dev/null @@ -1,68 +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.spark.sql.hudi - -import org.apache.hudi.common.config.HoodieCommonConfig -import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.analysis.ResolvedTable -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table -import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} - -/** - * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. - * for alter table column commands. - */ -class Spark40ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { - - def apply(plan: LogicalPlan): LogicalPlan = { - if (ProvidesHoodieConfig.isSchemaEvolutionEnabled(sparkSession)) { - plan.resolveOperatorsUp { - case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => - HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) - case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => - HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) - case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => - HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) - case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => - HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) - case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => - HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) - case alter@AlterColumns(ResolvedHoodieV2TablePlan(t), _) if alter.resolved => - HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) - case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => - HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) - } - } else { - plan - } - } - - object ResolvedHoodieV2TablePlan { - def unapply(plan: LogicalPlan): Option[HoodieInternalV2Table] = { - plan match { - case ResolvedTable(_, _, v2Table: HoodieInternalV2Table, _) => Some(v2Table) - case _ => None - } - } - } -} - diff --git a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark40Analysis.scala b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark40Analysis.scala index ef92ebc6504a9..200fe39af4713 100644 --- a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark40Analysis.scala +++ b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark40Analysis.scala @@ -20,21 +20,16 @@ package org.apache.spark.sql.hudi.analysis import org.apache.hudi.{DefaultSource, EmptyRelation, HoodieBaseRelation} import org.apache.hudi.SparkAdapterSupport.sparkAdapter -import org.apache.spark.sql.{AnalysisException, SparkSession, SQLContext} -import org.apache.spark.sql.catalyst.analysis.{ResolveInsertionBase, TableOutputResolver} -import org.apache.spark.sql.catalyst.catalog.{CatalogTable, HiveTableRelation} +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.catalog.HiveTableRelation import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.errors.DataTypeErrors.toSQLId -import org.apache.spark.sql.errors.QueryCompilationErrors -import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation, PreprocessTableInsertion} -import org.apache.spark.sql.execution.datasources.LogicalRelation +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.hudi.ProvidesHoodieConfig import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table import org.apache.spark.sql.sources.InsertableRelation import org.apache.spark.sql.types.StructType -import org.apache.spark.sql.util.PartitioningUtils.normalizePartitionSpec /** * NOTE: PLEASE READ CAREFULLY @@ -75,28 +70,11 @@ case class HoodieSpark40DataSourceV2ToV1Fallback(sparkSession: SparkSession) ext } /** - * In Spark 3.5, the following Resolution rules are removed, - * [[ResolveUserSpecifiedColumns]] and [[ResolveDefaultColumns]] - * (see code changes in [[org.apache.spark.sql.catalyst.analysis.Analyzer]] - * from https://github.com/apache/spark/pull/41262). - * The same logic of resolving the user specified columns and default values, - * which are required for a subset of columns as user specified compared to the table - * schema to work properly, are deferred to [[PreprocessTableInsertion]] for v1 INSERT. - * - * Note that [[HoodieAnalysis]] intercepts the [[InsertIntoStatement]] after Spark's built-in - * Resolution rules are applies, the logic of resolving the user specified columns and default - * values may no longer be applied. To make INSERT with a subset of columns specified by user - * to work, this custom resolution rule [[HoodieSpark40ResolveColumnsForInsertInto]] is added - * to achieve the same, before converting [[InsertIntoStatement]] into - * [[InsertIntoHoodieTableCommand]]. - * - * The implementation is copied and adapted from [[PreprocessTableInsertion]] - * https://github.com/apache/spark/blob/d061aadf25fd258d2d3e7332a489c9c24a2b5530/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala#L373 - * - * Also note that, the project logic in [[ResolveImplementationsEarly]] for INSERT is still - * needed in the case of INSERT with all columns in a different ordering. + * Resolution rule resolving the user specified columns and default values of + * [[InsertIntoStatement]] for Spark 4.0; see [[HoodieSpark4ResolveColumnsForInsertInto]] + * for the shared preprocessing logic and the rationale. */ -case class HoodieSpark40ResolveColumnsForInsertInto() extends ResolveInsertionBase { +case class HoodieSpark40ResolveColumnsForInsertInto() extends HoodieSpark4ResolveColumnsForInsertInto { // NOTE: This is copied from [[PreprocessTableInsertion]] with additional handling of Hudi relations override def apply(plan: LogicalPlan): LogicalPlan = { plan match { @@ -123,90 +101,4 @@ case class HoodieSpark40ResolveColumnsForInsertInto() extends ResolveInsertionBa case _ => plan } } - - private def preprocess(insert: InsertIntoStatement, - catalogTable: Option[CatalogTable]): InsertIntoStatement = { - preprocess(insert, catalogTable, catalogTable.map(_.partitionSchema).getOrElse(new StructType())) - } - - private def preprocess(insert: InsertIntoStatement, - catalogTable: Option[CatalogTable], - partitionSchema: StructType): InsertIntoStatement = { - val tblName = catalogTable.map(_.identifier.quotedString).getOrElse("unknown") - preprocess(insert, tblName, partitionSchema, catalogTable) - } - - // NOTE: this is copied from [[PreprocessTableInsertion]] with additional logic - // to unset user-specified columns at the end - private def preprocess(insert: InsertIntoStatement, - tblName: String, - partColNames: StructType, - catalogTable: Option[CatalogTable]): InsertIntoStatement = { - - val normalizedPartSpec = normalizePartitionSpec( - insert.partitionSpec, partColNames, tblName, conf.resolver) - - val staticPartCols = normalizedPartSpec.filter(_._2.isDefined).keySet - val expectedColumns = insert.table.output.filterNot(a => staticPartCols.contains(a.name)) - - val partitionsTrackedByCatalog = catalogTable.isDefined && - catalogTable.get.partitionColumnNames.nonEmpty && - catalogTable.get.tracksPartitionsInCatalog - if (partitionsTrackedByCatalog && normalizedPartSpec.nonEmpty) { - // empty partition column value - if (normalizedPartSpec.values.flatten.exists(v => v != null && v.isEmpty)) { - val spec = normalizedPartSpec.map(p => p._1 + "=" + p._2).mkString("[", ", ", "]") - throw QueryCompilationErrors.invalidPartitionSpecError( - s"The spec ($spec) contains an empty partition column value") - } - } - - // Create a project if this INSERT has a user-specified column list. - val hasColumnList = insert.userSpecifiedCols.nonEmpty - val query = if (hasColumnList) { - createProjectForByNameQuery(tblName, insert) - } else { - insert.query - } - val newQuery = try { - TableOutputResolver.resolveOutputColumns( - tblName, - expectedColumns, - query, - byName = hasColumnList || insert.byName, - conf, - supportColDefaultValue = true) - } catch { - case e: AnalysisException if staticPartCols.nonEmpty && - (e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.NOT_ENOUGH_DATA_COLUMNS" || - e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.TOO_MANY_DATA_COLUMNS") => - val newException = e.copy( - errorClass = Some("INSERT_PARTITION_COLUMN_ARITY_MISMATCH"), - messageParameters = e.messageParameters ++ Map( - "tableColumns" -> insert.table.output.map(c => toSQLId(c.name)).mkString(", "), - "staticPartCols" -> staticPartCols.toSeq.sorted.map(c => toSQLId(c)).mkString(", ") - )) - newException.setStackTrace(e.getStackTrace) - throw newException - } - if (normalizedPartSpec.nonEmpty) { - if (normalizedPartSpec.size != partColNames.length) { - throw QueryCompilationErrors.requestedPartitionsMismatchTablePartitionsError( - tblName, normalizedPartSpec, partColNames) - } - - // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] - // and the user specified is no longer need after resolution - // (`userSpecifiedCols = Seq()`) - insert.copy(query = newQuery, partitionSpec = normalizedPartSpec, userSpecifiedCols = Seq()) - } else { - // All partition columns are dynamic because the InsertIntoTable command does - // not explicitly specify partitioning columns. - // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] - // and the user specified is no longer need after resolution - // (`userSpecifiedCols = Seq()`) - insert.copy(query = newQuery, partitionSpec = partColNames.map(_.name).map(_ -> None).toMap, - userSpecifiedCols = Seq()) - } - } } diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodieFileScanRDD.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodieFileScanRDD.scala deleted file mode 100644 index 4b27188b51c13..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodieFileScanRDD.scala +++ /dev/null @@ -1,36 +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.hudi - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.execution.datasources.{FilePartition, FileScanRDD, PartitionedFile} -import org.apache.spark.sql.types.StructType - -class Spark41HoodieFileScanRDD(@transient private val sparkSession: SparkSession, - read: PartitionedFile => Iterator[InternalRow], - @transient filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty) - extends FileScanRDD(sparkSession, read, filePartitions, readDataSchema, metadataColumns) - with HoodieUnsafeRDD { - - override final def collect(): Array[InternalRow] = super[HoodieUnsafeRDD].collect() -} diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionCDCFileGroupMapping.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionCDCFileGroupMapping.scala index 005c17eb4128f..96f53d886ba50 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionCDCFileGroupMapping.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionCDCFileGroupMapping.scala @@ -24,11 +24,6 @@ import org.apache.hudi.common.table.cdc.HoodieCDCFileSplit import org.apache.spark.sql.catalyst.InternalRow class Spark41HoodiePartitionCDCFileGroupMapping(partitionValues: InternalRow, - fileSplits: List[HoodieCDCFileSplit]) + protected val fileSplits: List[HoodieCDCFileSplit]) extends Spark41HoodiePartitionValues(partitionValues) - with HoodiePartitionCDCFileGroupMapping { - - override def getFileSplits(): List[HoodieCDCFileSplit] = { - fileSplits - } -} + with Spark4HoodiePartitionCDCFileGroupMapping diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionFileSliceMapping.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionFileSliceMapping.scala index 07e199e4a9b1e..fbd66d8fc8a7c 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionFileSliceMapping.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionFileSliceMapping.scala @@ -24,13 +24,6 @@ import org.apache.hudi.common.model.FileSlice import org.apache.spark.sql.catalyst.InternalRow class Spark41HoodiePartitionFileSliceMapping(values: InternalRow, - slices: Map[String, FileSlice]) + protected val slices: Map[String, FileSlice]) extends Spark41HoodiePartitionValues(values) - with HoodiePartitionFileSliceMapping { - - override def getSlice(fileId: String): Option[FileSlice] = { - slices.get(fileId) - } - - override def getPartitionValues: InternalRow = values -} + with Spark4HoodiePartitionFileSliceMapping diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionValues.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionValues.scala index 7d2c71717d573..3964352f3cd85 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionValues.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/Spark41HoodiePartitionValues.scala @@ -20,71 +20,15 @@ package org.apache.hudi import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.util.{ArrayData, MapData} -import org.apache.spark.sql.types.{DataType, Decimal} -import org.apache.spark.unsafe.types.{CalendarInterval, GeographyVal, GeometryVal, UTF8String, VariantVal} +import org.apache.spark.unsafe.types.{GeographyVal, GeometryVal} -case class Spark41HoodiePartitionValues(values: InternalRow) extends HoodiePartitionValues { - override def numFields: Int = { - values.numFields - } - - override def setNullAt(i: Int): Unit = { - values.setNullAt(i) - } - - override def update(i: Int, value: Any): Unit = { - values.update(i, value) - } +case class Spark41HoodiePartitionValues(override val values: InternalRow) + extends Spark4HoodiePartitionValues(values) { override def copy(): InternalRow = { Spark41HoodiePartitionValues(values.copy()) } - override def isNullAt(ordinal: Int): Boolean = { - values.isNullAt(ordinal) - } - - override def getBoolean(ordinal: Int): Boolean = { - values.getBoolean(ordinal) - } - - override def getByte(ordinal: Int): Byte = { - values.getByte(ordinal) - } - - override def getShort(ordinal: Int): Short = { - values.getShort(ordinal) - } - - override def getInt(ordinal: Int): Int = { - values.getInt(ordinal) - } - - override def getLong(ordinal: Int): Long = { - values.getLong(ordinal) - } - - override def getFloat(ordinal: Int): Float = { - values.getFloat(ordinal) - } - - override def getDouble(ordinal: Int): Double = { - values.getDouble(ordinal) - } - - override def getDecimal(ordinal: Int, precision: Int, scale: Int): Decimal = { - values.getDecimal(ordinal, precision, scale) - } - - override def getUTF8String(ordinal: Int): UTF8String = { - values.getUTF8String(ordinal) - } - - override def getBinary(ordinal: Int): Array[Byte] = { - values.getBinary(ordinal) - } - override def getGeography(ordinal: Int): GeographyVal = { values.getGeography(ordinal) } @@ -92,28 +36,4 @@ case class Spark41HoodiePartitionValues(values: InternalRow) extends HoodieParti override def getGeometry(ordinal: Int): GeometryVal = { values.getGeometry(ordinal) } - - override def getInterval(ordinal: Int): CalendarInterval = { - values.getInterval(ordinal) - } - - override def getVariant(ordinal: Int): VariantVal = { - values.getVariant(ordinal) - } - - override def getStruct(ordinal: Int, numFields: Int): InternalRow = { - values.getStruct(ordinal, numFields) - } - - override def getArray(ordinal: Int): ArrayData = { - values.getArray(ordinal) - } - - override def getMap(ordinal: Int): MapData = { - values.getMap(ordinal) - } - - override def get(ordinal: Int, dataType: DataType): AnyRef = { - values.get(ordinal, dataType) - } } diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/client/model/Spark41HoodieInternalRow.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/client/model/Spark41HoodieInternalRow.scala index a56368eee1e4a..308424396a752 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/client/model/Spark41HoodieInternalRow.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/hudi/client/model/Spark41HoodieInternalRow.scala @@ -19,18 +19,13 @@ package org.apache.hudi.client.model import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.unsafe.types.{GeographyVal, GeometryVal, UTF8String, VariantVal} +import org.apache.spark.unsafe.types.{GeographyVal, GeometryVal, UTF8String} class Spark41HoodieInternalRow( metaFields: Array[UTF8String], sourceRow: InternalRow, sourceContainsMetaFields: Boolean) - extends HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) { - - override def getVariant(ordinal: Int): VariantVal = { - ruleOutMetaFieldsAccess(ordinal, classOf[VariantVal]) - sourceRow.getVariant(rebaseOrdinal(ordinal)) - } + extends Spark4HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) { override def getGeography(ordinal: Int): GeographyVal = { ruleOutMetaFieldsAccess(ordinal, classOf[GeographyVal]) @@ -42,11 +37,9 @@ class Spark41HoodieInternalRow( sourceRow.getGeometry(rebaseOrdinal(ordinal)) } - override def copy(): InternalRow = { - val copyMetaFields = metaFields.map(f => if (f != null) f.copy() else null) - new Spark41HoodieInternalRow( - copyMetaFields, - if (sourceRow == null) null else sourceRow.copy(), - sourceContainsMetaFields) + override protected def newInternalRow(metaFields: Array[UTF8String], + sourceRow: InternalRow, + sourceContainsMetaFields: Boolean): Spark4HoodieInternalRow = { + new Spark41HoodieInternalRow(metaFields, sourceRow, sourceContainsMetaFields) } } diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystExpressionUtils.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystExpressionUtils.scala index f835d7daace05..c41389f315345 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystExpressionUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystExpressionUtils.scala @@ -17,101 +17,4 @@ package org.apache.spark.sql -import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering -import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder -import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, DateFormatClass, DateSub, Divide, EvalMode, Exp, Expm1, Expression, FromUnixTime, FromUTCTimestamp, Log, Log10, Log1p, Log2, Lower, Multiply, ParseToDate, ParseToTimestamp, PredicateHelper, ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp, Upper} -import org.apache.spark.sql.execution.datasources.DataSourceStrategy -import org.apache.spark.sql.types.{DataType, StructType} - -object HoodieSpark41CatalystExpressionUtils extends HoodieSpark4CatalystExpressionUtils with PredicateHelper { - - override def getEncoder(schema: StructType): ExpressionEncoder[Row] = { - ExpressionEncoder.apply(schema).resolveAndBind() - } - - override def normalizeExprs(exprs: Seq[Expression], attributes: Seq[Attribute]): Seq[Expression] = { - DataSourceStrategy.normalizeExprs(exprs, attributes) - } - - override def extractPredicatesWithinOutputSet(condition: Expression, outputSet: AttributeSet): Option[Expression] = { - super[PredicateHelper].extractPredicatesWithinOutputSet(condition, outputSet) - } - - override def matchCast(expr: Expression): Option[(Expression, DataType, Option[String])] = { - expr match { - case Cast(child, dataType, timeZoneId, _) => Some((child, dataType, timeZoneId)) - case _ => None - } - } - - override def tryMatchAttributeOrderingPreservingTransformation(expr: Expression): Option[AttributeReference] = { - expr match { - case OrderPreservingTransformation(attrRef) => Some(attrRef) - case _ => None - } - } - - def canUpCast(fromType: DataType, toType: DataType): Boolean = - Cast.canUpCast(fromType, toType) - - override def unapplyCastExpression(expr: Expression): Option[(Expression, DataType, Option[String], Boolean)] = - expr match { - case Cast(castedExpr, dataType, timeZoneId, ansiEnabled) => - Some((castedExpr, dataType, timeZoneId, if (ansiEnabled == EvalMode.ANSI) true else false)) - case _ => None - } - - private object OrderPreservingTransformation { - def unapply(expr: Expression): Option[AttributeReference] = { - expr match { - // Date/Time Expressions - case DateFormatClass(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case DateAdd(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateSub(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case DateDiff(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - case FromUnixTime(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case FromUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ParseToDate(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ParseToTimestamp(OrderPreservingTransformation(attrRef), _, _, _, _) => Some(attrRef) - case ToUnixTimestamp(OrderPreservingTransformation(attrRef), _, _, _) => Some(attrRef) - case ToUTCTimestamp(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // String Expressions - case Lower(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Upper(OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Left API change: Improve RuntimeReplaceable - // https://issues.apache.org/jira/browse/SPARK-38240 - case org.apache.spark.sql.catalyst.expressions.Left(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Math Expressions - // Binary - case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Multiply(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case Multiply(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case Divide(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef) - case BitwiseOr(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => Some(attrRef) - // Unary - case Exp(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Expm1(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log10(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log1p(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case Log2(OrderPreservingTransformation(attrRef)) => Some(attrRef) - case ShiftLeft(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - case ShiftRight(OrderPreservingTransformation(attrRef), _) => Some(attrRef) - - // Other - case cast@Cast(OrderPreservingTransformation(attrRef), _, _, _) - if isCastPreservingOrdering(cast.child.dataType, cast.dataType) => Some(attrRef) - - // Identity transformation - case attrRef: AttributeReference => Some(attrRef) - // No match - case _ => None - } - } - } -} +object HoodieSpark41CatalystExpressionUtils extends HoodieSpark4CatalystExpressionUtils diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystPlanUtils.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystPlanUtils.scala index 6a385810aef9e..24489bb4f16a4 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystPlanUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41CatalystPlanUtils.scala @@ -18,128 +18,11 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.{AnalysisErrorAt, ResolvedTable} -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, Expression, ProjectionOverSchema} -import org.apache.spark.sql.catalyst.planning.ScanOperation -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog} -import org.apache.spark.sql.execution.command.RepairTableCommand -import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} -import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, ParquetFileFormat} +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.plans.logical.{Assignment, UpdateAction} import org.apache.spark.sql.execution.streaming.runtime.SerializedOffset -import org.apache.spark.sql.types.StructType -object HoodieSpark41CatalystPlanUtils extends BaseHoodieCatalystPlanUtils { - - def unapplyResolvedTable(plan: LogicalPlan): Option[(TableCatalog, Identifier, Table)] = - plan match { - case ResolvedTable(catalog, identifier, table, _) => Some((catalog, identifier, table)) - case _ => None - } - - override def unapplyMergeIntoTable(plan: LogicalPlan): Option[(LogicalPlan, LogicalPlan, Expression)] = { - plan match { - case MergeIntoTable(targetTable, sourceTable, mergeCondition, _, _, _, _) => - Some((targetTable, sourceTable, mergeCondition)) - case _ => None - } - } - - override def maybeApplyForNewFileFormat(plan: LogicalPlan): LogicalPlan = { - plan match { - case s@ScanOperation(_, _, _, - l@LogicalRelation(fs: HadoopFsRelation, _, _, _, _)) - if fs.fileFormat.isInstanceOf[ParquetFileFormat with HoodieFormatTrait] - && !fs.fileFormat.asInstanceOf[ParquetFileFormat with HoodieFormatTrait].isProjected => - FileFormatUtilsForFileGroupReader.applyNewFileFormatChanges(s, l, fs) - case _ => plan - } - } - - override def projectOverSchema(schema: StructType, output: AttributeSet): ProjectionOverSchema = - ProjectionOverSchema(schema, output) - - override def isRepairTable(plan: LogicalPlan): Boolean = { - plan.isInstanceOf[RepairTableCommand] - } - - override def getRepairTableChildren(plan: LogicalPlan): Option[(TableIdentifier, Boolean, Boolean, String)] = { - plan match { - case rtc: RepairTableCommand => - Some((rtc.tableName, rtc.enableAddPartitions, rtc.enableDropPartitions, rtc.cmd)) - case _ => - None - } - } - - override def failAnalysisForMIT(a: Attribute, cols: String): Unit = { - a.failAnalysis( - errorClass = "UNRESOLVED_COLUMN.WITH_SUGGESTION", - messageParameters = Map( - "objectName" -> a.sql, - "proposal" -> cols)) - } - - override def failTableNotFound(tableName: String): Unit = { - throw new AnalysisException( - errorClass = "TABLE_OR_VIEW_NOT_FOUND", - messageParameters = Map("relationName" -> s"`$tableName`")) - } - - override def unapplyCreateIndex(plan: LogicalPlan): Option[(LogicalPlan, String, String, Boolean, Seq[(Seq[String], Map[String, String])], Map[String, String])] = { - plan match { - case ci@CreateIndex(table, indexName, indexType, ignoreIfExists, columns, properties) => - Some((table, indexName, indexType, ignoreIfExists, columns.map(col => (col._1.name, col._2)), properties)) - case _ => - None - } - } - - override def unapplyDropIndex(plan: LogicalPlan): Option[(LogicalPlan, String, Boolean)] = { - plan match { - case ci@DropIndex(table, indexName, ignoreIfNotExists) => - Some((table, indexName, ignoreIfNotExists)) - case _ => - None - } - } - - override def unapplyShowIndexes(plan: LogicalPlan): Option[(LogicalPlan, Seq[Attribute])] = { - plan match { - case ci@HoodieShowIndexes(table, output) => - Some((table, output)) - case _ => - None - } - } - - override def unapplyRefreshIndex(plan: LogicalPlan): Option[(LogicalPlan, String)] = { - plan match { - case ci@RefreshIndex(table, indexName) => - Some((table, indexName)) - case _ => - None - } - } - - override def unapplyInsertIntoStatement(plan: LogicalPlan): Option[(LogicalPlan, Seq[String], Map[String, Option[String]], LogicalPlan, Boolean, Boolean)] = { - plan match { - case insert: InsertIntoStatement => - Some((insert.table, insert.userSpecifiedCols, insert.partitionSpec, insert.query, insert.overwrite, insert.ifPartitionNotExists)) - case _ => - None - } - } - - override def createProjectForByNameQuery(lr: LogicalRelation, plan: LogicalPlan): Option[LogicalPlan] = { - plan match { - case insert: InsertIntoStatement => - Some(ResolveInsertionBase.createProjectForByNameQuery(lr.catalogTable.get.qualifiedName, insert)) - case _ => - None - } - } +object HoodieSpark41CatalystPlanUtils extends HoodieSpark4CatalystPlanUtils { override def unapplyUpdateAction(mergeAction: Any): Option[(Option[Expression], Seq[Assignment])] = { mergeAction match { diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41SchemaUtils.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41SchemaUtils.scala index c68190466ca8f..149989471f95b 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41SchemaUtils.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/HoodieSpark41SchemaUtils.scala @@ -19,34 +19,7 @@ package org.apache.spark.sql -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.execution.datasources.jdbc.JdbcUtils -import org.apache.spark.sql.jdbc.JdbcDialect -import org.apache.spark.sql.types.StructType -import org.apache.spark.sql.util.SchemaUtils - -import java.sql.{Connection, ResultSet} - /** - * Utils on schema for Spark 3.4+. + * Utils on schema for Spark 4.1. */ -object HoodieSpark41SchemaUtils extends HoodieSchemaUtils { - override def checkColumnNameDuplication(columnNames: Seq[String], - colType: String, - caseSensitiveAnalysis: Boolean): Unit = { - SchemaUtils.checkColumnNameDuplication(columnNames, caseSensitiveAnalysis) - } - - override def toAttributes(struct: StructType): Seq[Attribute] = { - DataTypeUtils.toAttributes(struct) - } - - override def getSchema(conn: Connection, - resultSet: ResultSet, - dialect: JdbcDialect, - alwaysNullable: Boolean = false, - isTimestampNTZ: Boolean = false): StructType = { - JdbcUtils.getSchema(conn, resultSet, dialect, alwaysNullable) - } -} +object HoodieSpark41SchemaUtils extends HoodieSpark4SchemaUtils diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala index 195979548bc4f..f6a8e19b0e6c8 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.adapter -import org.apache.hudi.{HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, Spark41HoodieFileScanRDD, Spark41HoodiePartitionCDCFileGroupMapping, Spark41HoodiePartitionFileSliceMapping} +import org.apache.hudi.{HoodiePartitionCDCFileGroupMapping, HoodiePartitionFileSliceMapping, Spark41HoodiePartitionCDCFileGroupMapping, Spark41HoodiePartitionFileSliceMapping} import org.apache.hudi.client.model.{HoodieInternalRow, Spark41HoodieInternalRow} import org.apache.hudi.common.model.FileSlice import org.apache.hudi.common.schema.HoodieSchema @@ -32,7 +32,7 @@ import org.apache.spark.sql.avro._ import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, ResolvedTable} import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{AttributeReference, BoundReference, CreateNamedStruct, Expression, Literal, UnsafeProjection} +import org.apache.spark.sql.catalyst.expressions.{BoundReference, CreateNamedStruct, Expression, Literal, UnsafeProjection} import org.apache.spark.sql.catalyst.expressions.variant.VariantGet import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface} import org.apache.spark.sql.catalyst.planning.PhysicalOperation @@ -43,7 +43,6 @@ import org.apache.spark.sql.catalyst.util.{METADATA_COL_ATTR_KEY, RebaseDateTime import org.apache.spark.sql.connector.catalog.{V1Table, V2TableWithV1Fallback} import org.apache.spark.sql.execution.datasources._ import org.apache.spark.sql.execution.datasources.lance.SparkLanceReaderBase -import org.apache.spark.sql.execution.datasources.orc.Spark41OrcReader import org.apache.spark.sql.execution.datasources.parquet.{ParquetFileFormat, Spark41LegacyHoodieParquetFileFormat, Spark41ParquetReader} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.execution.streaming.runtime.MemoryStream @@ -98,8 +97,6 @@ class Spark4_1Adapter extends BaseSpark4Adapter { override def getSchemaUtils: HoodieSchemaUtils = HoodieSpark41SchemaUtils - override def getSparkPartitionedFileUtils: HoodieSparkPartitionedFileUtils = HoodieSpark41PartitionedFileUtils - override def newParseException(command: Option[String], exception: AnalysisException, start: Origin, @@ -138,14 +135,6 @@ class Spark4_1Adapter extends BaseSpark4Adapter { new Spark41HoodiePartitionFileSliceMapping(values, slices) } - override def createHoodieFileScanRDD(sparkSession: SparkSession, - readFunction: PartitionedFile => Iterator[InternalRow], - filePartitions: Seq[FilePartition], - readDataSchema: StructType, - metadataColumns: Seq[AttributeReference] = Seq.empty): FileScanRDD = { - new Spark41HoodieFileScanRDD(sparkSession, readFunction, filePartitions, readDataSchema, metadataColumns) - } - override def extractDeleteCondition(deleteFromTable: Command): Expression = { deleteFromTable.asInstanceOf[DeleteFromTable].condition } @@ -200,24 +189,6 @@ class Spark4_1Adapter extends BaseSpark4Adapter { Spark41ParquetReader.build(vectorized, sqlConf, options, hadoopConf) } - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @param dataSchema the data schema of the ORC file - * @return ORC file reader - */ - override def createOrcFileReader(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): SparkColumnarFileReader = { - Spark41OrcReader.build(vectorized, sqlConf, options, hadoopConf, dataSchema) - } - override def createLanceFileReader(vectorized: Boolean, sqlConf: SQLConf, options: Map[String, String], diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala deleted file mode 100644 index 8aae6b442f8a1..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/avro/AvroUtils.scala +++ /dev/null @@ -1,227 +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.spark.sql.avro - -import org.apache.avro.Schema -import org.apache.avro.file. FileReader -import org.apache.avro.generic.GenericRecord -import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types._ - -import java.util.Locale - -import scala.collection.JavaConverters._ - -/** - * NOTE: This code is borrowed from Spark 3.3.0 - * This code is borrowed, so that we can better control compatibility w/in Spark minor - * branches (3.2.x, 3.1.x, etc) - * - * PLEASE REFRAIN MAKING ANY CHANGES TO THIS CODE UNLESS ABSOLUTELY NECESSARY - */ -private[sql] object AvroUtils extends Logging { - - def supportsDataType(dataType: DataType): Boolean = dataType match { - case _: AtomicType => true - - case st: StructType => st.forall { f => supportsDataType(f.dataType) } - - case ArrayType(elementType, _) => supportsDataType(elementType) - - case MapType(keyType, valueType, _) => - supportsDataType(keyType) && supportsDataType(valueType) - - case udt: UserDefinedType[_] => supportsDataType(udt.sqlType) - - case _: NullType => true - - case _ => false - } - - // The trait provides iterator-like interface for reading records from an Avro file, - // deserializing and returning them as internal rows. - trait RowReader { - protected val fileReader: FileReader[GenericRecord] - protected val deserializer: AvroDeserializer - protected val stopPosition: Long - - private[this] var completed = false - private[this] var currentRow: Option[InternalRow] = None - - def hasNextRow: Boolean = { - while (!completed && currentRow.isEmpty) { - val r = fileReader.hasNext && !fileReader.pastSync(stopPosition) - if (!r) { - fileReader.close() - completed = true - currentRow = None - } else { - val record = fileReader.next() - // the row must be deserialized in hasNextRow, because AvroDeserializer#deserialize - // potentially filters rows - currentRow = deserializer.deserialize(record).asInstanceOf[Option[InternalRow]] - } - } - currentRow.isDefined - } - - def nextRow: InternalRow = { - if (currentRow.isEmpty) { - hasNextRow - } - val returnRow = currentRow - currentRow = None // free up hasNextRow to consume more Avro records, if not exhausted - returnRow.getOrElse { - throw new NoSuchElementException("next on empty iterator") - } - } - } - - /** Wrapper for a pair of matched fields, one Catalyst and one corresponding Avro field. */ - private[sql] case class AvroMatchedField( - catalystField: StructField, - catalystPosition: Int, - avroField: Schema.Field) - - /** - * Helper class to perform field lookup/matching on Avro schemas. - * - * This will match `avroSchema` against `catalystSchema`, attempting to find a matching field in - * the Avro schema for each field in the Catalyst schema and vice-versa, respecting settings for - * case sensitivity. The match results can be accessed using the getter methods. - * - * @param avroSchema The schema in which to search for fields. Must be of type RECORD. - * @param catalystSchema The Catalyst schema to use for matching. - * @param avroPath The seq of parent field names leading to `avroSchema`. - * @param catalystPath The seq of parent field names leading to `catalystSchema`. - * @param positionalFieldMatch If true, perform field matching in a positional fashion - * (structural comparison between schemas, ignoring names); - * otherwise, perform field matching using field names. - */ - class AvroSchemaHelper( - avroSchema: Schema, - catalystSchema: StructType, - avroPath: Seq[String], - catalystPath: Seq[String], - positionalFieldMatch: Boolean) { - if (avroSchema.getType != Schema.Type.RECORD) { - throw new IncompatibleSchemaException( - s"Attempting to treat ${avroSchema.getName} as a RECORD, but it was: ${avroSchema.getType}") - } - - private[this] val avroFieldArray = avroSchema.getFields.asScala.toArray - private[this] val fieldMap = avroSchema.getFields.asScala - .groupBy(_.name.toLowerCase(Locale.ROOT)) - .mapValues(_.toSeq) // toSeq needed for scala 2.13 - - /** The fields which have matching equivalents in both Avro and Catalyst schemas. */ - val matchedFields: Seq[AvroMatchedField] = catalystSchema.zipWithIndex.flatMap { - case (sqlField, sqlPos) => - getAvroField(sqlField.name, sqlPos).map(AvroMatchedField(sqlField, sqlPos, _)) - } - - /** - * Validate that there are no Catalyst fields which don't have a matching Avro field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. If `ignoreNullable` is false, - * consider nullable Catalyst fields to be eligible to be an extra field; otherwise, - * ignore nullable Catalyst fields when checking for extras. - */ - def validateNoExtraCatalystFields(ignoreNullable: Boolean): Unit = - catalystSchema.zipWithIndex.foreach { case (sqlField, sqlPos) => - if (getAvroField(sqlField.name, sqlPos).isEmpty && - (!ignoreNullable || !sqlField.nullable)) { - if (positionalFieldMatch) { - throw new IncompatibleSchemaException("Cannot find field at position " + - s"$sqlPos of ${toFieldStr(avroPath)} from Avro schema (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Cannot find ${toFieldStr(catalystPath :+ sqlField.name)} in Avro schema") - } - } - } - - /** - * Validate that there are no Avro fields which don't have a matching Catalyst field, throwing - * [[IncompatibleSchemaException]] if such extra fields are found. Only required (non-nullable) - * fields are checked; nullable fields are ignored. - */ - def validateNoExtraRequiredAvroFields(): Unit = { - val extraFields = avroFieldArray.toSet -- matchedFields.map(_.avroField) - extraFields.filterNot(isNullable).foreach { extraField => - if (positionalFieldMatch) { - throw new IncompatibleSchemaException(s"Found field '${extraField.name()}' at position " + - s"${extraField.pos()} of ${toFieldStr(avroPath)} from Avro schema but there is no " + - s"match in the SQL schema at ${toFieldStr(catalystPath)} (using positional matching)") - } else { - throw new IncompatibleSchemaException( - s"Found ${toFieldStr(avroPath :+ extraField.name())} in Avro schema but there is no " + - "match in the SQL schema") - } - } - } - - /** - * Extract a single field from the contained avro schema which has the desired field name, - * performing the matching with proper case sensitivity according to SQLConf.resolver. - * - * @param name The name of the field to search for. - * @return `Some(match)` if a matching Avro field is found, otherwise `None`. - */ - private[avro] def getFieldByName(name: String): Option[Schema.Field] = { - - // get candidates, ignoring case of field name - val candidates = fieldMap.getOrElse(name.toLowerCase(Locale.ROOT), Seq.empty) - - // search candidates, taking into account case sensitivity settings - candidates.filter(f => SQLConf.get.resolver(f.name(), name)) match { - case Seq(avroField) => Some(avroField) - case Seq() => None - case matches => throw new IncompatibleSchemaException(s"Searching for '$name' in Avro " + - s"schema at ${toFieldStr(avroPath)} gave ${matches.size} matches. Candidates: " + - matches.map(_.name()).mkString("[", ", ", "]") - ) - } - } - - /** Get the Avro field corresponding to the provided Catalyst field name/position, if any. */ - def getAvroField(fieldName: String, catalystPos: Int): Option[Schema.Field] = { - if (positionalFieldMatch) { - avroFieldArray.lift(catalystPos) - } else { - getFieldByName(fieldName) - } - } - } - - /** - * Convert a sequence of hierarchical field names (like `Seq(foo, bar)`) into a human-readable - * string representing the field, like "field 'foo.bar'". If `names` is empty, the string - * "top-level record" is returned. - */ - private[avro] def toFieldStr(names: Seq[String]): String = names match { - case Seq() => "top-level record" - case n => s"field '${n.mkString(".")}'" - } - - /** Return true iff `avroField` is nullable, i.e. `UNION` type and has `NULL` as an option. */ - private[avro] def isNullable(avroField: Schema.Field): Boolean = - avroField.schema().getType == Schema.Type.UNION && - avroField.schema().getTypes.asScala.exists(_.getType == Schema.Type.NULL) -} diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark41NestedSchemaPruning.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark41NestedSchemaPruning.scala deleted file mode 100644 index e9cac2a66ac23..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/Spark41NestedSchemaPruning.scala +++ /dev/null @@ -1,61 +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.spark.sql.execution.datasources - -import org.apache.hudi.HoodieBaseRelation - -import org.apache.spark.sql.catalyst.expressions.AttributeReference -import org.apache.spark.sql.catalyst.planning.PhysicalOperation -import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan -import org.apache.spark.sql.catalyst.types.DataTypeUtils -import org.apache.spark.sql.types.StructType - -class Spark41NestedSchemaPruning extends BaseHoodieNestedSchemaPruning { - - // Prune the given output to make it consistent with `requiredSchema`. - protected def getPrunedOutput(output: Seq[AttributeReference], - requiredSchema: StructType): Seq[AttributeReference] = { - // We need to replace the expression ids of the pruned relation output attributes - // with the expression ids of the original relation output attributes so that - // references to the original relation's output are not broken - val outputIdMap = output.map(att => (att.name, att.exprId)).toMap - DataTypeUtils.toAttributes(requiredSchema) - .map { - case att if outputIdMap.contains(att.name) => - att.withExprId(outputIdMap(att.name)) - case att => att - } - } - - override protected def apply0(plan: LogicalPlan): LogicalPlan = - plan transformDown { - case op@PhysicalOperation(projects, filters, - // NOTE: This is modified to accommodate for Hudi's custom relations, given that original - // [[NestedSchemaPruning]] rule is tightly coupled w/ [[HadoopFsRelation]] - // TODO generalize to any file-based relation - l@LogicalRelation(relation: HoodieBaseRelation, _, _, _, _)) - if relation.canPruneRelationSchema => - - prunePhysicalColumns(l.output, projects, filters, relation.dataSchema, - prunedDataSchema => { - val prunedRelation = - relation.updatePrunedDataSchema(prunedSchema = prunedDataSchema) - buildPrunedRelation(l, prunedRelation) - }).getOrElse(op) - } -} diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark41OrcReader.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark41OrcReader.scala deleted file mode 100644 index 413196a236a49..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/orc/Spark41OrcReader.scala +++ /dev/null @@ -1,92 +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.spark.sql.execution.datasources.orc - -import org.apache.hadoop.conf.Configuration -import org.apache.hadoop.fs.Path -import org.apache.spark.memory.MemoryMode -import org.apache.spark.sql.catalyst.expressions.Attribute -import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes -import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile} -import org.apache.spark.sql.internal.SQLConf -import org.apache.spark.sql.types.StructType - -class Spark41OrcReader(enableVectorizedReader: Boolean, - memoryMode: MemoryMode, - dataSchema: StructType, - orcFilterPushDown: Boolean, - isCaseSensitive: Boolean, - capacity: Int) extends SparkOrcReaderBase(enableVectorizedReader, dataSchema, orcFilterPushDown, isCaseSensitive) { - - override def partitionedFileToPath(file: PartitionedFile): Path = { - file.toPath - } - - override def buildReader(): OrcColumnarBatchReader = { - new OrcColumnarBatchReader(capacity, memoryMode) - } - - override def structTypeToAttributes(schema: StructType): Seq[Attribute] = { - toAttributes(schema) - } -} - -object Spark41OrcReader { - /** - * Get ORC file reader - * - * @param vectorized true if vectorized reading is not prohibited due to schema, reading mode, etc - * @param sqlConf the [[SQLConf]] used for the read - * @param options passed as a param to the file format - * @param hadoopConf some configs will be set for the hadoopConf - * @return ORC file reader - */ - def build(vectorized: Boolean, - sqlConf: SQLConf, - options: Map[String, String], - hadoopConf: Configuration, - dataSchema: StructType): Spark41OrcReader = { - //set hadoopconf - hadoopConf.set(SQLConf.SESSION_LOCAL_TIMEZONE.key, sqlConf.sessionLocalTimeZone) - hadoopConf.setBoolean(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key, sqlConf.nestedSchemaPruningEnabled) - hadoopConf.setBoolean(SQLConf.CASE_SENSITIVE.key, sqlConf.caseSensitiveAnalysis) - - val memoryMode = if (sqlConf.offHeapColumnVectorEnabled) { - MemoryMode.OFF_HEAP - } else { - MemoryMode.ON_HEAP - } - - val enableVectorizedReader = sqlConf.orcVectorizedReaderEnabled && - options.getOrElse(FileFormat.OPTION_RETURNING_BATCH, - throw new IllegalArgumentException( - "OPTION_RETURNING_BATCH should always be set for OrcFileFormat. " + - "To workaround this issue, set spark.sql.orc.enableVectorizedReader=false.")) - .equals("true") - - new Spark41OrcReader( - enableVectorizedReader = enableVectorizedReader && vectorized, - memoryMode = memoryMode, - isCaseSensitive = sqlConf.caseSensitiveAnalysis, - capacity = sqlConf.orcVectorizedReaderBatchSize, - orcFilterPushDown = sqlConf.orcFilterPushDown, - dataSchema = dataSchema) - } -} diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41DataSourceUtils.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41DataSourceUtils.scala deleted file mode 100644 index 9c4b28d2423a5..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41DataSourceUtils.scala +++ /dev/null @@ -1,76 +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.spark.sql.execution.datasources.parquet - -import org.apache.spark.sql.SPARK_VERSION_METADATA_KEY -import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf} -import org.apache.spark.util.Utils - -object Spark41DataSourceUtils { - - /** - * NOTE: This method was copied from [[Spark32PlusDataSourceUtils]], and is required to maintain runtime - * compatibility against Spark 3.5.0 - */ - // scalastyle:off - def int96RebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 3.0 and earlier follow the legacy hybrid calendar and we need to - // rebase the INT96 timestamp values. - // Files written by Spark 3.1 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.1.0" || lookupFileMeta("org.apache.spark.legacyINT96") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - - /** - * NOTE: This method was copied from Spark 3.2.0, and is required to maintain runtime - * compatibility against Spark 3.2.0 - */ - // scalastyle:off - def datetimeRebaseMode(lookupFileMeta: String => String, - modeByConfig: String): LegacyBehaviorPolicy.Value = { - if (Utils.isTesting && SQLConf.get.getConfString("spark.test.forceNoRebase", "") == "true") { - return LegacyBehaviorPolicy.CORRECTED - } - // If there is no version, we return the mode specified by the config. - Option(lookupFileMeta(SPARK_VERSION_METADATA_KEY)).map { version => - // Files written by Spark 2.4 and earlier follow the legacy hybrid calendar and we need to - // rebase the datetime values. - // Files written by Spark 3.0 and latter may also need the rebase if they were written with - // the "LEGACY" rebase mode. - if (version < "3.0.0" || lookupFileMeta("org.apache.spark.legacyDateTime") != null) { - LegacyBehaviorPolicy.LEGACY - } else { - LegacyBehaviorPolicy.CORRECTED - } - }.getOrElse(LegacyBehaviorPolicy.withName(modeByConfig)) - } - // scalastyle:on - -} diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41LegacyHoodieParquetFileFormat.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41LegacyHoodieParquetFileFormat.scala index 8dff79c1e07b8..91b085b693aaa 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41LegacyHoodieParquetFileFormat.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41LegacyHoodieParquetFileFormat.scala @@ -187,7 +187,7 @@ class Spark41LegacyHoodieParquetFileFormat(private val shouldAppendPartitionValu } // When there are vectorized reads, we can avoid - // 1. opening the file twice by transfering the SeekableInputStream + // 1. opening the file twice by transferring the SeekableInputStream // 2. reading the footer twice by reading all row groups in advance and filter row groups // according to filters that require push down val openedFooter = ParquetFooterReader.openFileAndReadFooter(sharedConf, file, enableVectorizedReader) diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41ParquetReader.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41ParquetReader.scala index f5ccd7b54176f..d15520106e23c 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41ParquetReader.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark41ParquetReader.scala @@ -108,7 +108,7 @@ class Spark41ParquetReader(enableVectorizedReader: Boolean, partitionSchema, internalSchemaOpt) // When there are vectorized reads, we can avoid - // 1. opening the file twice by transfering the SeekableInputStream + // 1. opening the file twice by transferring the SeekableInputStream // 2. reading the footer twice by reading all row groups in advance and filter row groups // according to filters that require push down val originalFooter = diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/Spark41ResolveHudiAlterTableCommand.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/Spark41ResolveHudiAlterTableCommand.scala deleted file mode 100644 index 92cbc2803bf6b..0000000000000 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/Spark41ResolveHudiAlterTableCommand.scala +++ /dev/null @@ -1,68 +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.spark.sql.hudi - -import org.apache.hudi.common.config.HoodieCommonConfig -import org.apache.hudi.internal.schema.action.TableChange.ColumnChangeID - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.analysis.ResolvedTable -import org.apache.spark.sql.catalyst.plans.logical._ -import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table -import org.apache.spark.sql.hudi.command.{AlterTableCommand => HudiAlterTableCommand} - -/** - * Rule to mostly resolve, normalize and rewrite column names based on case sensitivity. - * for alter table column commands. - */ -class Spark41ResolveHudiAlterTableCommand(sparkSession: SparkSession) extends Rule[LogicalPlan] { - - def apply(plan: LogicalPlan): LogicalPlan = { - if (ProvidesHoodieConfig.isSchemaEvolutionEnabled(sparkSession)) { - plan.resolveOperatorsUp { - case set@SetTableProperties(ResolvedHoodieV2TablePlan(t), _) if set.resolved => - HudiAlterTableCommand(t.v1Table, set.changes, ColumnChangeID.PROPERTY_CHANGE) - case unSet@UnsetTableProperties(ResolvedHoodieV2TablePlan(t), _, _) if unSet.resolved => - HudiAlterTableCommand(t.v1Table, unSet.changes, ColumnChangeID.PROPERTY_CHANGE) - case drop@DropColumns(ResolvedHoodieV2TablePlan(t), _, _) if drop.resolved => - HudiAlterTableCommand(t.v1Table, drop.changes, ColumnChangeID.DELETE) - case add@AddColumns(ResolvedHoodieV2TablePlan(t), _) if add.resolved => - HudiAlterTableCommand(t.v1Table, add.changes, ColumnChangeID.ADD) - case renameColumn@RenameColumn(ResolvedHoodieV2TablePlan(t), _, _) if renameColumn.resolved => - HudiAlterTableCommand(t.v1Table, renameColumn.changes, ColumnChangeID.UPDATE) - case alter@AlterColumns(ResolvedHoodieV2TablePlan(t), _) if alter.resolved => - HudiAlterTableCommand(t.v1Table, alter.changes, ColumnChangeID.UPDATE) - case replace@ReplaceColumns(ResolvedHoodieV2TablePlan(t), _) if replace.resolved => - HudiAlterTableCommand(t.v1Table, replace.changes, ColumnChangeID.REPLACE) - } - } else { - plan - } - } - - object ResolvedHoodieV2TablePlan { - def unapply(plan: LogicalPlan): Option[HoodieInternalV2Table] = { - plan match { - case ResolvedTable(_, _, v2Table: HoodieInternalV2Table, _) => Some(v2Table) - case _ => None - } - } - } -} - diff --git a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark41Analysis.scala b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark41Analysis.scala index 33dc723543e94..1a57c29767193 100644 --- a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark41Analysis.scala +++ b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark41Analysis.scala @@ -20,20 +20,16 @@ package org.apache.spark.sql.hudi.analysis import org.apache.hudi.{DefaultSource, EmptyRelation, HoodieBaseRelation} import org.apache.hudi.SparkAdapterSupport.sparkAdapter -import org.apache.spark.sql.{AnalysisException, SparkSession} -import org.apache.spark.sql.catalyst.analysis.{ResolveInsertionBase, TableOutputResolver} -import org.apache.spark.sql.catalyst.catalog.{CatalogTable, HiveTableRelation} +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.catalog.HiveTableRelation import org.apache.spark.sql.catalyst.plans.logical._ import org.apache.spark.sql.catalyst.rules.Rule -import org.apache.spark.sql.errors.DataTypeErrors.toSQLId -import org.apache.spark.sql.errors.QueryCompilationErrors -import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation, PreprocessTableInsertion} +import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, LogicalRelation} import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.hudi.ProvidesHoodieConfig import org.apache.spark.sql.hudi.catalog.HoodieInternalV2Table import org.apache.spark.sql.sources.InsertableRelation import org.apache.spark.sql.types.StructType -import org.apache.spark.sql.util.PartitioningUtils.normalizePartitionSpec /** * NOTE: PLEASE READ CAREFULLY @@ -74,28 +70,11 @@ case class HoodieSpark41DataSourceV2ToV1Fallback(sparkSession: SparkSession) ext } /** - * In Spark 3.5, the following Resolution rules are removed, - * [[ResolveUserSpecifiedColumns]] and [[ResolveDefaultColumns]] - * (see code changes in [[org.apache.spark.sql.catalyst.analysis.Analyzer]] - * from https://github.com/apache/spark/pull/41262). - * The same logic of resolving the user specified columns and default values, - * which are required for a subset of columns as user specified compared to the table - * schema to work properly, are deferred to [[PreprocessTableInsertion]] for v1 INSERT. - * - * Note that [[HoodieAnalysis]] intercepts the [[InsertIntoStatement]] after Spark's built-in - * Resolution rules are applies, the logic of resolving the user specified columns and default - * values may no longer be applied. To make INSERT with a subset of columns specified by user - * to work, this custom resolution rule [[HoodieSpark41ResolveColumnsForInsertInto]] is added - * to achieve the same, before converting [[InsertIntoStatement]] into - * [[InsertIntoHoodieTableCommand]]. - * - * The implementation is copied and adapted from [[PreprocessTableInsertion]] - * https://github.com/apache/spark/blob/d061aadf25fd258d2d3e7332a489c9c24a2b5530/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala#L373 - * - * Also note that, the project logic in [[ResolveImplementationsEarly]] for INSERT is still - * needed in the case of INSERT with all columns in a different ordering. + * Resolution rule resolving the user specified columns and default values of + * [[InsertIntoStatement]] for Spark 4.1; see [[HoodieSpark4ResolveColumnsForInsertInto]] + * for the shared preprocessing logic and the rationale. */ -case class HoodieSpark41ResolveColumnsForInsertInto() extends ResolveInsertionBase { +case class HoodieSpark41ResolveColumnsForInsertInto() extends HoodieSpark4ResolveColumnsForInsertInto { // NOTE: This is copied from [[PreprocessTableInsertion]] with additional handling of Hudi relations override def apply(plan: LogicalPlan): LogicalPlan = { plan match { @@ -122,90 +101,4 @@ case class HoodieSpark41ResolveColumnsForInsertInto() extends ResolveInsertionBa case _ => plan } } - - private def preprocess(insert: InsertIntoStatement, - catalogTable: Option[CatalogTable]): InsertIntoStatement = { - preprocess(insert, catalogTable, catalogTable.map(_.partitionSchema).getOrElse(new StructType())) - } - - private def preprocess(insert: InsertIntoStatement, - catalogTable: Option[CatalogTable], - partitionSchema: StructType): InsertIntoStatement = { - val tblName = catalogTable.map(_.identifier.quotedString).getOrElse("unknown") - preprocess(insert, tblName, partitionSchema, catalogTable) - } - - // NOTE: this is copied from [[PreprocessTableInsertion]] with additional logic - // to unset user-specified columns at the end - private def preprocess(insert: InsertIntoStatement, - tblName: String, - partColNames: StructType, - catalogTable: Option[CatalogTable]): InsertIntoStatement = { - - val normalizedPartSpec = normalizePartitionSpec( - insert.partitionSpec, partColNames, tblName, conf.resolver) - - val staticPartCols = normalizedPartSpec.filter(_._2.isDefined).keySet - val expectedColumns = insert.table.output.filterNot(a => staticPartCols.contains(a.name)) - - val partitionsTrackedByCatalog = catalogTable.isDefined && - catalogTable.get.partitionColumnNames.nonEmpty && - catalogTable.get.tracksPartitionsInCatalog - if (partitionsTrackedByCatalog && normalizedPartSpec.nonEmpty) { - // empty partition column value - if (normalizedPartSpec.values.flatten.exists(v => v != null && v.isEmpty)) { - val spec = normalizedPartSpec.map(p => p._1 + "=" + p._2).mkString("[", ", ", "]") - throw QueryCompilationErrors.invalidPartitionSpecError( - s"The spec ($spec) contains an empty partition column value") - } - } - - // Create a project if this INSERT has a user-specified column list. - val hasColumnList = insert.userSpecifiedCols.nonEmpty - val query = if (hasColumnList) { - createProjectForByNameQuery(tblName, insert) - } else { - insert.query - } - val newQuery = try { - TableOutputResolver.resolveOutputColumns( - tblName, - expectedColumns, - query, - byName = hasColumnList || insert.byName, - conf, - supportColDefaultValue = true) - } catch { - case e: AnalysisException if staticPartCols.nonEmpty && - (e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.NOT_ENOUGH_DATA_COLUMNS" || - e.getErrorClass == "INSERT_COLUMN_ARITY_MISMATCH.TOO_MANY_DATA_COLUMNS") => - val newException = e.copy( - errorClass = Some("INSERT_PARTITION_COLUMN_ARITY_MISMATCH"), - messageParameters = e.messageParameters ++ Map( - "tableColumns" -> insert.table.output.map(c => toSQLId(c.name)).mkString(", "), - "staticPartCols" -> staticPartCols.toSeq.sorted.map(c => toSQLId(c)).mkString(", ") - )) - newException.setStackTrace(e.getStackTrace) - throw newException - } - if (normalizedPartSpec.nonEmpty) { - if (normalizedPartSpec.size != partColNames.length) { - throw QueryCompilationErrors.requestedPartitionsMismatchTablePartitionsError( - tblName, normalizedPartSpec, partColNames) - } - - // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] - // and the user specified is no longer need after resolution - // (`userSpecifiedCols = Seq()`) - insert.copy(query = newQuery, partitionSpec = normalizedPartSpec, userSpecifiedCols = Seq()) - } else { - // All partition columns are dynamic because the InsertIntoTable command does - // not explicitly specify partitioning columns. - // NOTE: Hudi converts [[InsertIntoStatement]] to [[InsertIntoHoodieTableCommand]] - // and the user specified is no longer need after resolution - // (`userSpecifiedCols = Seq()`) - insert.copy(query = newQuery, partitionSpec = partColNames.map(_.name).map(_ -> None).toMap, - userSpecifiedCols = Seq()) - } - } } diff --git a/hudi-sync/hudi-adb-sync/pom.xml b/hudi-sync/hudi-adb-sync/pom.xml index c545aea9b2875..39772a1af1738 100644 --- a/hudi-sync/hudi-adb-sync/pom.xml +++ b/hudi-sync/hudi-adb-sync/pom.xml @@ -103,7 +103,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl org.slf4j diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveStylePartitionValueExtractor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveStylePartitionValueExtractor.java index 11098698e8aeb..41ccaa59d486d 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveStylePartitionValueExtractor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveStylePartitionValueExtractor.java @@ -34,7 +34,9 @@ public class HiveStylePartitionValueExtractor implements PartitionValueExtractor @Override public List extractPartitionValuesInPath(String partitionPath) { // partition path is expected to be in this format partition_key=partition_value. - String[] splits = partitionPath.split("="); + // Split on the first '=' only so a value that itself contains '=' (e.g. base64 padding) + // is kept intact rather than making the split produce more than two parts. + String[] splits = partitionPath.split("=", 2); if (splits.length != 2) { throw new IllegalArgumentException( "Partition path " + partitionPath + " is not in the form partition_key=partition_value."); diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java index 418676d591627..14afa81ea30f7 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncConfigHolder.java @@ -122,6 +122,28 @@ public class HiveSyncConfigHolder { .defaultValue(1000) .markAdvanced() .withDocumentation("The number of partitions one batch when synchronous partitions to hive."); + public static final ConfigProperty HIVE_SYNC_BATCHING_ENABLED = ConfigProperty + .key("hoodie.datasource.hive_sync.batching.enabled") + .defaultValue(false) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("Only applies to HiveQL sync mode; has no effect in HMS or JDBC mode. When true, " + + "ADD, TOUCH, and SET_LOCATION partition statements are dispatched in parallel across a pool of " + + "Hive Driver workers, with ADD and TOUCH additionally split into batches of " + + "`hoodie.datasource.hive_sync.batch_num` partitions per statement (ADD was already batched " + + "before this flag existed; only its dispatch becomes parallel here). SET_LOCATION remains one " + + "statement per partition, as Hive SQL has no multi-partition form. DROP remains serial. " + + "Table-level statements (create/alter table, last commit time, writer version) continue to run " + + "on the single session Driver. Default off; the default HiveQL path is unchanged unless " + + "explicitly opted in."); + public static final ConfigProperty HIVE_SYNC_BATCHING_THREADS = ConfigProperty + .key("hoodie.datasource.hive_sync.batching.threads") + .defaultValue(4) + .markAdvanced() + .sinceVersion("1.3.0") + .withDocumentation("Pool size (number of Hive Driver workers) and worker-thread count for parallel " + + "HiveQL partition dispatch when `hoodie.datasource.hive_sync.batching.enabled` is true. " + + "Ignored otherwise."); public static final ConfigProperty HIVE_SYNC_MODE = ConfigProperty .key("hoodie.datasource.hive_sync.mode") .noDefaultValue() diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java index 2a130d8ee7972..62c349b4ae0b0 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java @@ -26,6 +26,7 @@ import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.ConfigUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.VisibleForTesting; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.InvalidTableException; import org.apache.hudi.sync.common.HoodieSyncClient; @@ -51,6 +52,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static org.apache.hudi.common.table.timeline.InstantComparison.LESSER_THAN; +import static org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps; import static org.apache.hudi.common.util.StringUtils.nonEmpty; import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getInputFormatClassName; import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getOutputFormatClassName; @@ -211,7 +214,14 @@ protected void doSync() { syncHoodieTable(snapshotTableName, true, false); // sync origin table for MOR if (config.getBoolean(META_SYNC_SNAPSHOT_WITH_TABLE_NAME)) { - syncHoodieTable(tableName, true, false); + if (config.getBoolean(HIVE_SKIP_RO_SUFFIX_FOR_READ_OPTIMIZED_TABLE)) { + log.warn("{}=true claims the bare table name '{}' for the read-optimized view; " + + "ignoring {} for this table (the real-time view remains registered as '{}').", + HIVE_SKIP_RO_SUFFIX_FOR_READ_OPTIMIZED_TABLE.key(), tableId(databaseName, tableName), + META_SYNC_SNAPSHOT_WITH_TABLE_NAME.key(), snapshotTableName); + } else { + syncHoodieTable(tableName, true, false); + } } } break; @@ -268,7 +278,8 @@ protected void syncHoodieTable(String tableName, boolean useRealtimeInputFormat, boolean partitionsChanged = validateAndSyncPartitions(tableName, tableExists); boolean meetSyncConditions = schemaChanged || propertiesChanged || partitionsChanged; - if (!config.getBoolean(META_SYNC_CONDITIONAL_SYNC) || meetSyncConditions) { + if (!config.getBoolean(META_SYNC_CONDITIONAL_SYNC) || meetSyncConditions + || isLastCommitTimeSyncedBehindTimelineMidpoint(tableName)) { syncClient.updateLastCommitTimeSynced(tableName); } syncClient.updateHoodieWriterVersion(tableName); @@ -283,6 +294,28 @@ protected void syncHoodieTable(String tableName, boolean useRealtimeInputFormat, } } + /** + * Whether last commit time synced trails the midpoint of the completed commit instants. + * Advancing it at the midpoint bounds how far it can fall behind, capping the archived-timeline + * scans a stale value forces on every conditional-sync round. + */ + @VisibleForTesting + boolean isLastCommitTimeSyncedBehindTimelineMidpoint(String tableName) { + Option lastCommitTimeSynced = syncClient.getLastCommitTimeSynced(tableName); + if (!lastCommitTimeSynced.isPresent()) { + return false; + } + // Completed commits only: getCommitsTimeline() excludes non-commit actions (clean, rollback), + // and filterCompletedInstants() excludes inflight instants. + List completedCommits = + syncClient.getMetaClient().getCommitsTimeline().filterCompletedInstants().getInstants(); + if (completedCommits.isEmpty()) { + return false; + } + String midpointInstantTime = completedCommits.get(completedCommits.size() / 2).requestedTime(); + return compareTimestamps(lastCommitTimeSynced.get(), LESSER_THAN, midpointInstantTime); + } + private boolean isAlreadySynced(String tableName) { return syncClient.getLastCommitTimeSynced(tableName) .map(lastCommit -> { @@ -393,7 +426,8 @@ private Map getTableProperties(HoodieSchema schema) { Map tableProperties = ConfigUtils.toMap(config.getString(HIVE_TABLE_PROPERTIES)); if (config.getBoolean(HIVE_SYNC_AS_DATA_SOURCE_TABLE)) { Map sparkTableProperties = SparkDataSourceTableUtils.getSparkTableProperties(config.getSplitStrings(META_SYNC_PARTITION_FIELDS), - config.getStringOrDefault(META_SYNC_SPARK_VERSION), config.getIntOrDefault(HIVE_SYNC_SCHEMA_STRING_LENGTH_THRESHOLD), schema); + config.getStringOrDefault(META_SYNC_SPARK_VERSION), config.getIntOrDefault(HIVE_SYNC_SCHEMA_STRING_LENGTH_THRESHOLD), schema, + config.getBoolean(HIVE_SYNC_COMMENT)); tableProperties.putAll(sparkTableProperties); } return tableProperties; @@ -555,7 +589,7 @@ private boolean syncPartitions(String tableName, List partitionE List touchPartitions = config.getBoolean(META_SYNC_TOUCH_PARTITIONS_ENABLED) ? filterPartitions(partitionEventList, PartitionEventType.TOUCH) : Collections.emptyList(); if (!touchPartitions.isEmpty()) { - log.info("Touch Partitions " + touchPartitions); + log.info("Touch Partitions {}", touchPartitions); syncClient.touchPartitionsToTable(tableName, touchPartitions); } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java index 1bb34bf3d7840..44968ca3bc37f 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HoodieHiveSyncClient.java @@ -38,6 +38,7 @@ import org.apache.hudi.hive.ddl.HiveSyncMode; import org.apache.hudi.hive.ddl.JDBCBasedMetadataOperator; import org.apache.hudi.hive.ddl.JDBCExecutor; +import org.apache.hudi.hive.util.HiveDriverPool; import org.apache.hudi.hive.util.IMetaStoreClientUtil; import org.apache.hudi.hive.util.PartitionFilterGenerator; import org.apache.hudi.sync.common.HoodieSyncClient; @@ -66,6 +67,8 @@ import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getInputFormatClassName; import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getOutputFormatClassName; import static org.apache.hudi.hadoop.utils.HoodieInputFormatUtils.getSerDeClassName; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_THREADS; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_MODE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_USE_SPARK_CATALOG; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_USE_JDBC; @@ -86,6 +89,10 @@ public class HoodieHiveSyncClient extends HoodieSyncClient { private final Map initialTableByName = new HashMap<>(); DDLExecutor ddlExecutor; private IMetaStoreClient client; + // Present only when HIVE_SYNC_BATCHING_ENABLED and sync mode is HIVEQL (explicit + // or legacy default). Owned by HiveQueryDDLExecutor; this field is kept for + // reference only — close() is delegated through ddlExecutor.close(). + private Option partitionDriverPool = Option.empty(); /** * JDBC-based metadata operator, lazily initialized on first Thrift @@ -124,7 +131,8 @@ public HoodieHiveSyncClient(HiveSyncConfig config, HoodieTableMetaClient metaCli ddlExecutor = new HMSDDLExecutor(config, this.client); break; case HIVEQL: - ddlExecutor = new HiveQueryDDLExecutor(config, this.client); + this.partitionDriverPool = maybeBuildHiveDriverPool(config); + ddlExecutor = new HiveQueryDDLExecutor(config, this.client, this.partitionDriverPool); break; case JDBC: JDBCExecutor jdbcExecutor = new JDBCExecutor(config); @@ -142,14 +150,32 @@ public HoodieHiveSyncClient(HiveSyncConfig config, HoodieTableMetaClient metaCli jdbcMetadataOperator = new JDBCBasedMetadataOperator( jdbcExecutor.getConnection(), databaseName); } else { - ddlExecutor = new HiveQueryDDLExecutor(config, this.client); + this.partitionDriverPool = maybeBuildHiveDriverPool(config); + ddlExecutor = new HiveQueryDDLExecutor(config, this.client, this.partitionDriverPool); } } } catch (Exception e) { + // The pool owns live daemon threads and Hive Drivers, and is built before the + // executor that would otherwise own its lifecycle. Any throw between those two + // points would leak it -- notably QueryBasedDDLExecutor's super(config), which + // runs the PartitionValueExtractor reflection before HiveQueryDDLExecutor's own + // try block is even entered. Closing here covers every such window; the pool's + // close() is idempotent, so overlapping with the executor's cleanup is harmless. + closePartitionDriverPoolQuietly(); throw new HoodieHiveSyncException("Failed to create HiveMetaStoreClient", e); } } + private void closePartitionDriverPoolQuietly() { + partitionDriverPool.ifPresent(pool -> { + try { + pool.close(); + } catch (Exception e) { + log.warn("Error closing HiveDriverPool during failed sync client construction", e); + } + }); + } + /** * Returns true if Thrift API was detected as incompatible and JDBC * fallback is available. When true, metadata operations should use @@ -201,6 +227,14 @@ private IMetaStoreClient createMetaStoreClient(HiveSyncConfig config) { } } + private Option maybeBuildHiveDriverPool(HiveSyncConfig config) { + if (!config.getBooleanOrDefault(HIVE_SYNC_BATCHING_ENABLED)) { + return Option.empty(); + } + int size = config.getIntOrDefault(HIVE_SYNC_BATCHING_THREADS); + return Option.of(new HiveDriverPool(config, size)); + } + private Table getInitialTable(String table) { return initialTableByName.computeIfAbsent(table, t -> { try { @@ -598,6 +632,18 @@ public void close() { try { ddlExecutor.close(); if (client != null) { + // Close the proxied IMetaStoreClient directly before Hive.closeCurrent(). + // When RetryingMetaStoreClient rebuilds the underlying client on a transient + // TException, the fresh MSC is reachable only through this proxy, while the + // thread-local Hive singleton still references the older instance. So + // Hive.closeCurrent() alone closes the stale MSC and orphans the retry-created + // one, leaking a connection per sync cycle. client.close() releases the live + // MSC by identity; Hive.closeCurrent() remains a fallback for the singleton path. + try { + client.close(); + } catch (Exception e) { + log.warn("Failed to close IMetaStoreClient directly; Hive.closeCurrent() will run anyway", e); + } Hive.closeCurrent(); client = null; } @@ -697,6 +743,17 @@ public boolean updateTableComments(String tableName, List fromMetas } } }); + if (!ddlExecutor.supportsUpdatingPartitionColumnComments()) { + List skippedPartitionFields = config.getSplitStrings(META_SYNC_PARTITION_FIELDS).stream() + .map(partitionField -> partitionField.toLowerCase(Locale.ROOT)) + .filter(alterComments::containsKey) + .collect(Collectors.toList()); + if (!skippedPartitionFields.isEmpty()) { + log.debug("Cannot update comments of partition columns {} of {} in query based sync modes, use hms sync mode instead", + skippedPartitionFields, tableName); + skippedPartitionFields.forEach(alterComments::remove); + } + } if (alterComments.isEmpty()) { log.info("No comment difference of {} ", tableName); return false; diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/MultiPartKeysValueExtractor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/MultiPartKeysValueExtractor.java index dd356638a47e6..5404649a5fd6f 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/MultiPartKeysValueExtractor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/MultiPartKeysValueExtractor.java @@ -42,7 +42,9 @@ public List extractPartitionValuesInPath(String partitionPath) { String[] splits = partitionPath.split("/"); return Arrays.stream(splits).map(s -> { if (s.contains("=")) { - String[] moreSplit = s.split("="); + // Split on the first '=' only so partition values that themselves contain '=' + // (e.g. base64 padding like "col=YWJj==") are preserved instead of being rejected or truncated. + String[] moreSplit = s.split("=", 2); ValidationUtils.checkArgument(moreSplit.length == 2, "Partition Field (" + s + ") not in expected format"); return moreSplit[1]; } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/DDLExecutor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/DDLExecutor.java index d1e0e03aca3f3..a520bf6c1e945 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/DDLExecutor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/DDLExecutor.java @@ -108,4 +108,11 @@ void createTable(String tableName, HoodieSchema storageSchema, String inputForma * @param newSchema Map key: field name, Map value: [field type, field comment] */ void updateTableComments(String tableName, Map> newSchema); + + /** + * @return whether this executor can update the comments of partition columns of an existing table. + */ + default boolean supportsUpdatingPartitionColumnComments() { + return true; + } } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HMSDDLExecutor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HMSDDLExecutor.java index 6b7bd3af00963..b97bc8010d75f 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HMSDDLExecutor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HMSDDLExecutor.java @@ -52,12 +52,14 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_CREATE_MANAGED_TABLE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SUPPORT_TIMESTAMP_TYPE; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_COMMENT; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_BASE_PATH; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_EXTRACTOR_CLASS; @@ -110,11 +112,19 @@ public void createTable(String tableName, HoodieSchema storageSchema, String inp String partitionKeyType = HiveSchemaUtil.getPartitionKeyType(mapSchema, partitionKey); return new FieldSchema(partitionKey, partitionKeyType.toLowerCase(), ""); }).collect(Collectors.toList()); + + if (syncConfig.getBoolean(HIVE_SYNC_COMMENT)) { + Map fieldDocs = HiveSchemaUtil.getFieldDocs(storageSchema); + applyFieldDocs(fieldSchema, fieldDocs); + applyFieldDocs(partitionSchema, fieldDocs); + } Table newTb = new Table(); newTb.setDbName(databaseName); newTb.setTableName(tableName); newTb.setOwner(UserGroupInformation.getCurrentUser().getShortUserName()); - newTb.setCreateTime((int) System.currentTimeMillis()); + // Hive stores createTime as seconds since the epoch in an i32 field; passing raw + // milliseconds both uses the wrong unit and overflows the int cast. + newTb.setCreateTime((int) (System.currentTimeMillis() / 1000)); StorageDescriptor storageDescriptor = new StorageDescriptor(); storageDescriptor.setCols(fieldSchema); storageDescriptor.setInputFormat(inputFormatClass); @@ -266,13 +276,9 @@ public void updateTableComments(String tableName, Map fields, Map> alterSchema) { + for (FieldSchema fieldSchema : fields) { + if (alterSchema.containsKey(fieldSchema.getName())) { + String comment = alterSchema.get(fieldSchema.getName()).getRight(); + fieldSchema.setComment(comment); + } + } + } + + private static void applyFieldDocs(List fields, Map fieldDocs) { + for (FieldSchema fieldSchema : fields) { + String doc = fieldDocs.get(fieldSchema.getName().toLowerCase(Locale.ROOT)); + if (doc != null) { + fieldSchema.setComment(doc); + } + } + } + @Override public void close() { if (client != null) { diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java index 25434d29eb3ff..c853313182ed1 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/HiveQueryDDLExecutor.java @@ -19,8 +19,10 @@ package org.apache.hudi.hive.ddl; import org.apache.hudi.common.util.HoodieTimer; +import org.apache.hudi.common.util.Option; import org.apache.hudi.hive.HiveSyncConfig; import org.apache.hudi.hive.HoodieHiveSyncException; +import org.apache.hudi.hive.util.HiveDriverPool; import org.apache.hudi.hive.util.HivePartitionUtil; import lombok.extern.slf4j.Slf4j; @@ -41,6 +43,7 @@ import java.util.Map; import java.util.stream.Collectors; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM; import static org.apache.hudi.sync.common.util.TableUtils.tableId; /** @@ -52,10 +55,20 @@ public class HiveQueryDDLExecutor extends QueryBasedDDLExecutor { private final IMetaStoreClient metaStoreClient; private SessionState sessionState; private Driver hiveDriver; + // When present, partition-phase SQL lists fan out across this pool; table-level SQL + // (createTable, schema evolution, single-statement runSQL callers) always uses the + // session `hiveDriver` above. See HiveDriverPool javadoc. + private final Option driverPool; public HiveQueryDDLExecutor(HiveSyncConfig config, IMetaStoreClient metaStoreClient) { + this(config, metaStoreClient, Option.empty()); + } + + public HiveQueryDDLExecutor(HiveSyncConfig config, IMetaStoreClient metaStoreClient, + Option driverPool) { super(config); this.metaStoreClient = metaStoreClient; + this.driverPool = driverPool; try { this.sessionState = new SessionState(config.getHiveConf(), UserGroupInformation.getCurrentUser().getShortUserName()); @@ -73,6 +86,15 @@ public HiveQueryDDLExecutor(HiveSyncConfig config, IMetaStoreClient metaStoreCli if (this.hiveDriver != null) { this.hiveDriver.close(); } + // driverPool (if present) was already constructed by the caller before this + // ctor ran; since we're about to throw, no one else will call close() on it. + driverPool.ifPresent(pool -> { + try { + pool.close(); + } catch (Exception poolCloseException) { + log.error("Error while closing HiveDriverPool", poolCloseException); + } + }); throw new HoodieHiveSyncException("Failed to create HiveQueryDDL object", e); } } @@ -82,19 +104,74 @@ public void runSQL(String sql) { updateHiveSQLs(Collections.singletonList(sql)); } + /** + * Partition-phase SQL fan-out. When the driver pool is present, any leading + * {@code USE database} statements are run on every worker (Hive 2.x's + * ALTER PARTITION SET LOCATION ignores db.table qualifiers and uses the + * connection's current database, so each worker needs to USE the right db + * before any partition ALTER). The remaining statements are then dispatched + * round-robin across the pool. Falls through to the sequential path on the + * session Driver when no pool is configured. + */ + @Override + protected void runSQLs(List sqls) { + if (sqls.isEmpty()) { + return; + } + if (!driverPool.isPresent()) { + updateHiveSQLs(sqls); + return; + } + HiveDriverPool pool = driverPool.get(); + int useStatementCount = 0; + while (useStatementCount < sqls.size() && isUseStatement(sqls.get(useStatementCount))) { + useStatementCount++; + } + if (useStatementCount > 0) { + List setupStatements = sqls.subList(0, useStatementCount); + pool.runOnEachWorker(setupStatements); + } + List partitionStatements = sqls.subList(useStatementCount, sqls.size()); + if (partitionStatements.isEmpty()) { + return; + } + pool.awaitAll(pool.dispatchAll(partitionStatements)); + } + + /** + * Splits TOUCH into batches of {@code HIVE_BATCH_SYNC_PARTITION_NUM} only when a + * driver pool is actually present — i.e. only when {@link #runSQLs(List)} will + * dispatch those batches in parallel. Keyed on pool presence rather than on the + * {@code batching.enabled} config so the split can never take effect on a path + * that would just execute the batches serially (the base class, and therefore + * {@code JDBCExecutor}, always emits one statement). + */ + @Override + protected int getTouchBatchSize(int partitionCount) { + return driverPool.isPresent() + ? config.getIntOrDefault(HIVE_BATCH_SYNC_PARTITION_NUM) : partitionCount; + } + + // Strict 4-char prefix match on "USE ". Internal callers (constructPartitionAlterStatements) + // always emit the USE statement without leading whitespace; do not call with externally + // supplied SQL that might be padded. + private static boolean isUseStatement(String sql) { + return sql != null && sql.regionMatches(true, 0, "USE ", 0, 4); + } + private List updateHiveSQLs(List sqls) { List responses = new ArrayList<>(); + HoodieTimer timer = HoodieTimer.start(); try { for (String sql : sqls) { if (hiveDriver != null) { - HoodieTimer timer = HoodieTimer.start(); responses.add(hiveDriver.run(sql)); - log.info("Time taken to execute [{}]: {} ms", sql, timer.endTimer()); } } } catch (Exception e) { throw new HoodieHiveSyncException("Failed in executing SQL", e); } + log.info("Executed {} SQL statements sequentially in {} ms", sqls.size(), timer.endTimer()); return responses; } @@ -149,6 +226,16 @@ public void dropPartitionsToTable(String tableName, List partitionsToDro @Override public void close() { + // Close the pool first so the worker threads stop dispatching against their + // Drivers before we tear down anything else. The pool's close() runs + // Driver/SessionState cleanup on each worker's own thread. + driverPool.ifPresent(pool -> { + try { + pool.close(); + } catch (Exception e) { + log.warn("Error closing HiveDriverPool", e); + } + }); if (metaStoreClient != null) { Hive.closeCurrent(); } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java index 7f776f2f7a04d..3bcbbe1841dfa 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/ddl/QueryBasedDDLExecutor.java @@ -20,6 +20,7 @@ import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.common.util.PartitionPathEncodeUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.Pair; @@ -75,6 +76,35 @@ public QueryBasedDDLExecutor(HiveSyncConfig config) { */ public abstract void runSQL(String sql); + /** + * Runs a list of SQL statements. The default implementation executes them + * sequentially via {@link #runSQL(String)}. Subclasses that can parallelize + * (e.g. {@link HiveQueryDDLExecutor} with a driver pool) override this hook + * to fan the list out across workers. The contract requires that the list + * has no positional dependencies — callers must fully qualify table names + * with {@code `db`.`tbl`} so any statement can run on any worker. + */ + protected void runSQLs(List sqls) { + for (String sql : sqls) { + runSQL(sql); + } + } + + /** + * Number of partitions to pack into a single {@code ALTER TABLE ... TOUCH} statement. + * + *

    The base implementation returns {@code partitionCount}, i.e. one statement + * covering every partition — the long-standing behavior, and the only correct choice + * when {@link #runSQLs(List)} executes the list serially. Splitting a TOUCH into + * several statements changes failure semantics (a mid-list failure leaves some + * partitions touched and some not), so it is only worth doing when the resulting + * statements are actually dispatched in parallel. Subclasses that parallelize + * override this; see {@link HiveQueryDDLExecutor}. + */ + protected int getTouchBatchSize(int partitionCount) { + return partitionCount; + } + @Override public void createDatabase(String databaseName) { runSQL("create database if not exists " + databaseName); @@ -120,7 +150,7 @@ public void addPartitionsToTable(String tableName, List partitionsToAdd) } log.info("Adding partitions {} to table {}", partitionsToAdd.size(), tableName); List sqls = constructAddPartitions(tableName, partitionsToAdd); - sqls.stream().forEach(sql -> runSQL(sql)); + runSQLs(sqls); } @Override @@ -131,9 +161,7 @@ public void updatePartitionsToTable(String tableName, List changedPartit } log.info("Changing partitions {} on {}", changedPartitions.size(), tableName); List sqls = constructPartitionAlterStatements(tableName, changedPartitions, PartitionAlterType.SET_LOCATION); - for (String sql : sqls) { - runSQL(sql); - } + runSQLs(sqls); } @Override @@ -142,8 +170,7 @@ public void updateTableComments(String tableName, Map constructAddPartitions(String tableName, List partitions) { List result = new ArrayList<>(); int batchSyncPartitionNum = config.getIntOrDefault(HIVE_BATCH_SYNC_PARTITION_NUM); @@ -205,34 +239,48 @@ public String getPartitionClause(String partition) { @Override public void touchPartitionsToTable(String tableName, List touchPartitions) { if (touchPartitions.isEmpty()) { - log.info("No partitions to touch for " + tableName); + log.info("No partitions to touch for {}", tableName); return; } - log.info("Touching partitions " + touchPartitions.size() + " on " + tableName); + log.info("Touching partitions {} on {}", touchPartitions.size(), tableName); List sqls = constructPartitionAlterStatements(tableName, touchPartitions, PartitionAlterType.TOUCH); - for (String sql : sqls) { - runSQL(sql); - } + runSQLs(sqls); } /** * Builds SQL statements to either touch partitions or set their location. - * TOUCH: one ALTER TABLE ... TOUCH PARTITION (p1) PARTITION (p2) ... - * SET_LOCATION: one ALTER TABLE ... PARTITION (p) SET LOCATION '...' per partition. + * + *

    The first element of the returned list is always a {@code USE database} + * statement. Hive 2.x's ALTER PARTITION ... SET LOCATION does not respect the + * {@code db.table} qualifier (silently routes to the connection's current + * database), so the {@code USE} is load-bearing. Parallel execution paths must + * run this statement on every worker before fanning out the rest. + * + *

    TOUCH: one {@code ALTER TABLE ... TOUCH PARTITION (p1) ...} statement per + * batch of {@link #getTouchBatchSize(int)} partitions. The base implementation + * returns the full partition count, i.e. a single statement covering everything, + * matching pre-batching behavior. Only subclasses that actually dispatch the + * resulting statements in parallel override it to split. + * + *

    SET_LOCATION: one {@code ALTER TABLE ... PARTITION (p) SET LOCATION '...'} + * per partition (Hive SQL does not support multi-partition SET LOCATION in one + * statement). */ private List constructPartitionAlterStatements(String tableName, List partitions, PartitionAlterType alterType) { List result = new ArrayList<>(); - // Hive 2.x doesn't like db.table name for operations, hence we need to change to using the database first String useDatabase = "USE " + HIVE_ESCAPE_CHARACTER + databaseName + HIVE_ESCAPE_CHARACTER; result.add(useDatabase); String alterTablePrefix = "ALTER TABLE " + HIVE_ESCAPE_CHARACTER + tableName + HIVE_ESCAPE_CHARACTER; + int batchSyncPartitionNum = getTouchBatchSize(partitions.size()); switch (alterType) { case TOUCH: - String alterTable = alterTablePrefix + " TOUCH"; - for (String partition : partitions) { - alterTable += " PARTITION (" + getPartitionClause(partition) + ")"; + for (List batch : CollectionUtils.batches(partitions, batchSyncPartitionNum)) { + StringBuilder alterTable = new StringBuilder(alterTablePrefix).append(" TOUCH"); + for (String partition : batch) { + alterTable.append(" PARTITION (").append(getPartitionClause(partition)).append(")"); + } + result.add(alterTable.toString()); } - result.add(alterTable); break; case SET_LOCATION: for (String partition : partitions) { diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java index f91b660380447..0766419d677ab 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/Heartbeat.java @@ -19,25 +19,49 @@ package org.apache.hudi.hive.transaction.lock; -import org.apache.hudi.exception.HoodieLockException; - +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.NoSuchLockException; +import org.apache.hadoop.hive.metastore.api.NoSuchTxnException; +import org.apache.hadoop.hive.metastore.api.TxnAbortedException; + +import java.util.function.Consumer; +@Slf4j class Heartbeat implements Runnable { private final IMetaStoreClient client; private final long lockId; + private final Consumer onLockLost; + // Latches the terminal failure so that a tick already queued when the schedule was cancelled + // does not issue another doomed heartbeat or report the loss a second time. + private volatile boolean lockLost = false; - Heartbeat(IMetaStoreClient client, long lockId) { + Heartbeat(IMetaStoreClient client, long lockId, Consumer onLockLost) { this.client = client; this.lockId = lockId; + this.onLockLost = onLockLost; } @Override public void run() { + if (lockLost) { + return; + } try { client.heartbeat(0, lockId); + } catch (NoSuchLockException | NoSuchTxnException | TxnAbortedException e) { + // Terminal. The metastore has already expired or aborted this lock, so no later tick can + // renew it. Retrying would only log once per interval while the writer keeps believing it + // holds exclusivity, so report the loss to the owner, which stops the schedule and drops + // the lock. + lockLost = true; + onLockLost.accept(e); } catch (Exception e) { - throw new HoodieLockException(String.format("Failed to heartbeat for lock: %d", lockId)); + // Do not rethrow. This task is scheduled via ScheduledExecutorService.scheduleAtFixedRate, + // where a thrown exception permanently cancels all subsequent executions and is only + // observable through the (unread) ScheduledFuture. Swallowing a transient failure here keeps + // the lock heartbeated on the next tick instead of silently stopping renewal altogether. + log.warn("Failed to heartbeat for lock: {}", lockId, e); } } } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java index 80a95801fbdbc..a10302e1beddd 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProvider.java @@ -87,7 +87,11 @@ public class HiveMetastoreBasedLockProvider implements LockProvider future = null; + // Assigned by the acquiring thread, read and cancelled by the heartbeat thread. + private transient volatile ScheduledFuture future = null; + // Set when the metastore reports the lock as expired or aborted, so that a later unlock() can + // tell the caller its exclusivity was lost instead of silently doing nothing. + private volatile boolean lockLostRemotely = false; private final transient ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); public HiveMetastoreBasedLockProvider(final LockConfiguration lockConfiguration, final StorageConfiguration conf) { @@ -123,21 +127,35 @@ public boolean tryLock(long time, TimeUnit unit) { } catch (ExecutionException | InterruptedException | TimeoutException | TException e) { throw new HoodieLockException(generateLogStatement(FAILED_TO_ACQUIRE, generateLogSuffixString()), e); } - return this.lock != null && this.lock.getState() == LockState.ACQUIRED; + return isLockAcquired(); } + /** + * Releases the lock held at the metastore, if any. + * + *

    Unlike most providers, which stay silent when they do not believe they hold a lock, this one + * deliberately fails when the metastore had already expired or aborted the lock: the writer went + * on committing without exclusivity and has to hear about it. Note that {@code LockManager.unlock()} + * skips its metrics and its {@code close()} when the provider throws. + * + * @throws HoodieLockException if the metastore took the lock away, or if releasing it fails. + */ @Override public void unlock() { try { log.info(generateLogStatement(RELEASING, generateLogSuffixString())); LockResponse lockResponseLocal = lock; if (lockResponseLocal == null) { + if (lockLostRemotely) { + // The heartbeat already dropped the lock. Unlocking it would fail with a bare + // NoSuchLockException anyway, so fail with the actual reason instead. + throw new HoodieLockException(generateLogStatement(FAILED_TO_RELEASE, generateLogSuffixString()) + + ", the metastore had already expired or aborted it"); + } return; } lock = null; - if (future != null) { - future.cancel(false); - } + cancelHeartbeat(); hiveClient.unlock(lockResponseLocal.getLockid()); log.info(generateLogStatement(RELEASED, generateLogSuffixString())); } catch (TException e) { @@ -159,17 +177,22 @@ public void acquireLock(long time, TimeUnit unit) throws InterruptedException, E @Override public void close() { try { - if (lock != null) { - hiveClient.unlock(lock.getLockid()); - lock = null; - } - if (future != null) { - future.cancel(false); + // Snapshot the lock, then stop claiming it before releasing it, exactly as unlock() does: + // the release itself makes an in-flight heartbeat fail, and that must not be mistaken for + // the metastore taking the lock away. + LockResponse lockResponseLocal = lock; + lock = null; + cancelHeartbeat(); + if (lockResponseLocal != null) { + hiveClient.unlock(lockResponseLocal.getLockid()); } Hive.closeCurrent(); - executor.shutdown(); } catch (Exception e) { - log.error(generateLogStatement(org.apache.hudi.common.lock.LockState.FAILED_TO_RELEASE, generateLogSuffixString())); + log.error(generateLogStatement(org.apache.hudi.common.lock.LockState.FAILED_TO_RELEASE, generateLogSuffixString()), e); + } finally { + // Always release the heartbeat thread pool, even if unlock/closeCurrent above threw, + // otherwise its scheduled threads leak for the lifetime of the JVM. + executor.shutdown(); } } @@ -178,47 +201,101 @@ public boolean acquireLock(long time, TimeUnit unit, final LockComponent compone throws InterruptedException, ExecutionException, TimeoutException, TException { ValidationUtils.checkArgument(this.lock == null, ALREADY_ACQUIRED.name()); acquireLockInternal(time, unit, component); - return this.lock != null && this.lock.getState() == LockState.ACQUIRED; + return isLockAcquired(); + } + + /** + * Whether the lock is held right now. Reads {@link #lock} once: the heartbeat thread clears it + * as soon as the metastore reports the lock as gone, so re-reading the field can mix two states. + */ + private boolean isLockAcquired() { + LockResponse lockResponseLocal = this.lock; + return lockResponseLocal != null && lockResponseLocal.getState() == LockState.ACQUIRED; } private void acquireLockInternal(long time, TimeUnit unit, LockComponent lockComponent) throws InterruptedException, ExecutionException, TimeoutException, TException { - LockRequest lockRequest = null; + lockLostRemotely = false; try { // TODO : FIX:Using the parameterized constructor throws MethodNotFound final LockRequestBuilder builder = new LockRequestBuilder(); - lockRequest = builder.addLockComponent(lockComponent).setUser(System.getProperty("user.name")).build(); + final LockRequest lockRequest = builder.addLockComponent(lockComponent).setUser(System.getProperty("user.name")).build(); lockRequest.setUserIsSet(true); - final LockRequest lockRequestFinal = lockRequest; - this.lock = executor.submit(() -> hiveClient.lock(lockRequestFinal)) + this.lock = executor.submit(() -> hiveClient.lock(lockRequest)) .get(time, unit); - - // refresh lock in case that certain commit takes a long time. - Heartbeat heartbeat = new Heartbeat(hiveClient, lock.getLockid()); - long heartbeatIntervalMs = lockConfiguration.getConfig() - .getLong(LOCK_HEARTBEAT_INTERVAL_MS_KEY, DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS); - future = executor.scheduleAtFixedRate(heartbeat, heartbeatIntervalMs / 2, heartbeatIntervalMs, TimeUnit.MILLISECONDS); - } catch (InterruptedException | TimeoutException e) { - if (this.lock == null || this.lock.getState() != LockState.ACQUIRED) { - LockResponse lockResponse = this.hiveClient.checkLock(lockRequest.getTxnid()); - if (lockResponse.getState() == LockState.ACQUIRED) { - this.lock = lockResponse; - } else { - throw e; - } - } + scheduleHeartbeat(); } finally { // it is better to release WAITING lock, otherwise hive lock will hang forever - if (this.lock != null && this.lock.getState() != LockState.ACQUIRED) { - hiveClient.unlock(this.lock.getLockid()); + // Snapshot the lock: the heartbeat thread clears it as soon as the metastore reports it gone. + LockResponse lockResponseLocal = this.lock; + if (lockResponseLocal != null && lockResponseLocal.getState() != LockState.ACQUIRED) { + hiveClient.unlock(lockResponseLocal.getLockid()); lock = null; - if (future != null) { - future.cancel(false); - } + cancelHeartbeat(); } } } + /** + * Schedules a periodic {@link Heartbeat} to refresh the currently held lock in case a commit + * takes a long time. Does nothing unless {@link #lock} is held right now, so that callers may + * invoke it without checking the state of the response they just got. + */ + private void scheduleHeartbeat() { + LockResponse lockResponseLocal = lock; + if (lockResponseLocal == null) { + // Released while the acquisition was still completing, so there is nothing left to renew. + return; + } + if (lockResponseLocal.getState() != LockState.ACQUIRED) { + // The metastore only queued the request. The caller releases such a lock right away, and + // renewing one that was never granted can only latch a loss that never happened. + return; + } + // Bind the id into the task and its callback: cancelling a schedule does not stop a tick that + // is already inside the heartbeat RPC, so a tick can outlive the lock it was scheduled for. + long lockId = lockResponseLocal.getLockid(); + Heartbeat heartbeat = new Heartbeat(hiveClient, lockId, cause -> onLockLost(lockId, cause)); + long heartbeatIntervalMs = lockConfiguration.getConfig() + .getLong(LOCK_HEARTBEAT_INTERVAL_MS_KEY, DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS); + future = executor.scheduleAtFixedRate(heartbeat, heartbeatIntervalMs / 2, heartbeatIntervalMs, TimeUnit.MILLISECONDS); + } + + /** + * Invoked from the heartbeat thread once the metastore reports the lock as expired or aborted. + * The lock cannot be renewed anymore, so stop heartbeating it and stop claiming it is held. + * + * @param lockId the lock this heartbeat was scheduled for, which is not necessarily the one held + * now: releasing a lock is itself a reason for an in-flight heartbeat to fail. + */ + private void onLockLost(long lockId, Exception cause) { + LockResponse lockResponseLocal = lock; + if (lockResponseLocal == null || lockResponseLocal.getLockid() != lockId) { + // We released this lock ourselves while the tick was in flight, which is why the metastore + // no longer knows about it. Nothing was lost, and any lock held now is a different one that + // keeps its own heartbeat. + log.debug("Ignoring a heartbeat failure for the already released lock {}", lockId, cause); + return; + } + // Order matters: the flag is set first, so an unlock() racing with this can never find the + // lock gone without a reason for it; and the schedule is cancelled before the lock is dropped, + // so whoever observes the drop is guaranteed to see the renewal already stopped. + lockLostRemotely = true; + cancelHeartbeat(); + lock = null; + log.error("The metastore expired or aborted the lock at{}, heartbeat stopped and exclusivity is lost", + generateLogSuffixString(), cause); + } + + private void cancelHeartbeat() { + ScheduledFuture futureLocal = future; + if (futureLocal != null) { + // Never interrupt: this can run on the heartbeat thread itself, and the current tick is + // harmless. Cancelling only prevents further executions. + futureLocal.cancel(false); + } + } + private void checkRequiredProps(final LockConfiguration lockConfiguration) { ValidationUtils.checkArgument(lockConfiguration.getConfig().getString(HIVE_DATABASE_NAME_PROP_KEY) != null); ValidationUtils.checkArgument(lockConfiguration.getConfig().getString(HIVE_TABLE_NAME_PROP_KEY) != null); diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java new file mode 100644 index 0000000000000..d9ebea7e10efc --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveDriverPool.java @@ -0,0 +1,487 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.hive.util; + +import org.apache.hudi.common.util.VisibleForTesting; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.hive.HiveSyncConfig; +import org.apache.hudi.hive.HoodieHiveSyncException; + +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.hadoop.hive.ql.Driver; +import org.apache.hadoop.hive.ql.session.SessionState; +import org.apache.hadoop.security.UserGroupInformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME; + +/** + * Pool of Hive {@link Driver} + {@link SessionState} pairs for parallel HiveQL DDL. + * + *

    Hive's {@code SessionState.start(state)} binds state to the calling thread's + * thread-local, and {@code Driver} reads from that thread-local during {@code run()}. + * A Driver constructed on one thread cannot be safely used from another. This pool + * solves that by giving each slot its own dedicated worker thread (a single-thread + * executor) — the Driver and SessionState are built on that thread by a bootstrap + * task, and all subsequent SQL for that slot runs on the same thread. + * + *

    Usage contract: use this pool only for partition-row DDL statements that + * are independent of each other and freely shuffleable across workers. Table-level + * statements (createTable, schema evolution, USE database) must continue to run on + * the session {@code Driver} held by {@code HiveQueryDDLExecutor} on the sync driver + * thread. The pool is gated behind {@code hoodie.datasource.hive_sync.batching.enabled} + * and is constructed only for HiveQL sync mode. + */ +public class HiveDriverPool implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(HiveDriverPool.class); + + // Per-worker Driver construction has to be fast in practice (a few hundred ms + // for the SessionState + Driver init). A 60s ceiling per worker leaves plenty of + // headroom for a slow JVM warm-up but bounds the failure mode if the metastore + // is unreachable or Hive hangs during init. + private static final long BOOTSTRAP_TIMEOUT_SECONDS = 60; + + private final List workers; + private final int size; + private volatile boolean closed; + + public HiveDriverPool(HiveSyncConfig config, int size) { + this(config, size, new DefaultDriverFactory(config)); + } + + // Package-private for tests: accepts a DriverFactory so unit tests can inject + // mock Driver instances without standing up a real Hive instance. + HiveDriverPool(HiveSyncConfig config, int size, DriverFactory factory) { + if (size < 1) { + throw new IllegalArgumentException("Pool size must be >= 1, got " + size); + } + this.size = size; + this.workers = new ArrayList<>(size); + String databaseName = config.getStringOrDefault(META_SYNC_DATABASE_NAME); + PoolThreadFactory threadFactory = new PoolThreadFactory(); + try { + // Bootstrap workers one at a time (not concurrently): each worker builds its + // own exclusively-owned SessionState, and constructing several SessionStates + // in parallel risks racing on shared scratch-dir creation. This only affects + // one-time pool startup cost, not per-statement dispatch latency. + for (int i = 0; i < size; i++) { + Worker worker = new Worker(threadFactory); + workers.add(worker); + worker.executor.submit(() -> { + worker.driver = factory.newDriver(databaseName); + worker.sessionState = SessionState.get(); + return null; + }).get(BOOTSTRAP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } catch (Exception e) { + tearDown(); + throw new HoodieException("Failed to construct HiveDriverPool of size " + size, e); + } + LOG.info("Initialized HiveDriverPool with {} workers", size); + } + + /** + * Runs each given SQL on every worker, in order. Used for setup statements + * (e.g. {@code USE database}) that must establish per-thread session context + * before any partition statement runs. Blocks until all workers have completed + * the setup. Throws on first error. + */ + public void runOnEachWorker(List setupSqls) { + if (closed) { + throw new IllegalStateException("Cannot dispatch to a closed HiveDriverPool"); + } + if (setupSqls.isEmpty()) { + return; + } + Dispatch dispatch = new Dispatch(workers.size()); + for (Worker worker : workers) { + dispatch.add(worker.executor.submit(() -> { + if (dispatch.aborted()) { + throw new CancellationException("Skipped after an earlier setup statement failed"); + } + try { + for (String sql : setupSqls) { + worker.driver.run(sql); + } + } catch (Throwable t) { + dispatch.recordFailure(t); + throw t; + } finally { + dispatch.taskSettled(); + } + return null; + })); + } + dispatch.sealed(); + awaitAll(dispatch); + } + + /** + * Dispatches each SQL string to a worker (round-robin) and returns a handle to the + * in-flight batch — this method does not block. The caller is responsible for + * awaiting completion via {@link #awaitAll(Dispatch)} and collecting errors. SQL text + * is intentionally not logged per-statement here: batched TOUCH/ADD statements can + * be many kilobytes, and N parallel workers would multiply the log volume. See + * {@link #awaitAll(Dispatch)} for the per-call summary log. + * + *

    Statements are spread round-robin across workers, so worker w owns + * indices {@code w, w + N, w + 2N, ...}. Each worker drains its own queue + * independently, which is why abort has to be observed by the tasks themselves + * rather than by the awaiting thread — see {@link Dispatch}. + */ + public Dispatch dispatchAll(List sqls) { + if (closed) { + throw new IllegalStateException("Cannot dispatch to a closed HiveDriverPool"); + } + Dispatch dispatch = new Dispatch(sqls.size()); + for (int i = 0; i < sqls.size(); i++) { + String sql = sqls.get(i); + Worker worker = workers.get(i % workers.size()); + dispatch.add(worker.executor.submit(() -> { + // Abort check inside the task: a worker can dequeue this statement while the + // awaiting thread is still parked on some other worker's slower statement, so + // Future.cancel() alone cannot stop it in time. Checking here means no + // statement starts after a sibling has already failed. + if (dispatch.aborted()) { + throw new CancellationException("Skipped after an earlier statement failed"); + } + try { + worker.driver.run(sql); + } catch (Throwable t) { + dispatch.recordFailure(t); + throw t; + } finally { + dispatch.taskSettled(); + } + return null; + })); + } + dispatch.sealed(); + return dispatch; + } + + /** + * Awaits the dispatched batch and throws the first error encountered. Errors are + * observed in completion order, not submission order: the awaiting thread + * blocks until every task has settled (or the batch has aborted), so a failure on a + * fast worker cancels the queues of all other workers even while a slow worker is + * still mid-statement. Errors that finished before cancellation are logged at WARN. + * Callers do not need per-statement results (Hive's Driver.run side-effects the + * metastore), so this method is void. + */ + public void awaitAll(Dispatch dispatch) { + long start = System.currentTimeMillis(); + // Block until either every task settled or one of them aborted the batch. Only + // then walk the futures — by that point no un-started task can still begin, so + // the walk order no longer affects how much extra DDL gets applied. + dispatch.awaitSettledOrAborted(); + int cancelled = dispatch.cancelPending(); + + // Seeded from the batch's own record rather than discovered by walking the futures: + // cancelPending() above may have erased the failing task's exception. See + // Dispatch#recordFailure. The walk below still runs, to count outcomes and to catch + // a failure that somehow never made it into the record. + Throwable firstError = dispatch.firstFailure(); + int completed = 0; + for (Future f : dispatch.futures()) { + try { + f.get(); + completed++; + } catch (CancellationException ce) { + // Either we cancelled it before it started, or the task itself observed the + // abort flag and bailed. Not a new failure; just note it for the summary. + cancelled++; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + if (firstError == null) { + firstError = ie; + } + } catch (ExecutionException ee) { + Exception cause = unwrap(ee); + if (cause instanceof CancellationException) { + cancelled++; + } else if (firstError == null) { + firstError = cause; + } else if (ee.getCause() != firstError) { + // Identity check against the raw cause, not the unwrapped one: when the failing + // task wins the race against cancelPending(), its future reports the very + // Throwable already held in firstError, and re-logging it here would duplicate + // the exception this method is about to throw. + LOG.warn("Additional SQL batch failed (suppressed in favor of first error)", cause); + } + } + } + if (firstError != null) { + throw new HoodieHiveSyncException("Failed in executing SQL", firstError); + } + LOG.info("Completed {} SQL statements ({} cancelled) in {} ms across {} workers", + completed, cancelled, System.currentTimeMillis() - start, size); + } + + /** + * Handle to one {@link #dispatchAll(List)} batch: the submitted futures plus the + * shared abort flag the tasks consult before running. + * + *

    The flag exists because the futures belong to N independent single-thread + * executors. Cancelling from the awaiting thread is inherently late — a worker can + * pull its next statement off its own queue at any moment — so each task also + * re-checks {@link #aborted()} on entry. That is what actually bounds how much extra + * partition DDL a failed sync can apply. + */ + public static final class Dispatch { + private final List> futures; + private final int total; + private final AtomicInteger settled = new AtomicInteger(0); + private final AtomicBoolean aborted = new AtomicBoolean(false); + private final AtomicReference firstFailureRef = new AtomicReference<>(); + private final CountDownLatch done = new CountDownLatch(1); + private volatile boolean sealed; + + private Dispatch(int total) { + this.total = total; + this.futures = new ArrayList<>(total); + } + + private void add(Future future) { + futures.add(future); + } + + // Called once submission finishes. A task that settles before the last submit + // would otherwise see settled < total and never trip the latch, so re-check here. + private void sealed() { + sealed = true; + signalIfComplete(); + } + + private boolean aborted() { + return aborted.get(); + } + + /** + * Records a task's failure and aborts the batch. The Throwable is kept here rather + * than being left for {@link Future#get()} to report, because the failing task is + * racing the awaiting thread: this call releases {@link #awaitSettledOrAborted()}, + * but the task's exception only reaches its {@code FutureTask} after {@code call()} + * returns. {@link #cancelPending()} in between wins the {@code FutureTask} state CAS + * (cancel succeeds on any task still NEW, which includes one mid-unwind), turning the + * later {@code setException} into a no-op and the error into a CancellationException. + */ + private void recordFailure(Throwable t) { + firstFailureRef.compareAndSet(null, t); + aborted.set(true); + done.countDown(); + } + + private Throwable firstFailure() { + return firstFailureRef.get(); + } + + private void taskSettled() { + settled.incrementAndGet(); + signalIfComplete(); + } + + private void signalIfComplete() { + if (sealed && settled.get() >= total) { + done.countDown(); + } + } + + private void awaitSettledOrAborted() { + if (total == 0) { + return; + } + try { + done.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + aborted.set(true); + } + } + + // mayInterruptIfRunning=false: the worker thread is bound to a Hive Driver whose + // state we don't want to corrupt mid-statement. Cancel only tasks that haven't + // started; in-flight statements run to completion. + private int cancelPending() { + int cancelled = 0; + for (Future f : futures) { + if (f.cancel(false)) { + cancelled++; + } + } + return cancelled; + } + + private List> futures() { + return futures; + } + + @VisibleForTesting + public int size() { + return futures.size(); + } + + @VisibleForTesting + public Future futureAt(int index) { + return futures.get(index); + } + } + + private static Exception unwrap(ExecutionException ee) { + Throwable cause = ee.getCause(); + return (cause instanceof Exception) ? (Exception) cause : ee; + } + + public int size() { + return size; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + tearDown(); + } + + private void tearDown() { + // Close each worker's own Driver and SessionState on its own thread, then shut + // the executor down. Each worker owns an exclusive SessionState (see + // DefaultDriverFactory), so there is no cross-worker close ordering to worry + // about here — closing worker i never affects worker j. + for (Worker worker : workers) { + try { + worker.executor.submit(() -> { + if (worker.driver != null) { + try { + worker.driver.close(); + } catch (Exception e) { + LOG.warn("Error closing pooled Driver", e); + } + } + if (worker.sessionState != null) { + try { + worker.sessionState.close(); + } catch (Exception e) { + LOG.warn("Error closing pooled SessionState", e); + } + } + return null; + }).get(30, TimeUnit.SECONDS); + } catch (Exception e) { + LOG.warn("Error during pool worker shutdown", e); + } + worker.executor.shutdown(); + try { + if (!worker.executor.awaitTermination(10, TimeUnit.SECONDS)) { + worker.executor.shutdownNow(); + } + } catch (InterruptedException ie) { + worker.executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + workers.clear(); + } + + /** + * Per-slot state: a single-thread executor and the Driver + SessionState bound to + * its thread. Both are volatile because they are written by the bootstrap task and + * read by subsequent dispatch/teardown tasks on the same executor. + */ + private static final class Worker { + final ExecutorService executor; + volatile Driver driver; + volatile SessionState sessionState; + + Worker(ThreadFactory threadFactory) { + this.executor = Executors.newSingleThreadExecutor(threadFactory); + } + } + + @FunctionalInterface + interface DriverFactory { + Driver newDriver(String databaseName) throws Exception; + } + + /** + * Builds a real Hive {@link Driver} on the calling thread, backed by a + * {@link SessionState} that is exclusively owned by that thread (not shared with + * any other worker). Hive's session-scoped state (current database, scratch + * directories, and the transaction/lock manager under {@code DbTxnManager}) is + * mutated by {@code Driver.run()} and is not safe for concurrent use from multiple + * threads, so each worker must have its own instance. Bootstrap of all workers is + * done sequentially by the pool constructor specifically so these constructions + * don't race each other (e.g. on scratch-dir creation). + * + *

    Each worker also gets its own {@link HiveConf} copy. {@code SessionState} + * retains whatever {@code HiveConf} it's given, and {@code QueryState}/{@code Driver} + * mutate per-query keys on that conf during {@code run()} (e.g. {@code HIVEQUERYID}). + * Sharing one {@code HiveConf} instance across workers would let concurrent + * {@code Driver.run()} calls overwrite each other's query-scoped configuration even + * though each worker has its own {@code SessionState} object. + */ + private static final class DefaultDriverFactory implements DriverFactory { + private final HiveConf hiveConf; + + DefaultDriverFactory(HiveSyncConfig config) { + this.hiveConf = config.getHiveConf(); + } + + @Override + public Driver newDriver(String databaseName) throws Exception { + HiveConf workerConf = new HiveConf(hiveConf); + SessionState sessionState = new SessionState(workerConf, + UserGroupInformation.getCurrentUser().getShortUserName()); + sessionState.setCurrentDatabase(databaseName); + SessionState.start(sessionState); + return new Driver(workerConf); + } + } + + private static final class PoolThreadFactory implements ThreadFactory { + private static final AtomicInteger POOL_ID = new AtomicInteger(0); + private final AtomicInteger threadId = new AtomicInteger(0); + private final String namePrefix = "hudi-hive-driver-pool-" + POOL_ID.incrementAndGet() + "-"; + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, namePrefix + threadId.incrementAndGet()); + t.setDaemon(true); + return t; + } + } +} diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveSchemaUtil.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveSchemaUtil.java index 50cf61f3ce218..065d3515614af 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveSchemaUtil.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/HiveSchemaUtil.java @@ -33,6 +33,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -40,6 +41,7 @@ import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_CREATE_MANAGED_TABLE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SUPPORT_TIMESTAMP_TYPE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BUCKET_SYNC_SPEC; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_COMMENT; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_FIELDS; @@ -336,12 +338,23 @@ public static String generateSchemaString(HoodieSchema storageSchema, List colsToSkip, boolean supportTimestamp) throws IOException { + return generateSchemaString(storageSchema, colsToSkip, supportTimestamp, Collections.emptyMap()); + } + + public static String generateSchemaString(HoodieSchema storageSchema, List colsToSkip, boolean supportTimestamp, + Map fieldDocs) throws IOException { Map hiveSchema = convertSchemaToHiveSchema(storageSchema, supportTimestamp); StringBuilder columns = new StringBuilder(); for (Map.Entry hiveSchemaEntry : hiveSchema.entrySet()) { - if (!colsToSkip.contains(removeSurroundingTick(hiveSchemaEntry.getKey()))) { + String fieldName = removeSurroundingTick(hiveSchemaEntry.getKey()); + if (!colsToSkip.contains(fieldName)) { columns.append(hiveSchemaEntry.getKey()).append(" "); - columns.append(hiveSchemaEntry.getValue()).append(", "); + columns.append(hiveSchemaEntry.getValue()); + String doc = fieldDocs.get(fieldName.toLowerCase(Locale.ROOT)); + if (doc != null) { + columns.append(" COMMENT '").append(escapeSqlString(doc)).append("'"); + } + columns.append(", "); } } // Remove the last ", " @@ -349,17 +362,43 @@ public static String generateSchemaString(HoodieSchema storageSchema, List getFieldDocs(HoodieSchema schema) { + return schema.getFields().stream() + .filter(field -> field.doc().map(doc -> !doc.isEmpty()).orElse(false)) + .collect(Collectors.toMap(field -> field.name().toLowerCase(Locale.ROOT), field -> field.doc().get(), (existing, duplicate) -> existing)); + } + public static String generateCreateDDL(String tableName, HoodieSchema storageSchema, HiveSyncConfig config, String inputFormatClass, String outputFormatClass, String serdeClass, Map serdeProperties, Map tableProperties) throws IOException { Map hiveSchema = convertSchemaToHiveSchema(storageSchema, config.getBoolean(HIVE_SUPPORT_TIMESTAMP_TYPE)); - String columns = generateSchemaString(storageSchema, config.getSplitStrings(META_SYNC_PARTITION_FIELDS), config.getBoolean(HIVE_SUPPORT_TIMESTAMP_TYPE)); + Map fieldDocs = config.getBoolean(HIVE_SYNC_COMMENT) ? getFieldDocs(storageSchema) : Collections.emptyMap(); + String columns = generateSchemaString(storageSchema, config.getSplitStrings(META_SYNC_PARTITION_FIELDS), + config.getBoolean(HIVE_SUPPORT_TIMESTAMP_TYPE), fieldDocs); List partitionFields = new ArrayList<>(); for (String partitionKey : config.getSplitStrings(META_SYNC_PARTITION_FIELDS)) { String partitionKeyWithTicks = tickSurround(partitionKey); - partitionFields.add(partitionKeyWithTicks + " " - + getPartitionKeyType(hiveSchema, partitionKeyWithTicks)); + String partitionField = partitionKeyWithTicks + " " + + getPartitionKeyType(hiveSchema, partitionKeyWithTicks); + String doc = fieldDocs.get(partitionKey.toLowerCase(Locale.ROOT)); + if (doc != null) { + partitionField += " COMMENT '" + escapeSqlString(doc) + "'"; + } + partitionFields.add(partitionField); } String partitionsStr = String.join(",", partitionFields); @@ -401,7 +440,7 @@ private static String propertyToString(Map properties) { if (!first) { sb.append(","); } - sb.append("'").append(entry.getKey()).append("'='").append(entry.getValue()).append("'"); + sb.append("'").append(escapeSqlString(entry.getKey())).append("'='").append(escapeSqlString(entry.getValue())).append("'"); first = false; } return sb.toString(); diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/PartitionFilterGenerator.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/PartitionFilterGenerator.java index d1b934988d2f3..b3ec1b35afc27 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/PartitionFilterGenerator.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/PartitionFilterGenerator.java @@ -140,13 +140,12 @@ public ValueComparator(String type) { public int compare(String s1, String s2) { switch (valueType.toLowerCase(Locale.ROOT)) { case HiveSchemaUtil.INT_TYPE_NAME: - int i1 = Integer.parseInt(s1); - int i2 = Integer.parseInt(s2); - return i1 - i2; + // Use Integer.compare rather than subtraction, which overflows for values whose + // difference exceeds the int range and yields a wrong ordering. + return Integer.compare(Integer.parseInt(s1), Integer.parseInt(s2)); case HiveSchemaUtil.BIGINT_TYPE_NAME: - long l1 = Long.parseLong(s1); - long l2 = Long.parseLong(s2); - return Long.signum(l1 - l2); + // Use Long.compare rather than subtraction, which overflows the long arithmetic. + return Long.compare(Long.parseLong(s1), Long.parseLong(s2)); case HiveSchemaUtil.DATE_TYPE_NAME: case HiveSchemaUtil.STRING_TYPE_NAME: return s1.compareTo(s2); diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java index ec9d4b0406de7..93ccc45a34a4d 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java @@ -67,6 +67,7 @@ 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; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; @@ -75,7 +76,6 @@ import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; -import java.time.Instant; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -96,9 +96,13 @@ import static org.apache.hudi.hive.HiveSyncConfig.HIVE_SYNC_FILTER_PUSHDOWN_ENABLED; import static org.apache.hudi.hive.HiveSyncConfig.RECREATE_HIVE_TABLE_ON_ERROR; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_AUTO_CREATE_DATABASE; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_CREATE_MANAGED_TABLE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_IGNORE_EXCEPTIONS; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SKIP_RO_SUFFIX_FOR_READ_OPTIMIZED_TABLE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_AS_DATA_SOURCE_TABLE; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_THREADS; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_COMMENT; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_MODE; import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_TABLE_STRATEGY; @@ -116,6 +120,7 @@ import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_INCREMENTAL; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_EXTRACTOR_CLASS; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_FIELDS; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_SNAPSHOT_WITH_TABLE_NAME; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_TABLE_NAME; import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_TOUCH_PARTITIONS_ENABLED; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -181,6 +186,8 @@ private static Iterable syncModeAndTouchPartitionsEnabled() { private HiveSyncTool hiveSyncTool; private HoodieHiveSyncClient hiveClient; + @TempDir + java.nio.file.Path tempDir; @AfterAll public static void cleanUpClass() throws IOException { @@ -213,7 +220,7 @@ private static Iterable syncDataSourceTableParams() { @BeforeEach public void setUp() throws Exception { - HiveTestUtil.setUp(Option.empty(), true); + HiveTestUtil.setUp(Option.empty(), true, tempDir); } @AfterEach @@ -243,7 +250,7 @@ public void testUpdateBasePath(boolean useSchemaFromCommitMetadata, String syncM HiveTestUtil.fileSystem.delete(new Path(basePath), true); // create a new cow table and reSync - basePath = Files.createTempDirectory("hivesynctest" + Instant.now().toEpochMilli()).toUri().toString(); + basePath = createTempBasePath("hivesynctest"); hiveSyncProps.setProperty(META_SYNC_BASE_PATH.key(), basePath); HiveTestUtil.createCOWTable(instantTime, 1, useSchemaFromCommitMetadata); reInitHiveSyncClient(); @@ -327,6 +334,110 @@ public void testDropUpperCasePartitionWithHMS() throws Exception { "Table partitions should match the number of partitions we wrote"); } + /** + * Exercises HiveQL sync with parallel partition batching enabled. Routes through + * the HiveDriverPool — each worker thread owns a Driver+SessionState pair, and + * the SQL list (qualified with `db`.`tbl`) is fanned out across them. + */ + @Test + public void testHiveQLSyncWithBatchingEnabled() throws Exception { + hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), HiveSyncMode.HIVEQL.name()); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true"); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "3"); + hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "3"); + + int partitionCount = 10; + HiveTestUtil.createCOWTable("100", partitionCount, true); + + reInitHiveSyncClient(); + assertFalse(hiveClient.tableExists(HiveTestUtil.TABLE_NAME), + "Table should not exist before initial sync"); + reSyncHiveTable(); + assertEquals(partitionCount, hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(), + "All partitions should be added under parallel HiveQL batching"); + + // Add more partitions, then sync again to exercise the parallel update path. + HiveTestUtil.addCOWPartition("2050/01/01", true, true, "101"); + HiveTestUtil.addCOWPartition("2050/01/02", true, true, "102"); + HiveTestUtil.addCOWPartition("2050/01/03", true, true, "103"); + HiveTestUtil.addCOWPartition("2050/01/04", true, true, "104"); + reInitHiveSyncClient(); + reSyncHiveTable(); + assertEquals(partitionCount + 4, hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(), + "Incremental add via parallel HiveQL batching should sync the new partitions"); + } + + /** + * Exercises the SET_LOCATION path in HiveQL mode with batching on. SET_LOCATION + * emits one ALTER PARTITION ... SET LOCATION statement per partition (Hive SQL + * has no multi-partition SET LOCATION), so this is the fan-out path most likely + * to exercise concurrent ALTER PARTITION calls against the same table. + */ + @Test + public void testHiveQLSetLocationWithBatching() throws Exception { + hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), HiveSyncMode.HIVEQL.name()); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true"); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "3"); + hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "2"); + + int partitionCount = 6; + HiveTestUtil.createCOWTable("100", partitionCount, true); + reInitHiveSyncClient(); + reSyncHiveTable(); + assertEquals(partitionCount, hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size()); + + // Drive the SET_LOCATION path by directly calling updatePartitionsToTable with + // existing partition paths. Each partition produces its own ALTER ... SET LOCATION + // statement, fanned out across the 3 workers in the pool. + List existingPartitions = hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).stream() + .map(p -> getRelativePartitionPath(new Path(basePath), new Path(p.getStorageLocation()))) + .collect(Collectors.toList()); + hiveClient.updatePartitionsToTable(HiveTestUtil.TABLE_NAME, existingPartitions); + + List after = hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME); + assertEquals(partitionCount, after.size(), + "Parallel SET_LOCATION must not change the partition set"); + Set relativePaths = after.stream() + .map(p -> getRelativePartitionPath(new Path(basePath), new Path(p.getStorageLocation()))) + .collect(Collectors.toSet()); + assertEquals(partitionCount, relativePaths.size(), + "Each partition should resolve to a unique relative path after parallel SET_LOCATION"); + assertTrue(relativePaths.containsAll(existingPartitions), + "All original partition paths should still be present after parallel SET_LOCATION"); + } + + /** + * Exercises the TOUCH path in HiveQL mode with batching on. Verifies that + * splitting one giant ALTER TABLE TOUCH PARTITION(...)... into multiple smaller + * statements does not break partition visibility downstream. + */ + @Test + public void testHiveQLTouchPartitionsWithBatching() throws Exception { + hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), HiveSyncMode.HIVEQL.name()); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), "true"); + hiveSyncProps.setProperty(HIVE_SYNC_BATCHING_THREADS.key(), "2"); + hiveSyncProps.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), "2"); + hiveSyncProps.setProperty(META_SYNC_TOUCH_PARTITIONS_ENABLED.key(), "true"); + + int partitionCount = 6; + HiveTestUtil.createCOWTable("100", partitionCount, true); + reInitHiveSyncClient(); + reSyncHiveTable(); + assertEquals(partitionCount, hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size()); + + // Drive the TOUCH path directly by calling touchPartitionsToTable with existing + // partition paths. Partitions are batched into groups of HIVE_BATCH_SYNC_PARTITION_NUM + // and each batch's ALTER ... TOUCH statement is fanned out across the 2 workers in + // the pool. + List existingPartitions = hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).stream() + .map(p -> getRelativePartitionPath(new Path(basePath), new Path(p.getStorageLocation()))) + .collect(Collectors.toList()); + hiveClient.touchPartitionsToTable(HiveTestUtil.TABLE_NAME, existingPartitions); + + assertEquals(partitionCount, hiveClient.getAllPartitions(HiveTestUtil.TABLE_NAME).size(), + "TOUCH batching must not change the partition set"); + } + @ParameterizedTest @MethodSource({"syncModeAndSchemaFromCommitMetadata"}) public void testBasicSync(boolean useSchemaFromCommitMetadata, String syncMode, String enablePushDown) throws Exception { @@ -864,7 +975,7 @@ public void testRecreateCOWTableOnBasePathChange(String syncMode, String enableP String commitTime2 = "105"; // let's update the basepath - basePath = Files.createTempDirectory("hivesynctest_new" + Instant.now().toEpochMilli()).toUri().toString(); + basePath = createTempBasePath("hivesynctest-new"); hiveSyncProps.setProperty(META_SYNC_BASE_PATH.key(), basePath); // let's create new table in new basepath @@ -1030,6 +1141,186 @@ public void testSyncWithCommentedSchema(String syncMode) throws Exception { assertEquals(2, commentCnt, "hive schema field comment numbers should match the avro schema field doc numbers"); } + private static final String STANDARD_COLUMNS = + "{\"name\": \"name\", \"type\": \"string\", \"doc\": \"name_comment\"}," + + "{\"name\": \"favorite_number\", \"type\": \"int\", \"doc\": \"favorite_number_comment\"}," + + "{\"name\": \"favorite_color\", \"type\": \"string\", \"doc\": \"the person's favorite color\\\\\"}"; + + private static final String COMPLEX_COLUMNS = + "{\"name\": \"address\", \"type\": {\"type\": \"record\", \"name\": \"Address\", \"fields\": [" + + "{\"name\": \"city\", \"type\": \"string\", \"doc\": \"city_comment\"}," + + "{\"name\": \"zip\", \"type\": \"string\"}]}, \"doc\": \"address_comment\"}," + + "{\"name\": \"tags\", \"type\": {\"type\": \"array\", \"items\": \"string\"}, \"doc\": \"tags_comment\"}," + + "{\"name\": \"scores\", \"type\": {\"type\": \"map\", \"values\": \"int\"}}"; + + private static final String SINGLE_PARTITION_COLUMN = + "{\"name\": \"datestr\", \"type\": \"string\", \"doc\": \"partition_datestr_comment\"}"; + + private static final String MULTI_PARTITION_COLUMNS = + "{\"name\": \"year\", \"type\": \"string\", \"doc\": \"partition_year_comment\"}," + + "{\"name\": \"month\", \"type\": \"string\"}," + + "{\"name\": \"day\", \"type\": \"string\", \"doc\": \"partition_day_comment\"}"; + + @ParameterizedTest + @MethodSource("syncMode") + public void testSyncCommentsForStandardColumnsWithSinglePartitionColumn(String syncMode) throws Exception { + Map commentsByField = syncTableWithCommentedSchema(syncMode, STANDARD_COLUMNS, SINGLE_PARTITION_COLUMN); + assertStandardColumnComments(commentsByField); + assertEquals("partition_datestr_comment", commentsByField.get("datestr"), + "comment of the partition column should be synced on table creation"); + assertSparkSchemaPropertyContainsComments("\"comment\":\"name_comment\"", "\"comment\":\"partition_datestr_comment\""); + } + + @ParameterizedTest + @MethodSource("syncMode") + public void testSyncCommentsForComplexColumnsWithSinglePartitionColumn(String syncMode) throws Exception { + Map commentsByField = syncTableWithCommentedSchema(syncMode, STANDARD_COLUMNS, COMPLEX_COLUMNS, SINGLE_PARTITION_COLUMN); + assertStandardColumnComments(commentsByField); + assertComplexColumnComments(commentsByField); + assertEquals("partition_datestr_comment", commentsByField.get("datestr"), + "comment of the partition column should be synced on table creation"); + // docs of nested fields are only representable in the spark schema + assertSparkSchemaPropertyContainsComments("\"comment\":\"address_comment\"", "\"comment\":\"city_comment\""); + } + + @ParameterizedTest + @MethodSource("syncMode") + public void testSyncCommentsForStandardColumnsWithMultiplePartitionColumns(String syncMode) throws Exception { + setMultiPartitionFields(); + Map commentsByField = syncTableWithCommentedSchema(syncMode, STANDARD_COLUMNS, MULTI_PARTITION_COLUMNS); + assertStandardColumnComments(commentsByField); + assertMultiPartitionColumnComments(commentsByField); + assertSparkSchemaPropertyContainsComments("\"comment\":\"name_comment\"", "\"comment\":\"partition_year_comment\""); + } + + @ParameterizedTest + @MethodSource("syncMode") + public void testSyncCommentsForComplexColumnsWithMultiplePartitionColumns(String syncMode) throws Exception { + setMultiPartitionFields(); + Map commentsByField = syncTableWithCommentedSchema(syncMode, STANDARD_COLUMNS, COMPLEX_COLUMNS, MULTI_PARTITION_COLUMNS); + assertStandardColumnComments(commentsByField); + assertComplexColumnComments(commentsByField); + assertMultiPartitionColumnComments(commentsByField); + } + + @ParameterizedTest + @MethodSource("syncMode") + public void testSyncCommentsWithSpecialCharacters(String syncMode) throws Exception { + String specialColumns = + "{\"name\": \"c_quote\", \"type\": \"string\", \"doc\": \"it's the person's 'favorite'\"}," + + "{\"name\": \"c_backslash\", \"type\": \"string\", \"doc\": \"back\\\\slash ending\\\\\"}," + + "{\"name\": \"c_double_quote\", \"type\": \"string\", \"doc\": \"he said \\\"hello\\\"\"}," + + "{\"name\": \"c_mixed\", \"type\": \"string\", \"doc\": \"semi;colon, comma=equals %percent _underscore\"}," + + "{\"name\": \"c_unicode\", \"type\": \"string\", \"doc\": \"unicode héllo 你好\"}"; + Map commentsByField = syncTableWithCommentedSchema(syncMode, specialColumns, SINGLE_PARTITION_COLUMN); + assertEquals("it's the person's 'favorite'", commentsByField.get("c_quote"), + "comment with single quotes should be synced unchanged"); + assertEquals("back\\slash ending\\", commentsByField.get("c_backslash"), + "comment with backslashes should be synced unchanged"); + assertEquals("he said \"hello\"", commentsByField.get("c_double_quote"), + "comment with double quotes should be synced unchanged"); + assertEquals("semi;colon, comma=equals %percent _underscore", commentsByField.get("c_mixed"), + "comment with SQL separator characters should be synced unchanged"); + assertEquals("unicode héllo 你好", commentsByField.get("c_unicode"), + "comment with non-ascii characters should be synced unchanged"); + assertEquals("partition_datestr_comment", commentsByField.get("datestr"), + "comment of the partition column should be synced on table creation"); + } + + @ParameterizedTest + @MethodSource("syncMode") + public void testUpdateCommentsForPartitionColumns(String syncMode) throws Exception { + hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), syncMode); + hiveSyncProps.setProperty(HIVE_SYNC_COMMENT.key(), "false"); + setMultiPartitionFields(); + String commitTime = "100"; + HiveTestUtil.createCOWTableWithSchema(commitTime, commentedSchema(STANDARD_COLUMNS, MULTI_PARTITION_COLUMNS)); + + // table is created without comments, they are applied by the second sync + reInitHiveSyncClient(); + reSyncHiveTable(); + hiveSyncProps.setProperty(HIVE_SYNC_COMMENT.key(), "true"); + hiveSyncProps.setProperty(META_SYNC_INCREMENTAL.key(), "false"); + reInitHiveSyncClient(); + reSyncHiveTable(); + + Map commentsByField = metastoreFieldComments(HiveTestUtil.TABLE_NAME); + assertStandardColumnComments(commentsByField); + if (syncMode.equals(HiveSyncMode.HMS.name().toLowerCase())) { + assertMultiPartitionColumnComments(commentsByField); + } else { + assertEquals("", commentsByField.get("year"), + "comment of a partition column cannot be updated in query based sync modes"); + assertEquals("", commentsByField.get("day"), + "comment of a partition column cannot be updated in query based sync modes"); + } + } + + private Map syncTableWithCommentedSchema(String syncMode, String... fieldJsons) throws Exception { + hiveSyncProps.setProperty(HIVE_SYNC_MODE.key(), syncMode); + hiveSyncProps.setProperty(HIVE_SYNC_COMMENT.key(), "true"); + HiveTestUtil.createCOWTableWithSchema("100", commentedSchema(fieldJsons)); + reInitHiveSyncClient(); + reSyncHiveTable(); + return metastoreFieldComments(HiveTestUtil.TABLE_NAME); + } + + private static HoodieSchema commentedSchema(String... fieldJsons) { + return HoodieSchema.parse("{\"type\": \"record\", \"name\": \"User\", \"namespace\": \"example.avro\", \"fields\": [" + + String.join(",", fieldJsons) + "]}"); + } + + private void setMultiPartitionFields() { + hiveSyncProps.setProperty(META_SYNC_PARTITION_EXTRACTOR_CLASS.key(), MultiPartKeysValueExtractor.class.getCanonicalName()); + hiveSyncProps.setProperty(META_SYNC_PARTITION_FIELDS.key(), "year,month,day"); + } + + private void assertStandardColumnComments(Map commentsByField) { + assertEquals("name_comment", commentsByField.get("name"), + "comment of a regular column should be synced"); + assertEquals("favorite_number_comment", commentsByField.get("favorite_number"), + "comment of a regular column should be synced"); + assertEquals("the person's favorite color\\", commentsByField.get("favorite_color"), + "comment with a single quote and trailing backslash should be synced unchanged"); + } + + private void assertComplexColumnComments(Map commentsByField) { + assertEquals("address_comment", commentsByField.get("address"), + "comment of a struct column should be synced"); + assertEquals("tags_comment", commentsByField.get("tags"), + "comment of an array column should be synced"); + assertEquals("", commentsByField.get("scores"), + "map column without a doc should have no comment"); + } + + private void assertMultiPartitionColumnComments(Map commentsByField) { + assertEquals("partition_year_comment", commentsByField.get("year"), + "comment of the first partition column should be synced"); + assertEquals("", commentsByField.get("month"), + "partition column without a doc should have no comment"); + assertEquals("partition_day_comment", commentsByField.get("day"), + "comment of the last partition column should be synced"); + } + + private void assertSparkSchemaPropertyContainsComments(String... expectedComments) throws Exception { + SessionState.start(HiveTestUtil.getHiveConf()); + Driver hiveDriver = new Driver(HiveTestUtil.getHiveConf()); + hiveDriver.run(String.format("SHOW TBLPROPERTIES %s.%s", HiveTestUtil.DB_NAME, HiveTestUtil.TABLE_NAME)); + List results = new ArrayList<>(); + hiveDriver.getResults(results); + String tableProperties = String.join("\n", results); + for (String expectedComment : expectedComments) { + assertTrue(tableProperties.contains(expectedComment), + "spark schema table property should contain " + expectedComment); + } + } + + private Map metastoreFieldComments(String tableName) { + return hiveClient.getMetastoreFieldSchemas(tableName) + .stream() + .collect(Collectors.toMap(FieldSchema::getName, FieldSchema::getCommentOrEmpty)); + } + @ParameterizedTest @MethodSource("syncModeAndSchemaFromCommitMetadata") public void testSyncMergeOnRead(boolean useSchemaFromCommitMetadata, String syncMode, String enablePushDown) throws Exception { @@ -1116,7 +1407,7 @@ public void testSyncMergeOnReadWithBasePathChange(boolean useSchemaFromCommitMet reSyncHiveTable(); // change the hoodie base path - basePath = Files.createTempDirectory("hivesynctest_new" + Instant.now().toEpochMilli()).toUri().toString(); + basePath = createTempBasePath("hivesynctest-new"); hiveSyncProps.setProperty(META_SYNC_BASE_PATH.key(), basePath); String instantTime2 = "102"; @@ -1268,6 +1559,51 @@ public void testSyncMergeOnReadWithStrategy(String syncMode, HoodieSyncTableStra } } + @Test + void testSkipRoSuffixTakesPrecedenceOverSnapshotWithTableName() throws Exception { + // skip_ro_suffix explicitly claims the bare table name for the RO view; the now-default-true + // sync_snapshot_with_table_name must not be allowed to flip it to RT. + hiveSyncProps.setProperty(HIVE_SYNC_TABLE_STRATEGY.key(), HoodieSyncTableStrategy.ALL.name()); + hiveSyncProps.setProperty(HIVE_SKIP_RO_SUFFIX_FOR_READ_OPTIMIZED_TABLE.key(), "true"); + hiveSyncProps.setProperty(META_SYNC_SNAPSHOT_WITH_TABLE_NAME.key(), "true"); + hiveSyncProps.setProperty(HIVE_SYNC_AS_DATA_SOURCE_TABLE.key(), "true"); + + String instantTime = "100"; + String deltaCommitTime = "101"; + HiveTestUtil.createMORTable(instantTime, deltaCommitTime, 5, true, true); + + reInitHiveSyncClient(); + reSyncHiveTable(); + + // a second sync round with a new commit reproduces the flip; a single round does not + ZonedDateTime dateTime = ZonedDateTime.now().plusDays(6); + String commitTime2 = "102"; + String deltaCommitTime2 = "103"; + HiveTestUtil.addMORPartitions(1, true, false, true, dateTime, commitTime2, deltaCommitTime2); + reInitHiveSyncClient(); + reSyncHiveTable(); + + String snapshotTableName = HiveTestUtil.TABLE_NAME + HiveSyncTool.SUFFIX_SNAPSHOT_TABLE; + String roSuffixTableName = HiveTestUtil.TABLE_NAME + HiveSyncTool.SUFFIX_READ_OPTIMIZED_TABLE; + + // the bare table name must stay registered as the read-optimized view + StorageDescriptor bareTableSd = hiveClient.getMetastoreStorageDescriptor(HiveTestUtil.TABLE_NAME); + assertEquals(HoodieParquetInputFormat.class.getName(), bareTableSd.getInputFormat(), + "Bare table name should remain the RO view (skip_ro_suffix=true) despite sync_snapshot_with_table_name=true"); + assertEquals("true", bareTableSd.getSerdeInfo().getParameters().get(ConfigUtils.IS_QUERY_AS_RO_TABLE), + "Bare table name should still be marked as the RO table"); + + // the real-time/snapshot view remains available, and only there + assertTrue(hiveClient.tableExists(snapshotTableName), "Table " + snapshotTableName + " should exist after sync completes"); + StorageDescriptor snapshotTableSd = hiveClient.getMetastoreStorageDescriptor(snapshotTableName); + assertEquals(HoodieParquetRealtimeInputFormat.class.getName(), snapshotTableSd.getInputFormat(), + "Table " + snapshotTableName + " should use the realtime input format"); + + // no separate "_ro" table should be created when skip_ro_suffix is set + assertFalse(hiveClient.tableExists(roSuffixTableName), + "Table " + roSuffixTableName + " should not exist when skip_ro_suffix is set"); + } + @ParameterizedTest @EnumSource(value = HoodieSyncTableStrategy.class, names = {"RO", "RT"}) public void testSyncMergeOnReadWithStrategyWhenTableExist(HoodieSyncTableStrategy strategy) throws Exception { @@ -2164,12 +2500,16 @@ public void testSyncWithoutDiffs(String syncMode) throws Exception { HiveTestUtil.addMORPartitions(0, true, true, true, ZonedDateTime.now().plusDays(2), commitTime2, commitTime3); + // No sync condition is met, but the sync marker trails the midpoint of the active commits + // timeline ([100, 101, 102, 103] with midpoint 102), so it advances to the last commit. + reInitHiveSyncClient(); reSyncHiveTable(); - assertEquals(commitTime1, hiveClient.getLastCommitTimeSynced(tableName).get()); + assertEquals(commitTime3, hiveClient.getLastCommitTimeSynced(tableName).get()); // Let the last commit time synced to be before the start of the active timeline, - // to trigger the fallback of listing all partitions. There is no partition change - // and the last commit time synced should still be the same. + // to trigger the fallback of listing all partitions. There is no partition change, + // and the sync marker again trails the timeline midpoint, so it advances to the + // last commit instead of aging out further. HiveTestUtil.addMORPartitions(0, true, true, true, ZonedDateTime.now().plusDays(2), commitTime4, commitTime5); HiveTestUtil.removeCommitFromActiveTimeline(commitTime0, COMMIT_ACTION); HiveTestUtil.removeCommitFromActiveTimeline(commitTime1, DELTA_COMMIT_ACTION); @@ -2177,7 +2517,7 @@ public void testSyncWithoutDiffs(String syncMode) throws Exception { HiveTestUtil.removeCommitFromActiveTimeline(commitTime3, DELTA_COMMIT_ACTION); reInitHiveSyncClient(); reSyncHiveTable(); - assertEquals(commitTime1, hiveClient.getLastCommitTimeSynced(tableName).get()); + assertEquals(commitTime5, hiveClient.getLastCommitTimeSynced(tableName).get()); } @ParameterizedTest @@ -2268,6 +2608,10 @@ private void reInitHiveSyncClient() { hiveClient = (HoodieHiveSyncClient) hiveSyncTool.syncClient; } + private String createTempBasePath(String prefix) throws IOException { + return Files.createTempDirectory(tempDir, prefix).toUri().toString(); + } + private int getPartitionFieldSize() { return hiveSyncProps.getString(META_SYNC_PARTITION_FIELDS.key()).split(",").length; } diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncToolTimelineMidpoint.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncToolTimelineMidpoint.java new file mode 100644 index 0000000000000..3d0b5b3dd4311 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncToolTimelineMidpoint.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.hudi.hive; + +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.testutils.MockHoodieTimeline; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.sync.common.HoodieSyncClient; + +import org.junit.jupiter.api.Test; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link HiveSyncTool#isLastCommitTimeSyncedBehindTimelineMidpoint}, which decides + * whether a no-change conditional sync should still advance last commit time synced. The helper + * reads the completed commits timeline, so these mock {@code getMetaClient().getCommitsTimeline()}; + * a call to any other timeline would hit an unstubbed mock and fail. + */ +class TestHiveSyncToolTimelineMidpoint { + + private static final String TABLE_NAME = "table"; + + @Test + void midpointIsComputedFromCompletedCommitsOnly() { + // Completed commits [100, 102, 104], midpoint 102; the later inflight 106 must not shift it to 104. + HoodieSyncClient syncClient = mockSyncClient(new MockHoodieTimeline(Stream.of("100", "102", "104"), Stream.of("106"))); + HiveSyncTool tool = toolWith(syncClient); + + // Not synced yet: nothing to advance. + stubLastCommitTimeSynced(syncClient, Option.empty()); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + + // Trails the midpoint. + stubLastCommitTimeSynced(syncClient, Option.of("101")); + assertTrue(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + + // At the midpoint is not behind it. + stubLastCommitTimeSynced(syncClient, Option.of("102")); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + + // Past 102 but below 104: behind only if the inflight 106 is wrongly counted. + stubLastCommitTimeSynced(syncClient, Option.of("103")); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + } + + @Test + void midpointHandlesSmallTimelines() { + // Size 1: the sole commit is the midpoint. + HoodieSyncClient syncClient = mockSyncClient(new MockHoodieTimeline(Stream.of("101"), Stream.empty())); + HiveSyncTool tool = toolWith(syncClient); + stubLastCommitTimeSynced(syncClient, Option.of("100")); + assertTrue(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + stubLastCommitTimeSynced(syncClient, Option.of("101")); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + + // Size 2: the midpoint (index 1) is the newer commit. + syncClient = mockSyncClient(new MockHoodieTimeline(Stream.of("100", "102"), Stream.empty())); + tool = toolWith(syncClient); + stubLastCommitTimeSynced(syncClient, Option.of("101")); + assertTrue(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + stubLastCommitTimeSynced(syncClient, Option.of("102")); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + } + + @Test + void emptyCommitsTimelineIsNotBehind() { + HoodieSyncClient syncClient = mockSyncClient(new MockHoodieTimeline(Stream.empty(), Stream.empty())); + HiveSyncTool tool = toolWith(syncClient); + stubLastCommitTimeSynced(syncClient, Option.of("103")); + assertFalse(tool.isLastCommitTimeSyncedBehindTimelineMidpoint(TABLE_NAME)); + } + + private static HoodieSyncClient mockSyncClient(HoodieTimeline commitsTimeline) { + HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class); + when(metaClient.getCommitsTimeline()).thenReturn(commitsTimeline); + HoodieSyncClient syncClient = mock(HoodieSyncClient.class); + when(syncClient.getMetaClient()).thenReturn(metaClient); + return syncClient; + } + + private static HiveSyncTool toolWith(HoodieSyncClient syncClient) { + HiveSyncTool tool = mock(HiveSyncTool.class, CALLS_REAL_METHODS); + tool.syncClient = syncClient; + return tool; + } + + private static void stubLastCommitTimeSynced(HoodieSyncClient syncClient, Option value) { + when(syncClient.getLastCommitTimeSynced(TABLE_NAME)).thenReturn(value); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHoodieHiveSyncClientClose.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHoodieHiveSyncClientClose.java new file mode 100644 index 0000000000000..53c725dc6a3b5 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHoodieHiveSyncClientClose.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.hudi.hive; + +import org.apache.hudi.hive.ddl.DDLExecutor; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link HoodieHiveSyncClient#close()} connection cleanup. + * + *

    Regression coverage for the metastore connection leak: when + * {@code RetryingMetaStoreClient} rebuilds the underlying client on a transient + * error, {@code Hive.closeCurrent()} alone closes the stale singleton-bound + * client and orphans the retry-created one. {@code close()} must therefore + * release the client held on the proxy field directly. + */ +class TestHoodieHiveSyncClientClose { + + @Test + void closeReleasesProxiedMetastoreClientDirectly() throws Exception { + HoodieHiveSyncClient syncClient = mock(HoodieHiveSyncClient.class, CALLS_REAL_METHODS); + IMetaStoreClient metaStoreClient = mock(IMetaStoreClient.class); + DDLExecutor ddlExecutor = mock(DDLExecutor.class); + setField(syncClient, "client", metaStoreClient); + setField(syncClient, "ddlExecutor", ddlExecutor); + + syncClient.close(); + + verify(metaStoreClient).close(); + verify(ddlExecutor).close(); + } + + @Test + void closeSwallowsProxyCloseFailure() throws Exception { + HoodieHiveSyncClient syncClient = mock(HoodieHiveSyncClient.class, CALLS_REAL_METHODS); + IMetaStoreClient metaStoreClient = mock(IMetaStoreClient.class); + DDLExecutor ddlExecutor = mock(DDLExecutor.class); + doThrow(new RuntimeException("transient close failure")).when(metaStoreClient).close(); + setField(syncClient, "client", metaStoreClient); + setField(syncClient, "ddlExecutor", ddlExecutor); + + // A transient failure closing the proxied client must not propagate. + assertDoesNotThrow(syncClient::close); + verify(metaStoreClient).close(); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = HoodieHiveSyncClient.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestMultiPartKeysValueExtractor.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestMultiPartKeysValueExtractor.java index d8b9100309e58..74610a0ea8e66 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestMultiPartKeysValueExtractor.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestMultiPartKeysValueExtractor.java @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -40,4 +42,17 @@ public void testMultiPartExtractor() { // Test extract hive style partition path assertEquals(expected, valueExtractor.extractPartitionValuesInPath("ds=2021-04-25/hh=04")); } + + @Test + public void testValuesContainingEquals() { + MultiPartKeysValueExtractor valueExtractor = new MultiPartKeysValueExtractor(); + // Only the first '=' separates key from value, so a value containing '=' is kept intact. + assertEquals(Collections.singletonList("a=b"), valueExtractor.extractPartitionValuesInPath("k=a=b")); + // base64-encoded value with '=' padding must not be truncated + assertEquals(Collections.singletonList("YWJjZA=="), valueExtractor.extractPartitionValuesInPath("col=YWJjZA==")); + // empty value + assertEquals(Collections.singletonList(""), valueExtractor.extractPartitionValuesInPath("dt=")); + // multiple hive-style parts whose values contain '=' + assertEquals(Arrays.asList("a=b", "YWJj=="), valueExtractor.extractPartitionValuesInPath("k1=a=b/k2=YWJj==")); + } } diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestPartitionValueExtractor.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestPartitionValueExtractor.java index 075542d596717..0cce6ee794236 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestPartitionValueExtractor.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestPartitionValueExtractor.java @@ -49,6 +49,14 @@ public void testHiveStylePartition() { assertThrows( IllegalArgumentException.class, () -> hiveStylePartition.extractPartitionValuesInPath("2021/04/02")); + // Only the first '=' is the separator, so a value containing '=' is preserved. + assertEquals( + Collections.singletonList("a=b=c"), + hiveStylePartition.extractPartitionValuesInPath("k=a=b=c")); + // base64-encoded value with '=' padding must not be truncated + assertEquals( + Collections.singletonList("YWJjZA=="), + hiveStylePartition.extractPartitionValuesInPath("col=YWJjZA==")); } @Test diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestSparkSchemaUtils.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestSparkSchemaUtils.java index d5bf7c666e4ce..9ce19238f0939 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestSparkSchemaUtils.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestSparkSchemaUtils.java @@ -120,6 +120,31 @@ public void testConvertBasicTypes() { assertEquals(sparkSchema.json(), convertedSparkSchema.json()); } + @Test + public void testConvertWithFieldDocs() { + HoodieSchema nestedSchema = HoodieSchema.createRecord("profile", null, null, false, Arrays.asList( + HoodieSchemaField.of("city", HoodieSchema.create(HoodieSchemaType.STRING), "City of the person", null), + HoodieSchemaField.of("zip", HoodieSchema.createNullable(HoodieSchemaType.STRING), null, null))); + HoodieSchema schema = HoodieSchema.createRecord("root", null, null, false, Arrays.asList( + HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.STRING), "Unique \"id\"\nof the record", null), + HoodieSchemaField.of("name", HoodieSchema.createNullable(HoodieSchemaType.STRING), "Name of the person", null), + HoodieSchemaField.of("age", HoodieSchema.createNullable(HoodieSchemaType.INT), null, null), + HoodieSchemaField.of("profile", nestedSchema, "Profile of the person", null))); + + StructType withoutDocs = (StructType) StructType.fromJson(SparkSchemaUtils.convertToSparkSchemaJson(schema)); + assertFalse(withoutDocs.fields()[0].getComment().isDefined()); + assertFalse(((StructType) withoutDocs.fields()[3].dataType()).fields()[0].getComment().isDefined()); + + StructType withDocs = (StructType) StructType.fromJson(SparkSchemaUtils.convertToSparkSchemaJson(schema, true)); + assertEquals("Unique \"id\"\nof the record", withDocs.fields()[0].getComment().get()); + assertEquals("Name of the person", withDocs.fields()[1].getComment().get()); + assertFalse(withDocs.fields()[2].getComment().isDefined()); + assertEquals("Profile of the person", withDocs.fields()[3].getComment().get()); + StructType nestedStruct = (StructType) withDocs.fields()[3].dataType(); + assertEquals("City of the person", nestedStruct.fields()[0].getComment().get()); + assertFalse(nestedStruct.fields()[1].getComment().isDefined()); + } + @Test public void testConvertComplexType() { StructType sparkSchema = parser.parseTableSchema( diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestHMSDDLExecutorCreateTable.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestHMSDDLExecutorCreateTable.java new file mode 100644 index 0000000000000..a03a7c28042f2 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestHMSDDLExecutorCreateTable.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.hudi.hive.ddl; + +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.schema.HoodieSchemaType; +import org.apache.hudi.hive.HiveSyncConfig; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.Table; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Properties; + +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_BASE_PATH; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class TestHMSDDLExecutorCreateTable { + + @Test + void createTableSetsCreateTimeInSeconds() throws Exception { + Properties props = new Properties(); + props.setProperty(META_SYNC_DATABASE_NAME.key(), "testdb"); + props.setProperty(META_SYNC_BASE_PATH.key(), "/tmp/test_table"); + HiveSyncConfig config = new HiveSyncConfig(props, new Configuration()); + + IMetaStoreClient client = mock(IMetaStoreClient.class); + HMSDDLExecutor executor = new HMSDDLExecutor(config, client); + + HoodieSchema schema = HoodieSchema.createRecord("test_record", null, null, + Collections.singletonList(HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.INT)))); + + long beforeSec = System.currentTimeMillis() / 1000; + executor.createTable("test_table", schema, "input.Format", "output.Format", "serde.Class", + new HashMap<>(), new HashMap<>()); + long afterSec = System.currentTimeMillis() / 1000; + + ArgumentCaptor

    captor = ArgumentCaptor.forClass(Table.class); + verify(client).createTable(captor.capture()); + int createTime = captor.getValue().getCreateTime(); + + // createTime must be epoch seconds within the call window. + assertTrue(createTime >= beforeSec && createTime <= afterSec, + "createTime should be epoch seconds within [" + beforeSec + ", " + afterSec + "] but was " + createTime); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.java new file mode 100644 index 0000000000000..73d23859dbfa9 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/ddl/TestQueryBasedDDLExecutorTouchBatching.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.hudi.hive.ddl; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.hive.HiveSyncConfig; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_BATCH_SYNC_PARTITION_NUM; +import static org.apache.hudi.hive.HiveSyncConfigHolder.HIVE_SYNC_BATCHING_ENABLED; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_BASE_PATH; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_DATABASE_NAME; +import static org.apache.hudi.sync.common.HoodieSyncConfig.META_SYNC_PARTITION_FIELDS; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies which execution paths TOUCH batching applies to. + * + *

    {@code hoodie.datasource.hive_sync.batching.enabled} only makes sense where the + * resulting statements are dispatched in parallel. {@link QueryBasedDDLExecutor} is + * also the base class for {@link JDBCExecutor}, which executes the list serially — so + * splitting there would change statement count and partial-application semantics for + * no benefit. The split is therefore driven by {@link QueryBasedDDLExecutor#getTouchBatchSize(int)}, + * which only {@link HiveQueryDDLExecutor} overrides (and only when a driver pool is + * actually present). + */ +class TestQueryBasedDDLExecutorTouchBatching { + + private static final String TABLE_NAME = "tbl"; + private static final int PARTITION_COUNT = 5; + private static final int BATCH_SIZE = 2; + + /** + * Captures the SQL handed to the executor instead of running it. Uses the base + * class's serial {@code runSQLs}, exactly as {@link JDBCExecutor} does. + * + *

    {@code parallelBatchSize} stands in for a subclass that dispatches in parallel: + * when set, {@link #getTouchBatchSize(int)} returns it, mimicking + * {@link HiveQueryDDLExecutor} with a driver pool present. When unset, the base-class + * default applies — the JDBC-mode shape. + */ + private static final class RecordingExecutor extends QueryBasedDDLExecutor { + private final List executed = new ArrayList<>(); + private final Integer parallelBatchSize; + + RecordingExecutor(HiveSyncConfig config) { + this(config, null); + } + + RecordingExecutor(HiveSyncConfig config, Integer parallelBatchSize) { + super(config); + this.parallelBatchSize = parallelBatchSize; + } + + @Override + protected int getTouchBatchSize(int partitionCount) { + return parallelBatchSize != null ? parallelBatchSize : super.getTouchBatchSize(partitionCount); + } + + @Override + public void runSQL(String sql) { + executed.add(sql); + } + + @Override + public Map getTableSchema(String tableName) { + return Collections.emptyMap(); + } + + @Override + public void dropPartitionsToTable(String tableName, List partitionsToDrop) { + // not exercised here + } + + @Override + public void close() { + // no resources held + } + } + + private static HiveSyncConfig configWithBatching(boolean batchingEnabled) { + TypedProperties props = new TypedProperties(); + props.setProperty(META_SYNC_DATABASE_NAME.key(), "db"); + props.setProperty(META_SYNC_BASE_PATH.key(), "file:///tmp/base"); + props.setProperty(META_SYNC_PARTITION_FIELDS.key(), "dt"); + props.setProperty(HIVE_BATCH_SYNC_PARTITION_NUM.key(), String.valueOf(BATCH_SIZE)); + props.setProperty(HIVE_SYNC_BATCHING_ENABLED.key(), String.valueOf(batchingEnabled)); + return new HiveSyncConfig(props); + } + + private static List partitions() { + return IntStream.range(0, PARTITION_COUNT) + .mapToObj(i -> "2026-01-0" + (i + 1)) + .collect(Collectors.toList()); + } + + private static List touchStatements(RecordingExecutor executor) { + // constructPartitionAlterStatements always emits a leading `USE db`; the TOUCH + // statements are everything after it. + return executor.executed.stream() + .filter(sql -> sql.contains(" TOUCH ")) + .collect(Collectors.toList()); + } + + /** + * Given a serial executor (the JDBC-mode shape) with batching enabled, when TOUCH is + * issued for more partitions than the batch size, then a single TOUCH statement is + * still emitted — the flag must not reach non-parallel execution paths. + */ + @Test + void serialExecutorEmitsSingleTouchStatementEvenWithBatchingEnabled() { + RecordingExecutor executor = new RecordingExecutor(configWithBatching(true)); + + executor.touchPartitionsToTable(TABLE_NAME, partitions()); + + List touches = touchStatements(executor); + assertEquals(1, touches.size(), + "Serial executors (e.g. JDBC mode) must emit one TOUCH statement regardless of " + + "hoodie.datasource.hive_sync.batching.enabled"); + assertEquals(PARTITION_COUNT, countPartitionClauses(touches.get(0)), + "The single statement must still cover every partition"); + } + + /** + * Given the same executor with batching disabled, when TOUCH is issued, then the SQL + * is byte-identical to the enabled case — pinning that the flag is a no-op here. + */ + @Test + void serialExecutorTouchSqlIsIdenticalWithAndWithoutBatchingFlag() { + RecordingExecutor withFlag = new RecordingExecutor(configWithBatching(true)); + RecordingExecutor withoutFlag = new RecordingExecutor(configWithBatching(false)); + + withFlag.touchPartitionsToTable(TABLE_NAME, partitions()); + withoutFlag.touchPartitionsToTable(TABLE_NAME, partitions()); + + assertEquals(withoutFlag.executed, withFlag.executed, + "Enabling the batching flag must not change JDBC-mode TOUCH SQL shape"); + } + + /** + * Given an executor that reports a parallel-dispatch batch size (the HiveQL-with-pool + * shape), when TOUCH is issued, then partitions are split across multiple statements. + * This pins that the base-class default is the only thing suppressing the split. + */ + @Test + void parallelExecutorSplitsTouchIntoBatches() { + RecordingExecutor executor = new RecordingExecutor(configWithBatching(true), BATCH_SIZE); + + executor.touchPartitionsToTable(TABLE_NAME, partitions()); + + List touches = touchStatements(executor); + // 5 partitions at 2 per batch -> 3 statements (2, 2, 1). + assertEquals(3, touches.size()); + assertEquals(PARTITION_COUNT, + touches.stream().mapToInt(TestQueryBasedDDLExecutorTouchBatching::countPartitionClauses).sum(), + "Batching must not drop or duplicate partitions"); + } + + private static int countPartitionClauses(String sql) { + int count = 0; + int idx = sql.indexOf("PARTITION ("); + while (idx >= 0) { + count++; + idx = sql.indexOf("PARTITION (", idx + 1); + } + return count; + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/testutils/HiveTestUtil.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/testutils/HiveTestUtil.java index 4d75ca0e6b420..b3b1e2d88d875 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/testutils/HiveTestUtil.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/testutils/HiveTestUtil.java @@ -39,8 +39,8 @@ import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.log.HoodieLogFormat; import org.apache.hudi.common.table.log.HoodieLogFormat.Writer; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType; @@ -85,7 +85,6 @@ import java.io.OutputStream; import java.net.URISyntaxException; import java.nio.file.Files; -import java.time.Instant; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; @@ -140,6 +139,11 @@ public class HiveTestUtil { private static Set createdTablesSet = new HashSet<>(); public static void setUp(Option hiveSyncProperties, boolean shouldClearBasePathAndTables) throws Exception { + setUp(hiveSyncProperties, shouldClearBasePathAndTables, null); + } + + public static void setUp(Option hiveSyncProperties, boolean shouldClearBasePathAndTables, + java.nio.file.Path tempDir) throws Exception { configuration = new Configuration(); if (zkServer == null) { zkService = new ZookeeperTestService(configuration); @@ -155,7 +159,10 @@ public static void setUp(Option hiveSyncProperties, boolean sho hiveSyncProps.setProperty(HIVE_URL.key(), hiveTestService.getJdbcHive2Url()); basePath = hiveSyncProps.getProperty(META_SYNC_BASE_PATH.key()); } else { - basePath = Files.createTempDirectory("hivesynctest" + Instant.now().toEpochMilli()).toUri().toString(); + java.nio.file.Path baseDir = tempDir == null + ? Files.createTempDirectory("hivesynctest") + : Files.createTempDirectory(Files.createDirectories(tempDir), "hivesynctest"); + basePath = baseDir.toUri().toString(); hiveSyncProps = new TypedProperties(); hiveSyncProps.setProperty(HIVE_URL.key(), hiveTestService.getJdbcHive2Url()); @@ -183,15 +190,17 @@ public static void setUp(Option hiveSyncProperties, boolean sho if (shouldClearBasePathAndTables) { clear(); } + if (!hiveSyncProperties.isPresent()) { + HoodieTableMetaClient.newTableBuilder() + .setTableType(HoodieTableType.COPY_ON_WRITE) + .setTableName(TABLE_NAME) + .setPayloadClass(HoodieAvroPayload.class) + .initTable(HadoopFSUtils.getStorageConfWithCopy(configuration), basePath); + } } public static void clear() throws IOException, HiveException, MetaException { fileSystem.delete(new Path(basePath), true); - HoodieTableMetaClient.newTableBuilder() - .setTableType(HoodieTableType.COPY_ON_WRITE) - .setTableName(TABLE_NAME) - .setPayloadClass(HoodieAvroPayload.class) - .initTable(HadoopFSUtils.getStorageConfWithCopy(configuration), basePath); if (ddlExecutor != null) { for (String tableName : createdTablesSet) { @@ -386,6 +395,11 @@ public static void addRollbackInstantToTable(String instantTime, String commitTo public static void createCOWTableWithSchema(String instantTime, String schemaFileName) throws IOException, URISyntaxException { + createCOWTableWithSchema(instantTime, SchemaTestUtil.getSchemaFromResource(HiveTestUtil.class, schemaFileName)); + } + + public static void createCOWTableWithSchema(String instantTime, HoodieSchema schema) + throws IOException, URISyntaxException { Path path = new Path(basePath); FileIOUtils.deleteDirectory(new File(basePath)); HoodieTableMetaClient.newTableBuilder() @@ -408,7 +422,6 @@ public static void createCOWTableWithSchema(String instantTime, String schemaFil String fileId = UUID.randomUUID().toString(); Path filePath = new Path(partPath.toString() + "/" + FSUtils.makeBaseFileName(instantTime, "1-0-1", fileId, HoodieTableConfig.BASE_FILE_FORMAT.defaultValue().getFileExtension())); - HoodieSchema schema = SchemaTestUtil.getSchemaFromResource(HiveTestUtil.class, schemaFileName); generateParquetDataWithSchema(filePath, schema); HoodieWriteStat writeStat = new HoodieWriteStat(); writeStat.setFileId(fileId); @@ -716,8 +729,10 @@ private static HoodieLogFile generateLogData(StoragePath parquetFilePath, HoodieSchema schema = getTestDataSchema(isLogSchemaSimple); HoodieBaseFile dataFile = new HoodieBaseFile(storage.getPathInfo(parquetFilePath)); // Write a log file for this parquet file - Writer logWriter = HoodieLogFormat.newWriterBuilder().onParentPath(parquetFilePath.getParent()) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(dataFile.getFileId()) + Writer logWriter = HoodieLogFormatWriter.builder() + .withParentPath(parquetFilePath.getParent()) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(dataFile.getFileId()) .withInstantTime(dataFile.getCommitTime()).withStorage(storage).build(); List records = (isLogSchemaSimple ? SchemaTestUtil.generateTestRecords(0, 100) : SchemaTestUtil.generateEvolvedTestRecords(100, 100)).stream() @@ -736,9 +751,13 @@ private static HoodieLogFile generateLogData(StoragePath parquetFilePath, String HoodieSchema schema = SchemaTestUtil.getSchema(logSchemaPath); HoodieBaseFile dataFile = new HoodieBaseFile(storage.getPathInfo(parquetFilePath)); // Write a log file for this parquet file - Writer logWriter = HoodieLogFormat.newWriterBuilder().onParentPath(parquetFilePath.getParent()) - .withFileExtension(HoodieLogFile.DELTA_EXTENSION).withFileId(dataFile.getFileId()) - .withInstantTime(dataFile.getCommitTime()).withStorage(storage).build(); + Writer logWriter = HoodieLogFormatWriter.builder() + .withParentPath(parquetFilePath.getParent()) + .withFileExtension(HoodieLogFile.DELTA_EXTENSION) + .withLogFileId(dataFile.getFileId()) + .withInstantTime(dataFile.getCommitTime()) + .withStorage(storage) + .build(); List records = SchemaTestUtil.generateTestRecords(logSchemaPath, dataPath).stream().map(HoodieAvroIndexedRecord::new).collect(Collectors.toList()); Map header = new HashMap<>(2); header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, dataFile.getCommitTime()); diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProviderTestBase.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProviderTestBase.java new file mode 100644 index 0000000000000..0e329e3b2f459 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/HiveMetastoreBasedLockProviderTestBase.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.hudi.hive.transaction.lock; + +import org.apache.hudi.common.config.LockConfiguration; +import org.apache.hudi.common.config.TypedProperties; + +import org.apache.hadoop.hive.metastore.api.LockComponent; +import org.apache.hadoop.hive.metastore.api.LockLevel; +import org.apache.hadoop.hive.metastore.api.LockResponse; +import org.apache.hadoop.hive.metastore.api.LockState; +import org.apache.hadoop.hive.metastore.api.LockType; +import org.junit.jupiter.api.BeforeEach; + +import java.lang.reflect.Field; + +import static org.apache.hudi.common.config.LockConfiguration.DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS; +import static org.apache.hudi.common.config.LockConfiguration.HIVE_DATABASE_NAME_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.HIVE_TABLE_NAME_PROP_KEY; +import static org.apache.hudi.common.config.LockConfiguration.LOCK_HEARTBEAT_INTERVAL_MS_KEY; + +/** + * Shared fixture for the {@link HiveMetastoreBasedLockProvider} unit tests that drive the provider + * against a mocked {@code IMetaStoreClient}, without a live metastore or ZooKeeper. + */ +abstract class HiveMetastoreBasedLockProviderTestBase { + + protected static final String DB = "testdb"; + protected static final String TABLE = "testtable"; + + protected LockConfiguration lockConfiguration; + protected LockComponent lockComponent; + + @BeforeEach + void setUpLockFixture() { + TypedProperties props = new TypedProperties(); + props.setProperty(HIVE_DATABASE_NAME_PROP_KEY, DB); + props.setProperty(HIVE_TABLE_NAME_PROP_KEY, TABLE); + props.setProperty(LOCK_HEARTBEAT_INTERVAL_MS_KEY, String.valueOf(heartbeatIntervalMs())); + lockConfiguration = new LockConfiguration(props); + lockComponent = new LockComponent(LockType.EXCLUSIVE, LockLevel.TABLE, DB); + lockComponent.setTablename(TABLE); + } + + /** + * The heartbeat interval the provider is configured with, overridden by tests that need the + * scheduled heartbeat to actually fire while the test runs. + */ + protected long heartbeatIntervalMs() { + return DEFAULT_LOCK_HEARTBEAT_INTERVAL_MS; + } + + protected static LockResponse acquiredLock(long lockId) { + return lockResponse(lockId, LockState.ACQUIRED); + } + + protected static LockResponse waitingLock(long lockId) { + return lockResponse(lockId, LockState.WAITING); + } + + private static LockResponse lockResponse(long lockId, LockState state) { + LockResponse response = new LockResponse(); + response.setLockid(lockId); + response.setState(state); + return response; + } + + /** + * Reads a private field of the provider, for the state it does not expose: the heartbeat + * schedule and the thread pool running it. + */ + @SuppressWarnings("unchecked") + protected static T readField(HiveMetastoreBasedLockProvider provider, String name) throws Exception { + Field field = HiveMetastoreBasedLockProvider.class.getDeclaredField(name); + field.setAccessible(true); + return (T) field.get(provider); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.java new file mode 100644 index 0000000000000..2dbdf827de87f --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHeartbeat.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.hudi.hive.transaction.lock; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.NoSuchLockException; +import org.apache.hadoop.hive.metastore.api.NoSuchTxnException; +import org.apache.hadoop.hive.metastore.api.TxnAbortedException; +import org.apache.thrift.TException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class TestHeartbeat { + + private final List lockLostCauses = new ArrayList<>(); + + /** + * Failures that mean the metastore has already expired or aborted the lock. They are declared by + * {@code IMetaStoreClient.heartbeat(long, long)} and cannot be recovered from by retrying. + */ + private static Stream terminalFailures() { + return Stream.of( + new NoSuchLockException("lock does not exist"), + new NoSuchTxnException("txn does not exist"), + new TxnAbortedException("txn was aborted")); + } + + @Test + void runDoesNotRethrowWhenHeartbeatFails() throws TException { + IMetaStoreClient client = mock(IMetaStoreClient.class); + doThrow(new TException("transient failure")).when(client).heartbeat(anyLong(), anyLong()); + + Heartbeat heartbeat = new Heartbeat(client, 7L, lockLostCauses::add); + + // Rethrowing here would cancel every subsequent execution of a scheduleAtFixedRate task, + // silently stopping lock renewal. The fix must swallow the failure so the next tick retries. + assertDoesNotThrow(heartbeat::run); + assertTrue(lockLostCauses.isEmpty(), "a transient failure must not be reported as a lost lock"); + } + + @Test + void runKeepsRetryingAfterTransientFailure() throws TException { + IMetaStoreClient client = mock(IMetaStoreClient.class); + doThrow(new TException("transient failure")).when(client).heartbeat(anyLong(), anyLong()); + + Heartbeat heartbeat = new Heartbeat(client, 7L, lockLostCauses::add); + heartbeat.run(); + heartbeat.run(); + + verify(client, times(2)).heartbeat(0L, 7L); + assertTrue(lockLostCauses.isEmpty()); + } + + @Test + void runHeartbeatsTheLockOnSuccess() throws TException { + IMetaStoreClient client = mock(IMetaStoreClient.class); + + new Heartbeat(client, 99L, lockLostCauses::add).run(); + + verify(client, times(1)).heartbeat(0L, 99L); + assertTrue(lockLostCauses.isEmpty()); + } + + @ParameterizedTest + @MethodSource("terminalFailures") + void runReportsLockLossOnTerminalFailure(Exception terminalFailure) throws TException { + IMetaStoreClient client = mock(IMetaStoreClient.class); + doThrow(terminalFailure).when(client).heartbeat(anyLong(), anyLong()); + + Heartbeat heartbeat = new Heartbeat(client, 11L, lockLostCauses::add); + + assertDoesNotThrow(heartbeat::run); + assertEquals(1, lockLostCauses.size()); + assertSame(terminalFailure, lockLostCauses.get(0)); + } + + @ParameterizedTest + @MethodSource("terminalFailures") + void runStopsHeartbeatingAfterTerminalFailure(Exception terminalFailure) throws TException { + IMetaStoreClient client = mock(IMetaStoreClient.class); + doThrow(terminalFailure).when(client).heartbeat(anyLong(), anyLong()); + + Heartbeat heartbeat = new Heartbeat(client, 11L, lockLostCauses::add); + heartbeat.run(); + // A tick already queued when the schedule was cancelled must not renew a lock that is gone, + // nor report the loss a second time. + heartbeat.run(); + + verify(client, times(1)).heartbeat(0L, 11L); + assertEquals(1, lockLostCauses.size()); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderAcquireTimeout.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderAcquireTimeout.java new file mode 100644 index 0000000000000..750e8406da1b0 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderAcquireTimeout.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hudi.hive.transaction.lock; + +import org.apache.hudi.exception.HoodieLockException; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.NoSuchLockException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for what {@link HiveMetastoreBasedLockProvider} reports when the metastore does not + * answer a lock request in time, with a mocked {@link IMetaStoreClient} and no live metastore or + * ZooKeeper. + */ +class TestHiveMetastoreBasedLockProviderAcquireTimeout extends HiveMetastoreBasedLockProviderTestBase { + + private static final long LOCK_ID = 42L; + private static final long ACQUIRE_TIMEOUT_MS = 200L; + + @Test + void acquireTimeoutIsReportedAsATimeout() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + CountDownLatch metastoreAnswers = new CountDownLatch(1); + CountDownLatch lockReturned = new CountDownLatch(1); + when(client.lock(any())).thenAnswer(invocation -> { + metastoreAnswers.await(); + lockReturned.countDown(); + return acquiredLock(LOCK_ID); + }); + // What a real metastore answers for the txn id 0 that the timed-out request carried. Never + // reached now, it is here so that restoring the removed lookup fails this test on the cause of + // the exception rather than on a bare NPE from an unstubbed call. + when(client.checkLock(anyLong())).thenThrow(new NoSuchLockException("No such lock 0")); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + HoodieLockException thrown = assertThrows(HoodieLockException.class, + () -> provider.tryLock(ACQUIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + + // The metastore never answered, and that is what the writer has to be told. Looking the lock + // up afterwards cannot help: the request that timed out never returned a lock id. + assertInstanceOf(TimeoutException.class, thrown.getCause()); + verify(client, never()).checkLock(anyLong()); + assertNull(provider.getLock()); + } finally { + metastoreAnswers.countDown(); + provider.close(); + } + + // A lock granted after the client gave up is abandoned, not released: the timed-out get() never + // assigned it, so neither the acquire path nor close() knows of a lock to unlock or heartbeat. + // It stays held at the metastore until hive.txn.timeout reaps it. + assertTrue(lockReturned.await(30, TimeUnit.SECONDS)); + verify(client, never()).unlock(anyLong()); + verify(client, never()).heartbeat(anyLong(), anyLong()); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.java new file mode 100644 index 0000000000000..3d3a8c26c4e89 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderClose.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.hudi.hive.transaction.lock; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.thrift.TException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link HiveMetastoreBasedLockProvider#close()} that exercise the thread-pool + * shutdown path with a mocked {@link IMetaStoreClient}, without a live metastore or ZooKeeper. + */ +class TestHiveMetastoreBasedLockProviderClose extends HiveMetastoreBasedLockProviderTestBase { + + @Test + void closeShutsDownExecutorEvenWhenUnlockThrows() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(42L)); + doThrow(new TException("boom")).when(client).unlock(anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + // A failing unlock() must not prevent the heartbeat thread pool from being shut down. + assertDoesNotThrow(provider::close); + ScheduledExecutorService executor = readField(provider, "executor"); + assertTrue(executor.isShutdown(), "executor must be shut down even when unlock() throws"); + } + + @Test + void closeShutsDownExecutorOnNormalPath() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(1L)); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + provider.close(); + + verify(client).unlock(1L); + ScheduledExecutorService executor = readField(provider, "executor"); + assertTrue(executor.isShutdown()); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java new file mode 100644 index 0000000000000..b485f4b9cc391 --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/transaction/lock/TestHiveMetastoreBasedLockProviderLockLoss.java @@ -0,0 +1,362 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hudi.hive.transaction.lock; + +import org.apache.hudi.exception.HoodieLockException; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.NoSuchLockException; +import org.apache.thrift.TException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for how {@link HiveMetastoreBasedLockProvider} reacts to the metastore reporting its + * lock as gone, with a mocked {@link IMetaStoreClient} and no live metastore or ZooKeeper. + */ +class TestHiveMetastoreBasedLockProviderLockLoss extends HiveMetastoreBasedLockProviderTestBase { + + private static final long LOCK_ID = 42L; + private static final long OTHER_LOCK_ID = 43L; + private static final long HEARTBEAT_INTERVAL_MS = 100L; + private static final long AWAIT_TIMEOUT_MS = 30_000L; + + @Override + protected long heartbeatIntervalMs() { + // Keep the ticks short so the scheduled heartbeat fires within the test. + return HEARTBEAT_INTERVAL_MS; + } + + @Test + void terminalHeartbeatFailureStopsRenewalAndDropsTheLock() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID)); + doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist")) + .when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + // The metastore has expired the lock, so the provider must stop claiming to hold it. + awaitUntil(() -> provider.getLock() == null, "the lost lock must be dropped by the heartbeat"); + + // The heartbeat task latches the failure on its own, so the count below would stay at one + // even if the schedule were left running. Assert the cancellation itself as well. + assertTrue(heartbeatFutureOf(provider).isCancelled(), "the heartbeat schedule must be cancelled"); + + // Give the scheduler several more intervals: no further heartbeat may be attempted, since + // both the schedule and the heartbeat task itself are stopped after a terminal failure. + Thread.sleep(HEARTBEAT_INTERVAL_MS * 5); + verify(client, times(1)).heartbeat(0L, LOCK_ID); + + // The writer must learn that it no longer holds exclusivity, and the provider must not send + // a doomed unlock for a lock the metastore has already dropped. + assertThrows(HoodieLockException.class, provider::unlock); + verify(client, never()).unlock(anyLong()); + } finally { + provider.close(); + } + } + + @Test + void lockLostAfterTryLockIsReportedOnUnlock() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID)); + doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist")) + .when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + // Same loss, but driven through the entry point the LockManager actually calls rather than + // the test-only acquireLock overload. + assertTrue(provider.tryLock(1000L, TimeUnit.MILLISECONDS)); + awaitUntil(() -> provider.getLock() == null, "the lost lock must be dropped by the heartbeat"); + + assertThrows(HoodieLockException.class, provider::unlock); + verify(client, never()).unlock(anyLong()); + } finally { + provider.close(); + } + } + + @Test + void closeAfterALostLockSendsNoUnlockAndShutsDownTheExecutor() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID)); + doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist")) + .when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + awaitUntil(() -> provider.getLock() == null, "the lost lock must be dropped by the heartbeat"); + } finally { + provider.close(); + } + + // There is nothing left to release at the metastore, but the heartbeat pool must still go away. + verify(client, never()).unlock(anyLong()); + ScheduledExecutorService executor = readField(provider, "executor"); + assertTrue(executor.isShutdown(), "the heartbeat pool must be shut down after a lost lock"); + } + + @Test + void transientHeartbeatFailureKeepsRenewingTheLock() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID)); + CountDownLatch heartbeats = new CountDownLatch(2); + doAnswer(invocation -> { + heartbeats.countDown(); + throw new TException("transient failure"); + }).when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + assertTrue(heartbeats.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS), + "a transient failure must not stop the heartbeat schedule"); + assertNotNull(provider.getLock(), "a transient failure must not drop the lock"); + + provider.unlock(); + verify(client).unlock(LOCK_ID); + assertNull(provider.getLock()); + } finally { + provider.close(); + } + } + + @Test + void lockCanBeAcquiredAgainAfterItWasLost() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), acquiredLock(OTHER_LOCK_ID)); + // Only the first lock is expired by the metastore; heartbeating the second one succeeds. + doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist")) + .doNothing() + .when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + awaitUntil(() -> provider.getLock() == null, "the lost lock must be dropped by the heartbeat"); + + // Acquiring again must clear the lost-lock state, otherwise the provider would keep failing + // to release locks it holds perfectly well. + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + provider.unlock(); + + verify(client).unlock(OTHER_LOCK_ID); + assertNull(provider.getLock()); + } finally { + provider.close(); + } + } + + @Test + void lostLockStateDoesNotLeakIntoTheNextAcquire() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), waitingLock(OTHER_LOCK_ID)); + doThrow(new NoSuchLockException("lock " + LOCK_ID + " does not exist")) + .doNothing() + .when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + awaitUntil(() -> provider.getLock() == null, "the lost lock must be dropped by the heartbeat"); + + // The second attempt only got queued, so it leaves no lock behind and nothing was expired + // by the metastore this time round. + assertFalse(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + verify(client).unlock(OTHER_LOCK_ID); + + // Releasing must not report the loss that belonged to the previous lock. + assertDoesNotThrow(provider::unlock); + } finally { + provider.close(); + } + } + + @Test + void lockThatWasOnlyQueuedIsNeverHeartbeated() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(waitingLock(LOCK_ID)); + doThrow(new NoSuchLockException("lock " + LOCK_ID + " was never granted")) + .when(client).heartbeat(anyLong(), anyLong()); + // Releasing the queued lock is the provider's very next step, and it is an RPC: parking inside + // it holds open exactly the window in which a heartbeat scheduled for that lock would tick. + doAnswer(invocation -> { + Thread.sleep(HEARTBEAT_INTERVAL_MS * 5); + return null; + }).when(client).unlock(anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertFalse(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + // A lock that was never granted must never be renewed: a tick failing for it would be read + // as the metastore taking away exclusivity that the writer never had in the first place. + verify(client, never()).heartbeat(anyLong(), anyLong()); + assertNull(heartbeatFutureOf(provider), "a queued lock must not be given a heartbeat schedule"); + assertDoesNotThrow(provider::unlock); + } finally { + provider.close(); + } + } + + @Test + void unlockStaysSilentWhenNoLockWasEverHeld() { + IMetaStoreClient client = mock(IMetaStoreClient.class); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + // Releasing a lock that was never acquired is still a no-op: only a lock the metastore took + // away is reported as a failure. + assertDoesNotThrow(provider::unlock); + } finally { + provider.close(); + } + } + + @Test + void staleHeartbeatFromAReleasedLockDoesNotDropTheNextLock() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID), acquiredLock(OTHER_LOCK_ID)); + CountDownLatch staleTickStarted = new CountDownLatch(1); + CountDownLatch releaseStaleTick = new CountDownLatch(1); + CountDownLatch newLockHeartbeats = new CountDownLatch(3); + doAnswer(invocation -> { + long heartbeatedLockId = invocation.getArgument(1); + if (heartbeatedLockId == LOCK_ID) { + // Park this tick inside the RPC until the lock it renews has been released and another one + // taken, then fail it the way the metastore fails a heartbeat for a lock it no longer has. + staleTickStarted.countDown(); + releaseStaleTick.await(); + throw new NoSuchLockException("lock " + LOCK_ID + " does not exist"); + } + newLockHeartbeats.countDown(); + return null; + }).when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + assertTrue(staleTickStarted.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS), + "the heartbeat for the first lock must be in flight before it is released"); + + provider.unlock(); + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + + // Only now does the tick scheduled for the first lock come back, failing because that lock + // was released here. Cancelling its schedule could never have stopped it. + releaseStaleTick.countDown(); + + assertTrue(newLockHeartbeats.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS), + "the stale tick must not stop the renewal of the lock held now, which would let the " + + "metastore expire a perfectly healthy lock mid-commit"); + assertNotNull(provider.getLock(), "the stale tick must not drop the lock held now"); + assertFalse(heartbeatFutureOf(provider).isCancelled(), + "the stale tick must not cancel the schedule of the lock held now"); + + assertDoesNotThrow(provider::unlock); + verify(client).unlock(OTHER_LOCK_ID); + } finally { + // Free the parked tick even when an assertion above failed: close() only shuts the executor + // down, which neither interrupts the await nor lets the non-daemon thread exit, so leaving + // it parked would turn a failing test into a hanging JVM. + releaseStaleTick.countDown(); + provider.close(); + } + } + + @Test + void releasingALockNormallyIsNotReportedAsLost() throws Exception { + IMetaStoreClient client = mock(IMetaStoreClient.class); + when(client.lock(any())).thenReturn(acquiredLock(LOCK_ID)); + CountDownLatch tickStarted = new CountDownLatch(1); + CountDownLatch releaseTick = new CountDownLatch(1); + doAnswer(invocation -> { + tickStarted.countDown(); + releaseTick.await(); + throw new NoSuchLockException("lock " + LOCK_ID + " does not exist"); + }).when(client).heartbeat(anyLong(), anyLong()); + + HiveMetastoreBasedLockProvider provider = new HiveMetastoreBasedLockProvider(lockConfiguration, client); + try { + assertTrue(provider.acquireLock(1000L, TimeUnit.MILLISECONDS, lockComponent)); + assertTrue(tickStarted.await(AWAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS), + "the heartbeat must be in flight before the lock is released"); + + provider.unlock(); + verify(client).unlock(LOCK_ID); + releaseTick.countDown(); + + // Let the released tick finish and run whatever it makes of the failure. + Thread.sleep(HEARTBEAT_INTERVAL_MS * 5); + + // The heartbeat failed only because the lock was released here, so releasing again stays the + // no-op it has always been instead of reporting a loss that never happened. + assertDoesNotThrow(provider::unlock); + } finally { + // See the note in staleHeartbeatFromAReleasedLockDoesNotDropTheNextLock: a parked tick must + // never outlive a failed assertion. + releaseTick.countDown(); + provider.close(); + } + } + + private static ScheduledFuture heartbeatFutureOf(HiveMetastoreBasedLockProvider provider) throws Exception { + return readField(provider, "future"); + } + + private static void awaitUntil(BooleanSupplier condition, String message) throws InterruptedException { + long deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(20L); + } + fail(message); + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.java new file mode 100644 index 0000000000000..5b946447ba4dd --- /dev/null +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestHiveDriverPool.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.hudi.hive.util; + +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.hive.HiveSyncConfig; +import org.apache.hudi.hive.HoodieHiveSyncException; + +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.hadoop.hive.ql.Driver; +import org.junit.jupiter.api.Test; +import org.mockito.invocation.InvocationOnMock; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link HiveDriverPool} that exercise bootstrap, dispatch, error + * propagation, and close semantics without standing up a real Hive instance. + */ +class TestHiveDriverPool { + + private static HiveSyncConfig configWithEmptyHiveConf() { + HiveSyncConfig config = mock(HiveSyncConfig.class); + doAnswer(inv -> new HiveConf()).when(config).getHiveConf(); + doAnswer(inv -> "default").when(config).getStringOrDefault( + org.mockito.ArgumentMatchers.any()); + return config; + } + + @Test + void bootstrapBuildsOneDriverPerSlot() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + AtomicInteger built = new AtomicInteger(); + HiveDriverPool.DriverFactory factory = (db) -> { + built.incrementAndGet(); + return mock(Driver.class); + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 3, factory)) { + assertEquals(3, pool.size()); + assertEquals(3, built.get(), "One Driver per slot should be constructed eagerly"); + } + } + + @Test + void bootstrapFailurePropagatesAndTearsDown() { + HiveSyncConfig config = configWithEmptyHiveConf(); + AtomicInteger calls = new AtomicInteger(); + HiveDriverPool.DriverFactory factory = (db) -> { + int n = calls.incrementAndGet(); + if (n == 2) { + throw new RuntimeException("simulated driver build failure"); + } + return mock(Driver.class); + }; + HoodieException ex = assertThrows(HoodieException.class, + () -> new HiveDriverPool(config, 3, factory)); + assertTrue(ex.getMessage().contains("Failed to construct HiveDriverPool")); + } + + @Test + void runAllDispatchesEachSqlAcrossWorkers() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + // Each worker counts how many SQLs it received and remembers the thread. + ConcurrentHashMap> seenThreadsByDriver = new ConcurrentHashMap<>(); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + seenThreadsByDriver.put(d, ConcurrentHashMap.newKeySet()); + doAnswer((InvocationOnMock inv) -> { + seenThreadsByDriver.get(d).add(Thread.currentThread().getName()); + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) { + List sqls = Arrays.asList("SELECT 1", "SELECT 2", "SELECT 3", "SELECT 4"); + HiveDriverPool.Dispatch futures = pool.dispatchAll(sqls); + pool.awaitAll(futures); + assertEquals(2, seenThreadsByDriver.size(), "Expected exactly 2 worker Drivers"); + int totalCalls = seenThreadsByDriver.values().stream().mapToInt(Set::size).sum(); + assertTrue(totalCalls >= 1, "At least one worker should have logged a thread"); + // Each Driver should have been invoked exactly twice (round-robin with 4 sqls, 2 workers). + for (Driver d : seenThreadsByDriver.keySet()) { + verify(d, times(2)).run(anyString()); + } + } + } + + @Test + void awaitAllThrowsFirstError() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + doAnswer(inv -> { + String sql = inv.getArgument(0); + if (sql.equals("FAIL")) { + throw new RuntimeException("boom: " + sql); + } + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) { + HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("OK", "FAIL", "OK")); + HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class, + () -> pool.awaitAll(futures)); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("boom")); + } + } + + @Test + void concurrentDispatchBoundedByPoolSize() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + CountDownLatch hold = new CountDownLatch(1); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + doAnswer(inv -> { + int now = inFlight.incrementAndGet(); + maxInFlight.updateAndGet(prev -> Math.max(prev, now)); + hold.await(2, TimeUnit.SECONDS); + inFlight.decrementAndGet(); + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) { + // 5 SQLs against pool of size 2 → max in-flight should be 2. + HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("a", "b", "c", "d", "e")); + // Release after a short wait so all SQLs progress. + Thread.sleep(150); + hold.countDown(); + pool.awaitAll(futures); + assertTrue(maxInFlight.get() <= 2, + "Max concurrent dispatches must not exceed pool size, observed " + maxInFlight.get()); + assertTrue(maxInFlight.get() >= 1, "Sanity: at least one dispatch ran"); + } + } + + @Test + void closeIsIdempotentAndPreventsFurtherDispatch() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + HiveDriverPool.DriverFactory factory = (db) -> mock(Driver.class); + HiveDriverPool pool = new HiveDriverPool(config, 2, factory); + pool.close(); + pool.close(); + assertThrows(IllegalStateException.class, + () -> pool.dispatchAll(Arrays.asList("anything"))); + } + + @Test + void invalidSizeRejected() { + HiveSyncConfig config = configWithEmptyHiveConf(); + HiveDriverPool.DriverFactory factory = (db) -> mock(Driver.class); + assertThrows(IllegalArgumentException.class, + () -> new HiveDriverPool(config, 0, factory)); + } + + /** + * runOnEachWorker must execute the setup SQL on every worker (each on its bound + * thread) before {@code dispatchAll()} fans the partition statements out. Without this, + * Hive 2.x's SET LOCATION would silently route to the wrong database on the workers + * that never saw the leading USE statement. + */ + @Test + void runOnEachWorkerRunsSetupOnEveryWorker() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + ConcurrentHashMap> sqlsByDriver = new ConcurrentHashMap<>(); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + sqlsByDriver.put(d, java.util.Collections.synchronizedList(new java.util.ArrayList<>())); + doAnswer((InvocationOnMock inv) -> { + sqlsByDriver.get(d).add(inv.getArgument(0)); + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 3, factory)) { + pool.runOnEachWorker(Arrays.asList("USE `db1`")); + HiveDriverPool.Dispatch futures = pool.dispatchAll(Arrays.asList("ALTER 1", "ALTER 2", "ALTER 3")); + pool.awaitAll(futures); + + assertEquals(3, sqlsByDriver.size(), "Expected one Driver per worker"); + for (Map.Entry> e : sqlsByDriver.entrySet()) { + List seen = e.getValue(); + assertTrue(!seen.isEmpty() && seen.get(0).equals("USE `db1`"), + "Each worker must see USE first; saw " + seen); + } + } + } + + /** + * Given a single-worker pool where the first statement fails, when awaitAll runs, + * then it throws the original cause and neither queued statement is ever executed. + * + *

    Deterministic: statements queued behind the failure observe the batch's abort + * flag on entry and bail out without touching the Driver, so it does not matter + * whether the worker dequeues them before or after awaitAll's cancel() sweep. + */ + @Test + void awaitAllCancelsPendingFuturesOnFirstError() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + List executed = Collections.synchronizedList(new ArrayList<>()); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + doAnswer(inv -> { + String sql = inv.getArgument(0); + executed.add(sql); + if (sql.equals("FAIL")) { + throw new RuntimeException("boom"); + } + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 1, factory)) { + HiveDriverPool.Dispatch dispatch = pool.dispatchAll(Arrays.asList("FAIL", "PENDING_A", "PENDING_B")); + + HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class, + () -> pool.awaitAll(dispatch)); + + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("boom")); + assertEquals(Collections.singletonList("FAIL"), executed, + "Statements queued behind the failure must never reach the Driver"); + } + } + + /** + * Regression for the in-order-await bug: a slow statement on worker 0 must not let + * worker 1 keep applying partition DDL after worker 1 has already failed. + * + *

    Given two workers, statements are dispatched round-robin — worker 0 gets + * {@code SLOW} and worker 1 gets {@code FAIL} then {@code AFTER_FAIL}. When awaitAll + * blocks in submission order, it parks on SLOW's future while worker 1 races ahead + * and runs AFTER_FAIL. Then AFTER_FAIL must never execute: the abort flag is set by + * FAIL before worker 1 can dequeue its next statement. + * + *

    SLOW is released only after the batch has aborted, which pins the interleaving + * the bug needs — without that, SLOW could finish first and mask the race. + */ + @Test + void awaitAllStopsLaterWorkerWhenEarlierFutureIsSlow() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + List executed = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch releaseSlow = new CountDownLatch(1); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + doAnswer(inv -> { + String sql = inv.getArgument(0); + executed.add(sql); + if (sql.equals("FAIL")) { + failed.countDown(); + throw new RuntimeException("boom"); + } + if (sql.equals("SLOW")) { + // Hold worker 0 until worker 1 has failed, so awaitAll is definitely still + // parked on future 0 at the moment worker 1 would pick up AFTER_FAIL. + releaseSlow.await(5, TimeUnit.SECONDS); + } + return null; + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 2, factory)) { + // Round-robin over 2 workers: index 0 -> worker 0, indices 1 and 2 -> worker 1. + HiveDriverPool.Dispatch dispatch = + pool.dispatchAll(Arrays.asList("SLOW", "FAIL", "AFTER_FAIL")); + assertTrue(failed.await(5, TimeUnit.SECONDS), "FAIL must have run"); + releaseSlow.countDown(); + + HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class, + () -> pool.awaitAll(dispatch)); + + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("boom")); + assertFalse(executed.contains("AFTER_FAIL"), + "Statement queued behind a failure on the same worker must not be applied, " + + "even while an earlier future on another worker is still running"); + } + } + + /** + * Regression for the swallowed-failure race: awaitAll must still report the error when + * its own cancel() sweep has already marked the failing task's future CANCELLED. + * + *

    The failing worker aborts the batch from inside its catch block, which releases + * awaitAll, but its exception only reaches the FutureTask after {@code call()} returns. + * {@code FutureTask.cancel(false)} succeeds on any task still in state NEW - a task + * mid-unwind included - so the cancel wins the state CAS, the later {@code setException} + * becomes a no-op, and {@code get()} reports CancellationException instead of the error. + * awaitAll then counted it as merely cancelled and returned normally, reporting a failed + * partition-DDL batch as a successful sync. + * + *

    Deterministic where the three tests above are not: instead of hoping the awaiting + * thread wins, this cancels the future explicitly - exactly what cancelPending() does - + * while the Driver is parked, and only then lets it throw. + */ + @Test + void awaitAllReportsFailureWhenFailingFutureIsCancelledMidFlight() throws Exception { + HiveSyncConfig config = configWithEmptyHiveConf(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + HiveDriverPool.DriverFactory factory = (db) -> { + Driver d = mock(Driver.class); + doAnswer(inv -> { + entered.countDown(); + release.await(10, TimeUnit.SECONDS); + throw new RuntimeException("boom"); + }).when(d).run(anyString()); + return d; + }; + try (HiveDriverPool pool = new HiveDriverPool(config, 1, factory)) { + HiveDriverPool.Dispatch dispatch = pool.dispatchAll(Collections.singletonList("FAIL")); + assertTrue(entered.await(10, TimeUnit.SECONDS), "Driver must have started the statement"); + assertTrue(dispatch.futureAt(0).cancel(false), + "Sanity: a running FutureTask is still NEW, so cancel(false) must succeed"); + release.countDown(); + + HoodieHiveSyncException ex = assertThrows(HoodieHiveSyncException.class, + () -> pool.awaitAll(dispatch)); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("boom")); + } + } +} diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestPartitionFilterGenerator.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestPartitionFilterGenerator.java index b607e7f6948c6..a010261a21bdf 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestPartitionFilterGenerator.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/util/TestPartitionFilterGenerator.java @@ -79,6 +79,30 @@ public void testPushDownFilters() { partitionFilterGenerator.generatePushDownFilter(writtenPartitions, partitionFieldSchemas, config)); } + @Test + public void testMinMaxFilterNoNumericOverflow() { + Properties props = new Properties(); + // force the min/max branch so the values are sorted by ValueComparator + props.put(HIVE_SYNC_FILTER_PUSHDOWN_MAX_SIZE.key(), "0"); + HiveSyncConfig config = new HiveSyncConfig(props); + List partitionFieldSchemas = new ArrayList<>(2); + partitionFieldSchemas.add(new FieldSchema("intcol", "int")); + partitionFieldSchemas.add(new FieldSchema("bigcol", "bigint")); + + List writtenPartitions = new ArrayList<>(); + // extreme values whose pairwise difference overflows int/long subtraction + writtenPartitions.add("2147483647/9223372036854775807"); + writtenPartitions.add("-2147483648/-9223372036854775808"); + writtenPartitions.add("0/0"); + + // bounds must reflect the true numeric order; a subtraction-based comparator overflows and + // would pick wrong min/max (or throw a comparator-contract violation). + assertEquals( + "((intcol >= -2147483648 AND intcol <= 2147483647) " + + "AND (bigcol >= -9223372036854775808 AND bigcol <= 9223372036854775807))", + partitionFilterGenerator.generatePushDownFilter(writtenPartitions, partitionFieldSchemas, config)); + } + @Test public void testPushDownFilterIfExceedLimit() { Properties props = new Properties(); diff --git a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/metrics/HoodieMetaSyncMetrics.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/metrics/HoodieMetaSyncMetrics.java index c21adf7bc8124..c0ea52b475758 100644 --- a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/metrics/HoodieMetaSyncMetrics.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/metrics/HoodieMetaSyncMetrics.java @@ -78,6 +78,9 @@ private Timer createTimer(String name) { } public void incrementRecreateAndSyncFailureCounter() { + if (!metricsConfig.isMetricsOn()) { + return; + } recreateAndSyncFailureCounter = getCounter(recreateAndSyncFailureCounter, recreateAndSyncFailureCounterName); recreateAndSyncFailureCounter.inc(); } diff --git a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkDataSourceTableUtils.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkDataSourceTableUtils.java index d747a169aa411..73b7d6c6e8990 100644 --- a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkDataSourceTableUtils.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkDataSourceTableUtils.java @@ -38,6 +38,17 @@ public class SparkDataSourceTableUtils { */ public static Map getSparkTableProperties(List partitionNames, String sparkVersion, int schemaLengthThreshold, HoodieSchema schema) { + return getSparkTableProperties(partitionNames, sparkVersion, schemaLengthThreshold, schema, false); + } + + /** + * Get Spark Sql related table properties. This is used for spark datasource table. + * @param schema The schema to write to the table. + * @param includeFieldDocs Whether to include the field docs as column comments in the serialized spark schema. + * @return A new parameters added the spark's table properties. + */ + public static Map getSparkTableProperties(List partitionNames, String sparkVersion, + int schemaLengthThreshold, HoodieSchema schema, boolean includeFieldDocs) { // Convert the schema and partition info used by spark sql to hive table properties. // The following code refers to the spark code in // https://github.com/apache/spark/blob/master/sql/hive/src/main/scala/org/apache/spark/sql/hive/HiveExternalCatalog.scala @@ -73,7 +84,7 @@ public static Map getSparkTableProperties(List partition sparkProperties.put("spark.sql.create.version", sparkVersion); } // Split the schema string to multi-parts according the schemaLengthThreshold size. - String schemaString = SparkSchemaUtils.convertToSparkSchemaJson(reOrderedSchema); + String schemaString = SparkSchemaUtils.convertToSparkSchemaJson(reOrderedSchema, includeFieldDocs); int numSchemaPart = (schemaString.length() + schemaLengthThreshold - 1) / schemaLengthThreshold; sparkProperties.put("spark.sql.sources.schema.numParts", String.valueOf(numSchemaPart)); // Add each part of schema string to sparkProperties diff --git a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkSchemaUtils.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkSchemaUtils.java index 91adf3c6097f2..044681123ad10 100644 --- a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkSchemaUtils.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/SparkSchemaUtils.java @@ -19,8 +19,12 @@ package org.apache.hudi.sync.common.util; import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; import org.apache.hudi.common.schema.HoodieSchemaType; +import java.util.ArrayList; +import java.util.List; + /** * Convert the Hoodie schema to spark schema' json string. * This code is refer to org.apache.spark.sql.execution.datasources.parquet.ParquetToSparkSchemaConverter @@ -29,18 +33,69 @@ public class SparkSchemaUtils { public static String convertToSparkSchemaJson(HoodieSchema schema) { + return convertToSparkSchemaJson(schema, false); + } + + /** + * @param includeFieldDocs whether field docs should be included in the field metadata + * as {@code comment}, which spark displays as the column comment + */ + public static String convertToSparkSchemaJson(HoodieSchema schema, boolean includeFieldDocs) { String fieldsJsonString = schema.getFields().stream().map(field -> { - String metadata = "{}"; - if (field.getNonNullSchema().isBlobField()) { - metadata = String.format("{\"%s\":\"%s\"}", HoodieSchema.TYPE_METADATA_FIELD, HoodieSchemaType.BLOB.name()); - } - return "{\"name\":\"" + field.name() + "\",\"type\":" + convertFieldType(field.getNonNullSchema()) + String metadata = convertFieldMetadata(field, includeFieldDocs); + return "{\"name\":\"" + field.name() + "\",\"type\":" + convertFieldType(field.getNonNullSchema(), includeFieldDocs) + ",\"nullable\":" + field.isNullable() + ",\"metadata\":" + metadata + "}"; }).reduce((a, b) -> a + "," + b).orElse(""); return "{\"type\":\"struct\",\"fields\":[" + fieldsJsonString + "]}"; } - private static String convertFieldType(HoodieSchema originalFieldSchema) { + private static String convertFieldMetadata(HoodieSchemaField field, boolean includeFieldDocs) { + List entries = new ArrayList<>(2); + if (includeFieldDocs) { + field.doc().ifPresent(doc -> { + if (!doc.isEmpty()) { + entries.add("\"comment\":\"" + escapeJsonString(doc) + "\""); + } + }); + } + if (field.getNonNullSchema().isBlobField()) { + entries.add(String.format("\"%s\":\"%s\"", HoodieSchema.TYPE_METADATA_FIELD, HoodieSchemaType.BLOB.name())); + } + return "{" + String.join(",", entries) + "}"; + } + + private static String escapeJsonString(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + private static String convertFieldType(HoodieSchema originalFieldSchema, boolean includeFieldDocs) { HoodieSchema fieldSchema = originalFieldSchema.getNonNullType(); switch (fieldSchema.getType()) { case BOOLEAN: return "\"boolean\""; @@ -78,24 +133,24 @@ private static String convertFieldType(HoodieSchema originalFieldSchema) { HoodieSchema.Decimal decimal = (HoodieSchema.Decimal) fieldSchema; return "\"decimal(" + decimal.getPrecision() + "," + decimal.getScale() + ")\""; case ARRAY: - return arrayType(fieldSchema.getElementType()); + return arrayType(fieldSchema.getElementType(), includeFieldDocs); case MAP: HoodieSchema keyType = fieldSchema.getKeyType(); HoodieSchema valueType = fieldSchema.getValueType(); boolean valueOptional = valueType.isNullable(); - return "{\"type\":\"map\", \"keyType\":" + convertFieldType(keyType) - + ",\"valueType\":" + convertFieldType(valueType) + return "{\"type\":\"map\", \"keyType\":" + convertFieldType(keyType, includeFieldDocs) + + ",\"valueType\":" + convertFieldType(valueType, includeFieldDocs) + ",\"valueContainsNull\":" + valueOptional + "}"; case RECORD: case BLOB: case VARIANT: - return convertToSparkSchemaJson(fieldSchema); + return convertToSparkSchemaJson(fieldSchema, includeFieldDocs); default: throw new UnsupportedOperationException("Cannot convert " + fieldSchema.getType() + " to spark sql type"); } } - private static String arrayType(HoodieSchema elementType) { - return "{\"type\":\"array\", \"elementType\":" + convertFieldType(elementType) + ",\"containsNull\":" + elementType.isNullable() + "}"; + private static String arrayType(HoodieSchema elementType, boolean includeFieldDocs) { + return "{\"type\":\"array\", \"elementType\":" + convertFieldType(elementType, includeFieldDocs) + ",\"containsNull\":" + elementType.isNullable() + "}"; } } diff --git a/hudi-tests-common/pom.xml b/hudi-tests-common/pom.xml index af3ec9c9b21da..e174ba6d554ca 100644 --- a/hudi-tests-common/pom.xml +++ b/hudi-tests-common/pom.xml @@ -68,7 +68,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl compile diff --git a/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java b/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java index 9f804086f4811..715a8171f6c47 100644 --- a/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java +++ b/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java @@ -40,7 +40,6 @@ import java.io.BufferedWriter; import java.io.IOException; -import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Serializable; import java.nio.charset.StandardCharsets; @@ -54,7 +53,6 @@ import java.util.stream.Stream; import static org.apache.hudi.common.util.MarkerUtils.MARKERS_FILENAME_PREFIX; -import static org.apache.hudi.io.util.FileIOUtils.closeQuietly; import static org.apache.hudi.timeline.service.RequestHandler.jsonifyResult; /** @@ -207,46 +205,62 @@ public void processMarkerCreationRequests( log.debug("timeMs={} markerDirPath={} numRequests={} fileIndex={}", System.currentTimeMillis(), markerDirPath, pendingMarkerCreationFutures.size(), fileIndex); boolean shouldFlushMarkers = false; - - synchronized (markerCreationProcessingLock) { - for (MarkerCreationFuture future : pendingMarkerCreationFutures) { - String markerName = future.getMarkerName(); - boolean exists = allMarkers.contains(markerName); - if (!exists) { - if (conflictDetectionStrategy.isPresent()) { - try { - conflictDetectionStrategy.get().detectAndResolveConflictIfNecessary(); - } catch (HoodieEarlyConflictDetectionException he) { - log.error("Detected the write conflict due to a concurrent writer, " - + "failing the marker creation as the early conflict detection is enabled", he); - future.setIsSuccessful(false); - continue; - } catch (Exception e) { - log.warn("Failed to execute early conflict detection. Marker creation will continue.", e); - // When early conflict detection fails to execute, we still allow the marker creation - // to continue - addMarkerToMap(fileIndex, markerName); - future.setIsSuccessful(true); - shouldFlushMarkers = true; - continue; + int fileMarkersLengthBeforeBatch = 0; + + try { + synchronized (markerCreationProcessingLock) { + StringBuilder fileMarkers = fileMarkersMap.get(fileIndex); + fileMarkersLengthBeforeBatch = fileMarkers == null ? 0 : fileMarkers.length(); + for (MarkerCreationFuture future : pendingMarkerCreationFutures) { + String markerName = future.getMarkerName(); + boolean exists = allMarkers.contains(markerName); + if (!exists) { + if (conflictDetectionStrategy.isPresent()) { + try { + conflictDetectionStrategy.get().detectAndResolveConflictIfNecessary(); + } catch (HoodieEarlyConflictDetectionException he) { + log.error("Detected the write conflict due to a concurrent writer, " + + "failing the marker creation as the early conflict detection is enabled", he); + future.setIsSuccessful(false); + continue; + } catch (Exception e) { + log.warn("Failed to execute early conflict detection. Marker creation will continue.", e); + // When early conflict detection fails to execute, we still allow the marker creation + // to continue + addMarkerToMap(fileIndex, markerName); + future.setIsSuccessful(true); + shouldFlushMarkers = true; + continue; + } } + addMarkerToMap(fileIndex, markerName); + shouldFlushMarkers = true; } - addMarkerToMap(fileIndex, markerName); - shouldFlushMarkers = true; + future.setIsSuccessful(!exists); } - future.setIsSuccessful(!exists); - } - if (!isMarkerTypeWritten) { - // Create marker directory and write marker type to MARKERS.type - writeMarkerTypeToFile(); - isMarkerTypeWritten = true; + if (!isMarkerTypeWritten) { + // Create marker directory and write marker type to MARKERS.type + writeMarkerTypeToFile(); + isMarkerTypeWritten = true; + } } + if (shouldFlushMarkers) { + flushMarkersToFile(fileIndex); + } + } catch (Exception e) { + log.error("Failed to persist markers to file index {} in {}", fileIndex, markerDirPath, e); + // The markers added by this batch are not durably persisted, so they are removed from + // the in-memory state and all pending requests fail, so that no write operation + // proceeds without a durable marker and a retried request can recreate the marker + removeMarkersOfPendingFutures(pendingMarkerCreationFutures, fileIndex, fileMarkersLengthBeforeBatch); + for (MarkerCreationFuture future : pendingMarkerCreationFutures) { + future.completeExceptionally(e); + } + return; + } finally { + markFileAsAvailable(fileIndex); } - if (shouldFlushMarkers) { - flushMarkersToFile(fileIndex); - } - markFileAsAvailable(fileIndex); for (MarkerCreationFuture future : pendingMarkerCreationFutures) { try { @@ -309,6 +323,29 @@ private void addMarkerToMap(int fileIndex, String markerName) { stringBuilder.append('\n'); } + /** + * Removes the markers added by the pending marker creation requests from the in-memory state, + * used when the markers cannot be persisted, so that a retried request can recreate the markers. + * + * @param pendingMarkerCreationFutures futures of pending marker creation requests + * @param fileIndex file index used by the batch of requests + * @param fileMarkersLengthBeforeBatch length of the buffered markers of the file index before the batch + */ + private void removeMarkersOfPendingFutures( + List pendingMarkerCreationFutures, int fileIndex, int fileMarkersLengthBeforeBatch) { + synchronized (markerCreationProcessingLock) { + for (MarkerCreationFuture future : pendingMarkerCreationFutures) { + if (future.isSuccessful()) { + allMarkers.remove(future.getMarkerName()); + } + } + StringBuilder fileMarkers = fileMarkersMap.get(fileIndex); + if (fileMarkers != null) { + fileMarkers.setLength(fileMarkersLengthBeforeBatch); + } + } + } + /** * Writes marker type, "TIMELINE_SERVER_BASED", to file. */ @@ -357,17 +394,14 @@ private void flushMarkersToFile(int markerFileIndex) { HoodieTimer timer = HoodieTimer.start(); StoragePath markersFilePath = new StoragePath( markerDirPath, MARKERS_FILENAME_PREFIX + markerFileIndex); - OutputStream outputStream = null; - BufferedWriter bufferedWriter = null; - try { - outputStream = storage.create(markersFilePath); - bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + // The writer must be closed within the try scope, so that a failure to persist the markers + // at close() time, e.g., when an object store uploads the file content in close(), is + // propagated to the caller instead of being swallowed + try (BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter( + storage.create(markersFilePath), StandardCharsets.UTF_8))) { bufferedWriter.write(fileMarkersMap.get(markerFileIndex).toString()); } catch (IOException e) { throw new HoodieIOException("Failed to overwrite marker file " + markersFilePath, e); - } finally { - closeQuietly(bufferedWriter); - closeQuietly(outputStream); } log.debug("{} written in {} ms", markersFilePath, timer.endTimer()); } diff --git a/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.java b/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.java new file mode 100644 index 0000000000000..76597609c2901 --- /dev/null +++ b/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.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.hudi.timeline.service.handlers.marker; + +import org.apache.hudi.common.metrics.Registry; +import org.apache.hudi.common.testutils.HoodieCommonTestHarness; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.io.util.FileIOUtils; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.hadoop.HoodieHadoopStorage; + +import io.javalin.http.Context; +import org.apache.hadoop.conf.Configuration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.concurrent.ExecutionException; + +import static org.apache.hudi.common.util.MarkerUtils.MARKERS_FILENAME_PREFIX; +import static org.apache.hudi.common.util.MarkerUtils.MARKER_TYPE_FILENAME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Tests {@link MarkerDirState}. + */ +public class TestMarkerDirState extends HoodieCommonTestHarness { + private static final String MARKER_NAME = + "2016/68b3ad4d-9d43-4e4c-8f4b-4c9b6d3e8b3a-0_1-0-1_00000000000001.parquet.marker.CREATE"; + + private CloseFailingHoodieStorage storage; + private String markerDir; + + @BeforeEach + void setUp() throws IOException { + initPath(); + storage = new CloseFailingHoodieStorage(basePath, new Configuration()); + markerDir = basePath + "/.hoodie/.temp/00000000000001"; + } + + @Test + void testMarkerCreationRequestProcessing() throws Exception { + MarkerDirState dirState = createMarkerDirState(); + MarkerCreationFuture future = createFuture(); + int fileIndex = dirState.getNextFileIndexToUse().get(); + + dirState.processMarkerCreationRequests(Collections.singletonList(future), fileIndex); + + assertTrue(future.isSuccessful()); + assertEquals("true", future.get()); + assertEquals(Collections.singleton(MARKER_NAME), dirState.getAllMarkers()); + assertEquals(MARKER_NAME + "\n", readMarkersFileContent(fileIndex)); + // The file index must be released after the batch is processed + assertEquals(Option.of(fileIndex), dirState.getNextFileIndexToUse()); + } + + @Test + void testMarkerCreationRequestsFailWhenFlushFails() { + MarkerDirState dirState = createMarkerDirState(); + storage.setShouldFailClose(true); + MarkerCreationFuture future = createFuture(); + int fileIndex = dirState.getNextFileIndexToUse().get(); + + dirState.processMarkerCreationRequests(Collections.singletonList(future), fileIndex); + + assertTrue(future.isCompletedExceptionally()); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + assertInstanceOf(HoodieIOException.class, exception.getCause()); + // The marker without durable persistence must not be acknowledged as existing + assertTrue(dirState.getAllMarkers().isEmpty()); + // The file index must be released even if the flush fails + assertEquals(Option.of(fileIndex), dirState.getNextFileIndexToUse()); + } + + @Test + void testMarkerRecreationAfterFlushFailure() throws Exception { + MarkerDirState dirState = createMarkerDirState(); + storage.setShouldFailClose(true); + MarkerCreationFuture failedFuture = createFuture(); + dirState.processMarkerCreationRequests(Collections.singletonList(failedFuture), 0); + assertTrue(failedFuture.isCompletedExceptionally()); + + // A retried request for the same marker must succeed once the storage recovers + storage.setShouldFailClose(false); + MarkerCreationFuture retriedFuture = createFuture(); + dirState.processMarkerCreationRequests(Collections.singletonList(retriedFuture), 0); + + assertTrue(retriedFuture.isSuccessful()); + assertEquals("true", retriedFuture.get()); + assertEquals(Collections.singleton(MARKER_NAME), dirState.getAllMarkers()); + assertEquals(MARKER_NAME + "\n", readMarkersFileContent(0)); + } + + private MarkerDirState createMarkerDirState() { + return new MarkerDirState( + markerDir, 1, Option.empty(), storage, Registry.getRegistry("TestMarkerDirState"), 1); + } + + private MarkerCreationFuture createFuture() { + return new MarkerCreationFuture(mock(Context.class), markerDir, MARKER_NAME); + } + + private String readMarkersFileContent(int fileIndex) throws IOException { + return FileIOUtils.readAsUTFString( + storage.open(new StoragePath(markerDir, MARKERS_FILENAME_PREFIX + fileIndex))); + } + + /** + * Storage whose created streams for {@code MARKERS} index files fail at close() time, + * simulating an object store that uploads the file content in close(). + */ + private static class CloseFailingHoodieStorage extends HoodieHadoopStorage { + private boolean shouldFailClose = false; + + CloseFailingHoodieStorage(String path, Configuration conf) { + super(path, conf); + } + + void setShouldFailClose(boolean shouldFailClose) { + this.shouldFailClose = shouldFailClose; + } + + @Override + public OutputStream create(StoragePath path) throws IOException { + OutputStream stream = super.create(path); + String fileName = path.getName(); + if (shouldFailClose + && fileName.startsWith(MARKERS_FILENAME_PREFIX) && !fileName.equals(MARKER_TYPE_FILENAME)) { + return new CloseFailingStream(stream); + } + return stream; + } + } + + private static class CloseFailingStream extends FilterOutputStream { + CloseFailingStream(OutputStream delegate) { + super(delegate); + } + + @Override + public void close() throws IOException { + super.close(); + throw new IOException("Simulated failure to persist the file at close() time"); + } + } +} diff --git a/hudi-trino-plugin/pom.xml b/hudi-trino-plugin/pom.xml deleted file mode 100644 index c67ab6a07f512..0000000000000 --- a/hudi-trino-plugin/pom.xml +++ /dev/null @@ -1,482 +0,0 @@ - - - 4.0.0 - - - io.trino - trino-root - 472 - - - - - trino-hudi - trino-plugin - Trino - Hudi connector - - - true - 1.0.2 - 1.15.2 - - - - - - com.esotericsoftware - kryo - 4.0.2 - - - - com.google.errorprone - error_prone_annotations - true - - - - com.google.guava - guava - - - - com.google.inject - guice - - - - io.airlift - bootstrap - - - - io.airlift - concurrent - - - - io.airlift - configuration - - - - io.airlift - json - - - - io.airlift - log - - - - io.airlift - units - - - - io.trino - trino-cache - - - - io.trino - trino-filesystem - - - - io.trino - trino-filesystem-manager - - - - io.trino - trino-hive - - - - io.trino - trino-memory-context - - - - io.trino - trino-metastore - - - - io.trino - trino-parquet - - - - io.trino - trino-plugin-toolkit - - - - jakarta.validation - jakarta.validation-api - - - - joda-time - joda-time - - - - org.apache.avro - avro - - - - org.apache.hudi - hudi-common - ${dep.hudi.version} - - - io.dropwizard.metrics - * - - - org.apache.hbase - * - - - org.apache.httpcomponents - * - - - org.apache.orc - * - - - - - - org.apache.hudi - hudi-hive-sync - ${dep.hudi.version} - - - org.apache.hudi - hudi-hadoop-common - - - - - - org.apache.hudi - hudi-io - ${dep.hudi.version} - - - com.google.protobuf - protobuf-java - - - - - - org.apache.hudi - hudi-sync-common - ${dep.hudi.version} - - - org.apache.hudi - hudi-hadoop-common - - - - - - org.apache.parquet - parquet-column - - - - org.weakref - jmxutils - - - - com.fasterxml.jackson.core - jackson-annotations - provided - - - - io.airlift - slice - provided - - - - io.opentelemetry - opentelemetry-api - provided - - - - io.opentelemetry - opentelemetry-api-incubator - provided - - - - io.opentelemetry - opentelemetry-context - provided - - - - io.trino - trino-spi - provided - - - - org.openjdk.jol - jol-core - provided - - - - com.github.ben-manes.caffeine - caffeine - runtime - - - - io.airlift - log-manager - runtime - - - - io.dropwizard.metrics - metrics-core - runtime - - - - io.opentelemetry - opentelemetry-sdk-trace - runtime - - - - io.trino - trino-hive-formats - runtime - - - - org.jetbrains - annotations - runtime - - - - io.airlift - junit-extensions - test - - - - io.airlift - testing - test - - - - io.trino - trino-client - test - - - - io.trino - trino-filesystem - test-jar - test - - - - io.trino - trino-hdfs - test - - - - io.trino - trino-hive - test-jar - test - - - - io.trino - trino-main - test - - - - io.trino - trino-main - test-jar - test - - - - io.trino - trino-parser - test - - - - io.trino - trino-spi - test-jar - test - - - - io.trino - trino-testing - test - - - - io.trino - trino-testing-containers - test - - - - io.trino - trino-testing-services - test - - - - io.trino - trino-tpch - test - - - - io.trino.hadoop - hadoop-apache - test - - - - io.trino.tpch - tpch - test - - - - org.apache.hudi - hudi-client-common - ${dep.hudi.version} - test - - - * - * - - - - - - org.apache.hudi - hudi-hadoop-common - ${dep.hudi.version} - test - - - * - * - - - - - - org.apache.hudi - hudi-java-client - ${dep.hudi.version} - test - - - org.apache.hudi - * - - - - - - org.apache.parquet - parquet-avro - ${trino.parquet.version} - test - - - - org.apache.parquet - parquet-hadoop - test - - - - org.assertj - assertj-core - test - - - - org.json - json - 20250107 - test - - - - org.junit.jupiter - junit-jupiter-api - test - - - - org.junit.jupiter - junit-jupiter-engine - test - - - org.junit.jupiter - junit-jupiter-params - test - - - - - - - org.basepom.maven - duplicate-finder-maven-plugin - - - - log4j.properties - log4j-surefire.properties - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java deleted file mode 100644 index 9b8411fdf9079..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java +++ /dev/null @@ -1,444 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; -import com.google.inject.Inject; -import io.airlift.log.Logger; -import io.trino.filesystem.Location; -import io.trino.filesystem.TrinoFileSystem; -import io.trino.filesystem.TrinoFileSystemFactory; -import io.trino.filesystem.TrinoInputFile; -import io.trino.memory.context.AggregatedMemoryContext; -import io.trino.parquet.ParquetCorruptionException; -import io.trino.parquet.ParquetDataSource; -import io.trino.parquet.ParquetDataSourceId; -import io.trino.parquet.ParquetReaderOptions; -import io.trino.parquet.metadata.FileMetadata; -import io.trino.parquet.metadata.ParquetMetadata; -import io.trino.parquet.predicate.TupleDomainParquetPredicate; -import io.trino.parquet.reader.MetadataReader; -import io.trino.parquet.reader.ParquetReader; -import io.trino.parquet.reader.RowGroupInfo; -import io.trino.plugin.base.metrics.FileFormatDataSourceStats; -import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hive.ReaderColumns; -import io.trino.plugin.hive.parquet.ParquetReaderConfig; -import io.trino.plugin.hudi.file.HudiBaseFile; -import io.trino.plugin.hudi.reader.HudiTrinoReaderContext; -import io.trino.plugin.hudi.storage.HudiTrinoStorage; -import io.trino.plugin.hudi.storage.TrinoStorageConfiguration; -import io.trino.plugin.hudi.util.SynthesizedColumnHandler; -import io.trino.spi.TrinoException; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.ConnectorPageSource; -import io.trino.spi.connector.ConnectorPageSourceProvider; -import io.trino.spi.connector.ConnectorSession; -import io.trino.spi.connector.ConnectorSplit; -import io.trino.spi.connector.ConnectorTableHandle; -import io.trino.spi.connector.ConnectorTransactionHandle; -import io.trino.spi.connector.DynamicFilter; -import io.trino.spi.connector.EmptyPageSource; -import io.trino.spi.predicate.TupleDomain; -import org.apache.avro.Schema; -import org.apache.avro.generic.IndexedRecord; -import org.apache.hudi.common.model.HoodieTableType; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.read.HoodieFileGroupReader; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.storage.StoragePath; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.io.MessageColumnIO; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.Type; -import org.joda.time.DateTimeZone; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalLong; -import java.util.stream.Collectors; - -import static io.trino.memory.context.AggregatedMemoryContext.newSimpleAggregatedMemoryContext; -import static io.trino.parquet.ParquetTypeUtils.getColumnIO; -import static io.trino.parquet.ParquetTypeUtils.getDescriptors; -import static io.trino.parquet.predicate.PredicateUtils.buildPredicate; -import static io.trino.parquet.predicate.PredicateUtils.getFilteredRowGroups; -import static io.trino.plugin.hive.HiveColumnHandle.partitionColumnHandle; -import static io.trino.plugin.hive.HivePageSourceProvider.projectBaseColumns; -import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.ParquetReaderProvider; -import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createDataSource; -import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createParquetPageSource; -import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.getParquetMessageType; -import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.getParquetTupleDomain; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CANNOT_OPEN_SPLIT; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CURSOR_ERROR; -import static io.trino.plugin.hudi.HudiSessionProperties.getParquetMaxReadBlockRowCount; -import static io.trino.plugin.hudi.HudiSessionProperties.getParquetMaxReadBlockSize; -import static io.trino.plugin.hudi.HudiSessionProperties.getParquetSmallFileThreshold; -import static io.trino.plugin.hudi.HudiSessionProperties.isParquetIgnoreStatistics; -import static io.trino.plugin.hudi.HudiSessionProperties.isParquetUseColumnIndex; -import static io.trino.plugin.hudi.HudiSessionProperties.isParquetVectorizedDecodingEnabled; -import static io.trino.plugin.hudi.HudiSessionProperties.shouldUseParquetColumnNames; -import static io.trino.plugin.hudi.HudiSessionProperties.useParquetBloomFilter; -import static io.trino.plugin.hudi.HudiUtil.buildTableMetaClient; -import static io.trino.plugin.hudi.HudiUtil.constructSchema; -import static io.trino.plugin.hudi.HudiUtil.convertToFileSlice; -import static io.trino.plugin.hudi.HudiUtil.getLatestTableSchema; -import static io.trino.plugin.hudi.HudiUtil.prependHudiMetaColumns; -import static java.lang.String.format; -import static java.util.Objects.requireNonNull; -import static java.util.stream.Collectors.toUnmodifiableList; - -public class HudiPageSourceProvider - implements ConnectorPageSourceProvider -{ - private static final Logger log = Logger.get(HudiPageSourceProvider.class); - private static final int DOMAIN_COMPACTION_THRESHOLD = 1000; - - private final TrinoFileSystemFactory fileSystemFactory; - private final FileFormatDataSourceStats dataSourceStats; - private final ParquetReaderOptions options; - private final DateTimeZone timeZone = DateTimeZone.forID("UTC"); - - @Inject - public HudiPageSourceProvider( - TrinoFileSystemFactory fileSystemFactory, - FileFormatDataSourceStats dataSourceStats, - ParquetReaderConfig parquetReaderConfig) - { - this.fileSystemFactory = requireNonNull(fileSystemFactory, "fileSystemFactory is null"); - this.dataSourceStats = requireNonNull(dataSourceStats, "dataSourceStats is null"); - this.options = requireNonNull(parquetReaderConfig, "parquetReaderConfig is null").toParquetReaderOptions(); - } - - @Override - public ConnectorPageSource createPageSource( - ConnectorTransactionHandle transaction, - ConnectorSession session, - ConnectorSplit connectorSplit, - ConnectorTableHandle connectorTable, - List columns, - DynamicFilter dynamicFilter) - { - HudiTableHandle hudiTableHandle = (HudiTableHandle) connectorTable; - HudiSplit hudiSplit = (HudiSplit) connectorSplit; - Optional hudiBaseFileOpt = hudiSplit.getBaseFile(); - - String dataFilePath = hudiBaseFileOpt.isPresent() - ? hudiBaseFileOpt.get().getPath() - : hudiSplit.getLogFiles().getFirst().getPath(); - // Filter out metadata table splits - // TODO: Move this check into a higher calling stack, such that the split is not created at all - if (dataFilePath.contains(new StoragePath( - ((HudiTableHandle) connectorTable).getBasePath()).toUri().getPath() + "/.hoodie/metadata")) { - return new EmptyPageSource(); - } - - // Handle MERGE_ON_READ tables to be read in read_optimized mode - // IMPORTANT: These tables will have a COPY_ON_WRITE table, see: `HudiTableTypeUtils#fromInputFormat` - // TODO: Move this check into a higher calling stack, such that the split is not created at all - if (hudiTableHandle.getTableType().equals(HoodieTableType.COPY_ON_WRITE) && !hudiSplit.getLogFiles().isEmpty()) { - if (hudiBaseFileOpt.isEmpty()) { - // Handle hasLogFiles=true, hasBaseFile = false - // Ignoring log files without base files, no data required to be read - return new EmptyPageSource(); - } - } - - long start = 0; - long length = 10; - if (hudiBaseFileOpt.isPresent()) { - start = hudiBaseFileOpt.get().getStart(); - length = hudiBaseFileOpt.get().getLength(); - } - - // Enable predicate pushdown for splits containing only base files - boolean isBaseFileOnly = hudiSplit.getLogFiles().isEmpty(); - // Convert columns to HiveColumnHandles - List hiveColumnHandles = getHiveColumns(columns, isBaseFileOnly); - - // Get non-synthesized columns (columns that are available in data file) - List dataColumnHandles = hiveColumnHandles.stream() - .filter(columnHandle -> !columnHandle.isPartitionKey() && !columnHandle.isHidden()) - .collect(Collectors.toList()); - // The `columns` list could be empty when count(*) is issued, - // prepending hoodie meta columns for Hudi split with log files - // to allow a non-empty dataPageSource to be returned - List hudiMetaAndDataColumnHandles = prependHudiMetaColumns(dataColumnHandles); - - TrinoFileSystem fileSystem = fileSystemFactory.create(session); - ConnectorPageSource dataPageSource = createPageSource( - session, - isBaseFileOnly ? dataColumnHandles : hudiMetaAndDataColumnHandles, - hudiSplit, - fileSystem.newInputFile(Location.of(hudiBaseFileOpt.get().getPath()), hudiBaseFileOpt.get().getFileSize()), - dataSourceStats, - options - .withIgnoreStatistics(isParquetIgnoreStatistics(session)) - .withMaxReadBlockSize(getParquetMaxReadBlockSize(session)) - .withMaxReadBlockRowCount(getParquetMaxReadBlockRowCount(session)) - .withSmallFileThreshold(getParquetSmallFileThreshold(session)) - .withUseColumnIndex(isParquetUseColumnIndex(session)) - .withBloomFilter(useParquetBloomFilter(session)) - .withVectorizedDecodingEnabled(isParquetVectorizedDecodingEnabled(session)), - timeZone, dynamicFilter, isBaseFileOnly); - - SynthesizedColumnHandler synthesizedColumnHandler = SynthesizedColumnHandler.create(hudiSplit); - - // Avoid avro serialization if split/filegroup only contains base files - if (isBaseFileOnly) { - ValidationUtils.checkArgument(!hiveColumnHandles.isEmpty(), - "Column handles should always be present for providing Hudi data page source on a base file"); - return new HudiBaseFileOnlyPageSource( - dataPageSource, - hiveColumnHandles, - dataColumnHandles, - synthesizedColumnHandler); - } - - // TODO: Move this into HudiTableHandle - HoodieTableMetaClient metaClient = buildTableMetaClient( - fileSystemFactory.create(session), hudiTableHandle.getSchemaTableName().toString(), hudiTableHandle.getBasePath()); - - HudiTrinoReaderContext readerContext = new HudiTrinoReaderContext( - dataPageSource, - dataColumnHandles, - hudiMetaAndDataColumnHandles, - synthesizedColumnHandler); - Schema dataSchema = - Optional.ofNullable(hudiTableHandle.getTableSchema()) - .orElseGet(() -> getLatestTableSchema(metaClient, hudiTableHandle.getTableName())); - - // Construct an Avro schema for log file reader - Schema requestedSchema = constructSchema(dataSchema, hudiMetaAndDataColumnHandles.stream().map(HiveColumnHandle::getName).toList()); - HoodieFileGroupReader fileGroupReader = - new HoodieFileGroupReader<>( - readerContext, - new HudiTrinoStorage(fileSystemFactory.create(session), new TrinoStorageConfiguration()), - hudiTableHandle.getBasePath(), - hudiTableHandle.getLatestCommitTime(), - convertToFileSlice(hudiSplit, hudiTableHandle.getBasePath()), - dataSchema, - requestedSchema, - Option.empty(), - metaClient, - metaClient.getTableConfig().getProps(), - start, - length, - false); - - return new HudiPageSource( - dataPageSource, - fileGroupReader, - readerContext, - hiveColumnHandles, - synthesizedColumnHandler); - } - - static ConnectorPageSource createPageSource( - ConnectorSession session, - List columns, - HudiSplit hudiSplit, - TrinoInputFile inputFile, - FileFormatDataSourceStats dataSourceStats, - ParquetReaderOptions options, - DateTimeZone timeZone, - DynamicFilter dynamicFilter, - boolean enablePredicatePushDown) - { - ParquetDataSource dataSource = null; - boolean useColumnNames = shouldUseParquetColumnNames(session); - HudiBaseFile baseFile = hudiSplit.getBaseFile().get(); - String path = baseFile.getPath(); - long start = baseFile.getStart(); - long length = baseFile.getLength(); - try { - AggregatedMemoryContext memoryContext = newSimpleAggregatedMemoryContext(); - dataSource = createDataSource(inputFile, OptionalLong.of(baseFile.getFileSize()), options, memoryContext, dataSourceStats); - ParquetMetadata parquetMetadata = MetadataReader.readFooter(dataSource, Optional.empty()); - FileMetadata fileMetaData = parquetMetadata.getFileMetaData(); - MessageType fileSchema = fileMetaData.getSchema(); - - // When not using columnNames, physical indexes are used and there could be cases when the physical index in HiveColumnHandle is different from the fileSchema of the - // parquet files. This could happen when schema evolution happened. In such a case, we will need to remap the column indices in the HiveColumnHandles. - if (!useColumnNames) { - // HiveColumnHandle names are in lower case, case-insensitive - columns = remapColumnIndicesToPhysical(fileSchema, columns, false); - } - - Optional message = getParquetMessageType(columns, useColumnNames, fileSchema); - - MessageType requestedSchema = message.orElse(new MessageType(fileSchema.getName(), ImmutableList.of())); - MessageColumnIO messageColumn = getColumnIO(fileSchema, requestedSchema); - - Map, ColumnDescriptor> descriptorsByPath = getDescriptors(fileSchema, requestedSchema); - - TupleDomain parquetTupleDomain = options.isIgnoreStatistics() || !enablePredicatePushDown - ? TupleDomain.all() - : getParquetTupleDomain(descriptorsByPath, getCombinedPredicate(hudiSplit, dynamicFilter), fileSchema, useColumnNames); - - TupleDomainParquetPredicate parquetPredicate = buildPredicate(requestedSchema, parquetTupleDomain, descriptorsByPath, timeZone); - - List rowGroups = getFilteredRowGroups( - start, - length, - dataSource, - parquetMetadata, - ImmutableList.of(parquetTupleDomain), - ImmutableList.of(parquetPredicate), - descriptorsByPath, - timeZone, - DOMAIN_COMPACTION_THRESHOLD, - options); - - Optional readerProjections = projectBaseColumns(columns); - List baseColumns = readerProjections.map(projection -> - projection.get().stream() - .map(HiveColumnHandle.class::cast) - .collect(toUnmodifiableList())) - .orElse(columns); - ParquetDataSourceId dataSourceId = dataSource.getId(); - ParquetDataSource finalDataSource = dataSource; - ParquetReaderProvider parquetReaderProvider = fields -> new ParquetReader( - Optional.ofNullable(fileMetaData.getCreatedBy()), - fields, - rowGroups, - finalDataSource, - timeZone, - memoryContext, - options, - exception -> handleException(dataSourceId, exception), - Optional.of(parquetPredicate), - Optional.empty()); - return createParquetPageSource(baseColumns, fileSchema, messageColumn, useColumnNames, parquetReaderProvider); - } - catch (IOException | RuntimeException e) { - try { - if (dataSource != null) { - dataSource.close(); - } - } - catch (IOException _) { - } - if (e instanceof TrinoException) { - throw (TrinoException) e; - } - if (e instanceof ParquetCorruptionException) { - throw new TrinoException(HUDI_BAD_DATA, e); - } - String message = "Error opening Hudi split %s (offset=%s, length=%s): %s".formatted(path, start, length, e.getMessage()); - throw new TrinoException(HUDI_CANNOT_OPEN_SPLIT, message, e); - } - } - - private static TrinoException handleException(ParquetDataSourceId dataSourceId, Exception exception) - { - if (exception instanceof TrinoException) { - return (TrinoException) exception; - } - if (exception instanceof ParquetCorruptionException) { - return new TrinoException(HUDI_BAD_DATA, exception); - } - return new TrinoException(HUDI_CURSOR_ERROR, format("Failed to read Parquet file: %s", dataSourceId), exception); - } - - /** - * Creates a new list of ColumnHandles where the index associated with each handle corresponds to its physical position within the provided fileSchema (MessageType). - * This is necessary when a downstream component relies on the handle's index for physical data access, and the logical schema order (potentially reflected in the - * original handles) differs from the physical file layout. - * - * @param fileSchema The MessageType representing the physical schema of the Parquet file. - * @param requestedColumns The original list of Trino ColumnHandles as received from the engine. - * @param caseSensitive Whether the lookup between Trino column names (from handles) and Parquet field names (from fileSchema) should be case-sensitive. - * @return A new list of HiveColumnHandle, preserving the original order, but with each handle containing the correct physical index relative to fileSchema. - */ - @VisibleForTesting - public static List remapColumnIndicesToPhysical( - MessageType fileSchema, - List requestedColumns, - boolean caseSensitive) - { - // Create a map from column name to its physical index in the fileSchema. - Map physicalIndexMap = new HashMap<>(); - List fileFields = fileSchema.getFields(); - for (int i = 0; i < fileFields.size(); i++) { - Type field = fileFields.get(i); - String fieldName = field.getName(); - String mapKey = caseSensitive ? fieldName : fieldName.toLowerCase(Locale.getDefault()); - physicalIndexMap.put(mapKey, i); - } - - // Iterate through the columns requested by Trino IN ORDER. - List remappedHandles = new ArrayList<>(requestedColumns.size()); - for (HiveColumnHandle originalHandle : requestedColumns) { - String requestedName = originalHandle.getBaseColumnName(); - - // Determine the key to use for looking up the physical index - String lookupKey = caseSensitive ? requestedName : requestedName.toLowerCase(Locale.getDefault()); - - // Find the physical index from the file schema map constructed from fielSchema - Integer physicalIndex = physicalIndexMap.get(lookupKey); - - HiveColumnHandle remappedHandle = new HiveColumnHandle( - requestedName, - physicalIndex, - originalHandle.getBaseHiveType(), - originalHandle.getType(), - originalHandle.getHiveColumnProjectionInfo(), - originalHandle.getColumnType(), - originalHandle.getComment()); - remappedHandles.add(remappedHandle); - } - - return remappedHandles; - } - - private static TupleDomain getCombinedPredicate(HudiSplit hudiSplit, DynamicFilter dynamicFilter) - { - // Combine static and dynamic predicates - TupleDomain staticPredicate = hudiSplit.getPredicate(); - TupleDomain dynamicPredicate = dynamicFilter.getCurrentPredicate() - .transformKeys(HiveColumnHandle.class::cast); - TupleDomain combinedPredicate = staticPredicate.intersect(dynamicPredicate); - - if (!combinedPredicate.isAll()) { - log.debug("Combined predicate for Parquet read (Split: %s): %s", hudiSplit, combinedPredicate); - } - return combinedPredicate; - } - - private static List getHiveColumns(List columns, - boolean isBaseFileOnly) - { - if (!isBaseFileOnly || !columns.isEmpty()) { - return columns.stream() - .map(HiveColumnHandle.class::cast) - .toList(); - } - - // The `columns` list containing the requested columns to read could be empty - // when count(*) is in the statement; to make sure the page source works properly, - // the synthesized partition column is added in this case. - return Collections.singletonList(partitionColumnHandle()); - } -} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiUtil.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiUtil.java deleted file mode 100644 index 5389e80f57187..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiUtil.java +++ /dev/null @@ -1,400 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi; - -import com.google.common.cache.Cache; -import com.google.common.cache.Weigher; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import io.airlift.log.Logger; -import io.trino.cache.EvictableCacheBuilder; -import io.trino.filesystem.FileIterator; -import io.trino.filesystem.Location; -import io.trino.filesystem.TrinoFileSystem; -import io.trino.metastore.HivePartition; -import io.trino.metastore.HiveType; -import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hive.HivePartitionKey; -import io.trino.plugin.hive.avro.AvroHiveFileUtils; -import io.trino.plugin.hudi.storage.HudiTrinoStorage; -import io.trino.plugin.hudi.storage.TrinoStorageConfiguration; -import io.trino.spi.TrinoException; -import io.trino.spi.connector.ColumnHandle; -import io.trino.spi.connector.SchemaTableName; -import io.trino.spi.predicate.Domain; -import io.trino.spi.predicate.NullableValue; -import io.trino.spi.predicate.TupleDomain; -import io.trino.spi.type.VarcharType; -import org.apache.avro.Schema; -import org.apache.avro.SchemaBuilder; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.HoodieBaseFile; -import org.apache.hudi.common.model.HoodieFileFormat; -import org.apache.hudi.common.model.HoodieFileGroupId; -import org.apache.hudi.common.model.HoodieLogFile; -import org.apache.hudi.common.table.HoodieTableMetaClient; -import org.apache.hudi.common.table.TableSchemaResolver; -import org.apache.hudi.common.table.view.HoodieTableFileSystemView; -import org.apache.hudi.common.util.HoodieTimer; -import org.apache.hudi.exception.TableNotFoundException; -import org.apache.hudi.metadata.HoodieTableMetadata; -import org.apache.hudi.storage.StoragePath; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import static io.airlift.slice.SizeOf.estimatedSizeOf; -import static io.trino.plugin.hive.HiveErrorCode.HIVE_INVALID_METADATA; -import static io.trino.plugin.hive.util.HiveUtil.checkCondition; -import static io.trino.plugin.hive.util.HiveUtil.parsePartitionValue; -import static io.trino.plugin.hive.util.SerdeConstants.LIST_COLUMNS; -import static io.trino.plugin.hive.util.SerdeConstants.LIST_COLUMN_TYPES; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_FILESYSTEM_ERROR; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_META_CLIENT_ERROR; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_SCHEMA_ERROR; -import static io.trino.plugin.hudi.HudiErrorCode.HUDI_UNSUPPORTED_FILE_FORMAT; -import static java.lang.Math.toIntExact; -import static org.apache.hudi.common.model.HoodieRecord.HOODIE_META_COLUMNS; - -public final class HudiUtil -{ - private static final Logger log = Logger.get(HudiUtil.class); - private static final Cache> SCHEMA_FIELD_CACHE = - EvictableCacheBuilder.newBuilder() - .maximumWeight(10L * 1000L * 1024L) // 10MB - .weigher((Weigher>) (schema, fieldMap) -> { - // approximate size estimation of schema size - long schemaSize = estimatedSizeOf(schema.toString()); - - long fieldsSize = fieldMap.entrySet().stream() - .mapToLong(e -> estimatedSizeOf(e.getKey()) + estimatedSizeOf(e.getValue().name())) - .sum(); - - return toIntExact(schemaSize + fieldsSize); - }) - .expireAfterWrite(5, TimeUnit.MINUTES) - .shareNothingWhenDisabled() - .build(); - - private HudiUtil() {} - - public static HoodieFileFormat getHudiFileFormat(String path) - { - String extension = getFileExtension(path); - if (extension.equals(HoodieFileFormat.PARQUET.getFileExtension())) { - return HoodieFileFormat.PARQUET; - } - if (extension.equals(HoodieFileFormat.HOODIE_LOG.getFileExtension())) { - return HoodieFileFormat.HOODIE_LOG; - } - if (extension.equals(HoodieFileFormat.ORC.getFileExtension())) { - return HoodieFileFormat.ORC; - } - if (extension.equals(HoodieFileFormat.HFILE.getFileExtension())) { - return HoodieFileFormat.HFILE; - } - throw new TrinoException(HUDI_UNSUPPORTED_FILE_FORMAT, "Hoodie InputFormat not implemented for base file of type " + extension); - } - - private static String getFileExtension(String fullName) - { - String fileName = Location.of(fullName).fileName(); - int dotIndex = fileName.lastIndexOf('.'); - return dotIndex == -1 ? "" : fileName.substring(dotIndex); - } - - public static boolean hudiMetadataExists(TrinoFileSystem trinoFileSystem, Location baseLocation) - { - try { - Location metaLocation = baseLocation.appendPath(HoodieTableMetaClient.METAFOLDER_NAME); - FileIterator iterator = trinoFileSystem.listFiles(metaLocation); - // If there is at least one file in the .hoodie directory, it's a valid Hudi table - return iterator.hasNext(); - } - catch (IOException e) { - throw new TrinoException(HUDI_FILESYSTEM_ERROR, "Failed to check for Hudi table at location: " + baseLocation, e); - } - } - - public static boolean partitionMatchesPredicates( - SchemaTableName tableName, - String hivePartitionName, - List partitionColumnHandles, - List partitionValues, - TupleDomain constraintSummary) - { - HivePartition partition = parsePartition( - tableName, hivePartitionName, partitionColumnHandles, partitionValues); - - return partitionMatches(partitionColumnHandles, constraintSummary, partition); - } - - /** - * Copied from {@link io.trino.plugin.hive.HivePartitionManager#parsePartition} - * to keep partition parsing logic self-contained within {@code trino-hudi}. - */ - private static HivePartition parsePartition( - SchemaTableName tableName, - String partitionName, - List partitionColumns, - List partitionValues) - { - ImmutableMap.Builder builder = ImmutableMap.builderWithExpectedSize(partitionColumns.size()); - for (int i = 0; i < partitionColumns.size(); i++) { - HiveColumnHandle column = partitionColumns.get(i); - NullableValue parsedValue = parsePartitionValue(partitionName, partitionValues.get(i), column.getType()); - builder.put(column, parsedValue); - } - Map values = builder.buildOrThrow(); - return new HivePartition(tableName, partitionName, values); - } - - public static boolean partitionMatches(List partitionColumns, TupleDomain constraintSummary, HivePartition partition) - { - if (constraintSummary.isNone()) { - return false; - } - Map domains = constraintSummary.getDomains().orElseGet(ImmutableMap::of); - for (HiveColumnHandle column : partitionColumns) { - NullableValue value = partition.getKeys().get(column); - Domain allowedDomain = domains.get(column); - if (allowedDomain != null && !allowedDomain.includesNullableValue(value.getValue())) { - return false; - } - } - return true; - } - - public static List buildPartitionKeys(List keys, List values) - { - checkCondition(keys.size() == values.size(), HIVE_INVALID_METADATA, - "Expected %s partition key values, but got %s. Keys: %s, Values: %s.", - keys.size(), values.size(), keys, values); - ImmutableList.Builder partitionKeys = ImmutableList.builder(); - for (int i = 0; i < keys.size(); i++) { - String name = keys.get(i).getName(); - String value = values.get(i); - partitionKeys.add(new HivePartitionKey(name, value)); - } - return partitionKeys.build(); - } - - public static HoodieTableMetaClient buildTableMetaClient( - TrinoFileSystem fileSystem, - String tableName, - String basePath) - { - try { - return HoodieTableMetaClient.builder() - .setStorage(new HudiTrinoStorage(fileSystem, new TrinoStorageConfiguration())) - .setBasePath(basePath) - .build(); - } - catch (TableNotFoundException e) { - throw new TrinoException(HUDI_BAD_DATA, - "Location of table %s does not contain Hudi table metadata: %s".formatted(tableName, basePath)); - } - catch (Throwable e) { - throw new TrinoException(HUDI_META_CLIENT_ERROR, - "Unable to load Hudi meta client for table %s (%s)".formatted(tableName, basePath)); - } - } - - public static Schema constructSchema(List columnNames, List columnTypes) - { - // Convert lists into the format expected by the utility class - String columnNamesString = String.join(",", columnNames); - String columnTypesString = columnTypes.stream() - .map(HiveType::getHiveTypeName) - .map(Object::toString) - .collect(Collectors.joining(":")); - - // Create the properties map - Map properties = new HashMap<>(); - properties.put(LIST_COLUMNS, columnNamesString); - properties.put(LIST_COLUMN_TYPES, columnTypesString); - - // Call the public static method to build the schema - try { - // Pass null for the file system as we are not reading from a URL - return AvroHiveFileUtils.determineSchemaOrThrowException(null, properties); - } - catch (IOException e) { - // The IOException is declared on the method, but this path shouldn't throw it - throw new UncheckedIOException("Failed to construct Avro schema", e); - } - } - - public static Schema constructSchema(Schema dataSchema, List columnNames) - { - SchemaBuilder.RecordBuilder schemaBuilder = SchemaBuilder.record("baseRecord"); - SchemaBuilder.FieldAssembler fieldBuilder = schemaBuilder.fields(); - for (String columnName : columnNames) { - Schema.Field field = getFieldFromSchema(columnName, dataSchema); - Schema originalFieldSchema = field.schema(); - - Schema typeForNewField; - - // Check if the original field schema is already nullable (i.e., a UNION containing NULL) - if (originalFieldSchema.isNullable()) { - typeForNewField = originalFieldSchema; - } - else { - typeForNewField = Schema.createUnion(Schema.create(Schema.Type.NULL), originalFieldSchema); - } - - fieldBuilder = fieldBuilder - .name(field.name()) - .type(typeForNewField) - .withDefault(null); - } - return fieldBuilder.endRecord(); - } - - private static Map buildFieldLookup(Schema schema) - { - return schema.getFields().stream() - .collect(Collectors.toMap( - f -> f.name().toLowerCase(Locale.ROOT), - f -> f)); - } - - /** - * Retrieves a field from the given Avro schema by column name. - *

    - * The lookup proceeds in two steps: - *

      - *
    • First, attempts an exact match on the column name.
    • - *
    • If not found, falls back to a case-insensitive match using a cached lookup table
    • - *
    - *

    - * - * @param columnName Column name to search for. - * @param schema Avro {@link Schema} in which to search. - * @return The matching {@link Schema.Field}, if found. - * @throws TrinoException if no field matches the given column name. - */ - public static Schema.Field getFieldFromSchema(String columnName, Schema schema) - { - Schema.Field field = schema.getField(columnName); - if (field != null) { - return field; - } - - try { - field = SCHEMA_FIELD_CACHE - .get(schema, () -> buildFieldLookup(schema)).get(columnName.toLowerCase(Locale.ROOT)); - if (field != null) { - return field; - } - } - catch (ExecutionException e) { - throw new TrinoException(HUDI_SCHEMA_ERROR, - "Failed to build field lookup for schema", e); - } - - throw new TrinoException(HUDI_SCHEMA_ERROR, - "Failed to get column " + columnName + " from table schema"); - } - - public static List prependHudiMetaColumns(List dataColumns) - { - //For efficient lookup - Set dataColumnNames = dataColumns.stream() - .map(HiveColumnHandle::getName) - .collect(Collectors.toSet()); - - // If all Hudi meta columns are already present, return the original list - if (dataColumnNames.containsAll(HOODIE_META_COLUMNS)) { - return dataColumns; - } - - // Identify only the meta columns that are missing from dataColumns to avoid duplicates - List missingMetaColumns = HOODIE_META_COLUMNS.stream() - .filter(metaColumn -> !dataColumnNames.contains(metaColumn)) - .toList(); - - List columns = new ArrayList<>(); - - // Create and prepend the new HiveColumnHandles for the missing meta columns - columns.addAll(IntStream.range(0, missingMetaColumns.size()) - .boxed() - .map(i -> new HiveColumnHandle( - missingMetaColumns.get(i), - i, - HiveType.HIVE_STRING, - VarcharType.VARCHAR, - Optional.empty(), - HiveColumnHandle.ColumnType.REGULAR, - Optional.empty())) - .toList()); - - // Add all the original data columns after the new meta columns - columns.addAll(dataColumns); - - return columns; - } - - public static FileSlice convertToFileSlice(HudiSplit split, String basePath) - { - String dataFilePath = split.getBaseFile().isPresent() - ? split.getBaseFile().get().getPath() - : split.getLogFiles().getFirst().getPath(); - String fileId = FSUtils.getFileIdFromFileName(new StoragePath(dataFilePath).getName()); - HoodieBaseFile baseFile = split.getBaseFile().isPresent() - ? new HoodieBaseFile(dataFilePath, fileId, split.getCommitTime(), null) - : null; - - return new FileSlice( - new HoodieFileGroupId(FSUtils.getRelativePartitionPath(new StoragePath(basePath), new StoragePath(dataFilePath)), fileId), - split.getCommitTime(), - baseFile, - split.getLogFiles().stream().map(lf -> new HoodieLogFile(lf.getPath())).toList()); - } - - public static HoodieTableFileSystemView getFileSystemView( - HoodieTableMetadata tableMetadata, - HoodieTableMetaClient metaClient) - { - return new HoodieTableFileSystemView( - tableMetadata, metaClient, metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants()); - } - - public static Schema getLatestTableSchema(HoodieTableMetaClient metaClient, String tableName) - { - try { - HoodieTimer timer = HoodieTimer.start(); - Schema schema = new TableSchemaResolver(metaClient).getTableAvroSchema(); - log.info("Fetched table schema for table %s in %s ms", tableName, timer.endTimer()); - return schema; - } - catch (Exception e) { - // failed to read schema - throw new TrinoException(HUDI_FILESYSTEM_ERROR, e); - } - } -} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java deleted file mode 100644 index 36c5342920f4e..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java +++ /dev/null @@ -1,227 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi.reader; - -import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hudi.util.HudiAvroSerializer; -import io.trino.plugin.hudi.util.SynthesizedColumnHandler; -import io.trino.spi.Page; -import io.trino.spi.connector.ConnectorPageSource; -import org.apache.avro.Schema; -import org.apache.avro.generic.GenericData; -import org.apache.avro.generic.GenericData.Record; -import org.apache.avro.generic.GenericRecord; -import org.apache.avro.generic.IndexedRecord; -import org.apache.hudi.common.config.RecordMergeMode; -import org.apache.hudi.common.engine.HoodieReaderContext; -import org.apache.hudi.common.model.HoodieAvroIndexedRecord; -import org.apache.hudi.common.model.HoodieAvroRecordMerger; -import org.apache.hudi.common.model.HoodieEmptyRecord; -import org.apache.hudi.common.model.HoodieKey; -import org.apache.hudi.common.model.HoodieRecord; -import org.apache.hudi.common.model.HoodieRecordMerger; -import org.apache.hudi.common.table.read.BufferedRecord; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.collection.ClosableIterator; -import org.apache.hudi.storage.HoodieStorage; -import org.apache.hudi.storage.StoragePath; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.UnaryOperator; - -public class HudiTrinoReaderContext - extends HoodieReaderContext -{ - ConnectorPageSource pageSource; - private final HudiAvroSerializer avroSerializer; - Map colToPosMap; - List dataHandles; - List columnHandles; - - public HudiTrinoReaderContext( - ConnectorPageSource pageSource, - List dataHandles, - List columnHandles, - SynthesizedColumnHandler synthesizedColumnHandler) - { - this.pageSource = pageSource; - this.avroSerializer = new HudiAvroSerializer(columnHandles, synthesizedColumnHandler); - this.dataHandles = dataHandles; - this.columnHandles = columnHandles; - this.colToPosMap = new HashMap<>(); - for (int i = 0; i < columnHandles.size(); i++) { - HiveColumnHandle handle = columnHandles.get(i); - colToPosMap.put(handle.getBaseColumnName(), i); - } - } - - @Override - public ClosableIterator getFileRecordIterator( - StoragePath storagePath, - long start, - long length, - Schema dataSchema, - Schema requiredSchema, - HoodieStorage storage) - { - return new ClosableIterator<>() - { - private Page currentPage; - private int currentPosition; - - @Override - public void close() - { - try { - pageSource.close(); - } - catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean hasNext() - { - // If all records in the current page are consume, try to get next page - if (currentPage == null || currentPosition >= currentPage.getPositionCount()) { - if (pageSource.isFinished()) { - return false; - } - - // Get next page and reset currentPosition - currentPage = pageSource.getNextPage(); - currentPosition = 0; - - // If no more pages are available - return currentPage != null; - } - - return true; - } - - @Override - public IndexedRecord next() - { - if (!hasNext()) { - // TODO: This can probably be removed or ignored, added this as a sanity check - throw new RuntimeException("No more records in the iterator"); - } - - IndexedRecord record = avroSerializer.serialize(currentPage, currentPosition); - currentPosition++; - return record; - } - }; - } - - @Override - public IndexedRecord convertAvroRecord(IndexedRecord record) - { - return record; - } - - @Override - public GenericRecord convertToAvroRecord(IndexedRecord record, Schema schema) - { - GenericRecord ret = new GenericData.Record(schema); - for (Schema.Field field : schema.getFields()) { - ret.put(field.name(), record.get(field.pos())); - } - return ret; - } - - @Override - public Option getRecordMerger(RecordMergeMode mergeMode, String mergeStrategyId, String mergeImplClasses) - { - return Option.of(HoodieAvroRecordMerger.INSTANCE); - } - - @Override - public Object getValue(IndexedRecord record, Schema schema, String fieldName) - { - if (colToPosMap.containsKey(fieldName)) { - return record.get(colToPosMap.get(fieldName)); - } - else { - // record doesn't have the queried field, return null - return null; - } - } - - @Override - public IndexedRecord seal(IndexedRecord record) - { - // TODO: this can rely on colToPos map directly instead of schema - Schema schema = record.getSchema(); - IndexedRecord newRecord = new Record(schema); - List fields = schema.getFields(); - for (Schema.Field field : fields) { - int pos = schema.getField(field.name()).pos(); - newRecord.put(pos, record.get(pos)); - } - return newRecord; - } - - @Override - public IndexedRecord toBinaryRow(Schema schema, IndexedRecord record) - { - return record; - } - - @Override - public ClosableIterator mergeBootstrapReaders( - ClosableIterator closableIterator, Schema schema, - ClosableIterator closableIterator1, Schema schema1) - { - return null; - } - - @Override - public UnaryOperator projectRecord( - Schema from, - Schema to, - Map renamedColumns) - { - List toFields = to.getFields(); - int[] projection = new int[toFields.size()]; - for (int i = 0; i < projection.length; i++) { - projection[i] = from.getField(toFields.get(i).name()).pos(); - } - - return fromRecord -> { - IndexedRecord toRecord = new Record(to); - for (int i = 0; i < projection.length; i++) { - toRecord.put(i, fromRecord.get(projection[i])); - } - return toRecord; - }; - } - - @Override - public HoodieRecord constructHoodieRecord( - BufferedRecord bufferedRecord) - { - if (bufferedRecord.isDelete()) { - return new HoodieEmptyRecord<>( - new HoodieKey(bufferedRecord.getRecordKey(), null), - HoodieRecord.HoodieRecordType.AVRO); - } - - return new HoodieAvroIndexedRecord(bufferedRecord.getRecord()); - } -} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoRecord.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoRecord.java deleted file mode 100644 index 7fa0f71399edc..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoRecord.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi.reader; - -import com.esotericsoftware.kryo.Kryo; -import com.esotericsoftware.kryo.io.Input; -import com.esotericsoftware.kryo.io.Output; -import org.apache.avro.Schema; -import org.apache.avro.generic.IndexedRecord; -import org.apache.hudi.common.model.HoodieAvroIndexedRecord; -import org.apache.hudi.common.model.HoodieKey; -import org.apache.hudi.common.model.HoodieOperation; -import org.apache.hudi.common.model.HoodieRecord; -import org.apache.hudi.common.model.MetadataValues; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.collection.Pair; -import org.apache.hudi.keygen.BaseKeyGenerator; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Map; -import java.util.Properties; - -public class HudiTrinoRecord - extends HoodieRecord -{ - public HudiTrinoRecord() - { - } - - @Override - public HoodieRecord newInstance() - { - return null; - } - - @Override - public HoodieRecord newInstance(HoodieKey hoodieKey, HoodieOperation hoodieOperation) - { - return null; - } - - @Override - public HoodieRecord newInstance(HoodieKey hoodieKey) - { - return null; - } - - @Override - public Comparable doGetOrderingValue(Schema schema, Properties properties) - { - return null; - } - - @Override - public HoodieRecordType getRecordType() - { - return null; - } - - @Override - public String getRecordKey(Schema schema, Option option) - { - return ""; - } - - @Override - public String getRecordKey(Schema schema, String s) - { - return ""; - } - - @Override - protected void writeRecordPayload(IndexedRecord page, Kryo kryo, Output output) - { - } - - @Override - protected IndexedRecord readRecordPayload(Kryo kryo, Input input) - { - return null; - } - - @Override - public Object[] getColumnValues(Schema schema, String[] strings, boolean b) - { - return new Object[0]; - } - - @Override - public HoodieRecord joinWith(HoodieRecord hoodieRecord, Schema schema) - { - return null; - } - - @Override - public HoodieRecord prependMetaFields(Schema schema, Schema schema1, - MetadataValues metadataValues, Properties properties) - { - return null; - } - - @Override - public HoodieRecord rewriteRecordWithNewSchema(Schema schema, Properties properties, - Schema schema1, Map map) - { - return null; - } - - @Override - public boolean isDelete(Schema schema, Properties properties) - throws IOException - { - return false; - } - - @Override - public boolean shouldIgnore(Schema schema, Properties properties) - throws IOException - { - return false; - } - - @Override - public HoodieRecord copy() - { - return null; - } - - @Override - public Option> getMetadata() - { - return null; - } - - @Override - public HoodieRecord wrapIntoHoodieRecordPayloadWithParams(Schema schema, Properties properties, - Option> option, Boolean aBoolean, Option option1, - Boolean aBoolean1, Option option2) - throws IOException - { - return null; - } - - @Override - public HoodieRecord wrapIntoHoodieRecordPayloadWithKeyGen(Schema schema, Properties properties, - Option option) - { - return null; - } - - @Override - public HoodieRecord truncateRecordKey(Schema schema, Properties properties, String s) - throws IOException - { - return null; - } - - @Override - public Option toIndexedRecord(Schema schema, Properties properties) - throws IOException - { - return null; - } - - @Override - public ByteArrayOutputStream getAvroBytes(Schema schema, Properties properties) - throws IOException - { - return null; - } -} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnHandler.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnHandler.java deleted file mode 100644 index 5d55adb5577c5..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnHandler.java +++ /dev/null @@ -1,320 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi.util; - -import com.google.common.collect.ImmutableMap; -import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hive.HivePartitionKey; -import io.trino.plugin.hudi.HudiSplit; -import io.trino.plugin.hudi.file.HudiFile; -import io.trino.spi.TrinoException; -import io.trino.spi.block.Block; -import io.trino.spi.block.BlockBuilder; -import io.trino.spi.block.RunLengthEncodedBlock; -import io.trino.spi.type.BigintType; -import io.trino.spi.type.BooleanType; -import io.trino.spi.type.DateType; -import io.trino.spi.type.DecimalType; -import io.trino.spi.type.IntegerType; -import io.trino.spi.type.SqlDecimal; -import io.trino.spi.type.TimestampWithTimeZoneType; -import io.trino.spi.type.Type; -import io.trino.spi.type.VarcharType; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static io.airlift.slice.Slices.utf8Slice; -import static io.trino.metastore.Partitions.makePartName; -import static io.trino.plugin.hive.HiveColumnHandle.FILE_MODIFIED_TIME_COLUMN_NAME; -import static io.trino.plugin.hive.HiveColumnHandle.FILE_SIZE_COLUMN_NAME; -import static io.trino.plugin.hive.HiveColumnHandle.PARTITION_COLUMN_NAME; -import static io.trino.plugin.hive.HiveColumnHandle.PATH_COLUMN_NAME; -import static io.trino.spi.StandardErrorCode.GENERIC_INTERNAL_ERROR; -import static io.trino.spi.type.DateTimeEncoding.packDateTimeWithZone; -import static io.trino.spi.type.Decimals.writeBigDecimal; -import static io.trino.spi.type.Decimals.writeShortDecimal; -import static io.trino.spi.type.TimeZoneKey.UTC_KEY; -import static java.lang.Math.toIntExact; -import static java.lang.String.format; - -/** - * Handles synthesized (virtual) columns in Hudi tables, such as partition columns and metadata (not hudi metadata) - * columns. - */ -public class SynthesizedColumnHandler -{ - private final Map strategies; - private final SplitMetadata splitMetadata; - - public static SynthesizedColumnHandler create(HudiSplit hudiSplit) - { - return new SynthesizedColumnHandler(hudiSplit); - } - - /** - * Constructs a SynthesizedColumnHandler with the given partition keys. - */ - public SynthesizedColumnHandler(HudiSplit hudiSplit) - { - this.splitMetadata = SplitMetadata.of(hudiSplit); - ImmutableMap.Builder builder = ImmutableMap.builder(); - initSynthesizedColStrategies(builder); - initPartitionKeyStrategies(builder, hudiSplit); - strategies = builder.buildOrThrow(); - } - - /** - * Initializes strategies for synthesized columns. - */ - private void initSynthesizedColStrategies(ImmutableMap.Builder builder) - { - builder.put(PARTITION_COLUMN_NAME, (blockBuilder, _) -> - VarcharType.VARCHAR.writeSlice(blockBuilder, - utf8Slice(toPartitionName(splitMetadata.getPartitionKeyVals())))); - - builder.put(PATH_COLUMN_NAME, (blockBuilder, _) -> - VarcharType.VARCHAR.writeSlice(blockBuilder, utf8Slice(splitMetadata.getFilePath()))); - - builder.put(FILE_SIZE_COLUMN_NAME, (blockBuilder, _) -> - BigintType.BIGINT.writeLong(blockBuilder, splitMetadata.getFileSize())); - - builder.put(FILE_MODIFIED_TIME_COLUMN_NAME, (blockBuilder, _) -> { - long packedTimestamp = packDateTimeWithZone( - splitMetadata.getFileModificationTime(), UTC_KEY); - TimestampWithTimeZoneType.TIMESTAMP_TZ_MILLIS.writeLong(blockBuilder, packedTimestamp); - }); - } - - /** - * Initializes strategies for partition columns. - */ - private void initPartitionKeyStrategies(ImmutableMap.Builder builder, - HudiSplit hudiSplit) - { - // Type is ignored here as input partitionKey.value() is always passed as a String type - for (HivePartitionKey partitionKey : hudiSplit.getPartitionKeys()) { - builder.put(partitionKey.name(), (blockBuilder, targetType) -> - appendPartitionKey(targetType, partitionKey.value(), blockBuilder)); - } - } - - /** - * Checks if a column is a synthesized column. - * - * @param columnName The column name. - * @return True if the column is synthesized, false otherwise. - */ - public boolean isSynthesizedColumn(String columnName) - { - return strategies.containsKey(columnName); - } - - /** - * Checks if a Hive column handle represents a synthesized column. - * - * @param columnHandle The Hive column handle. - * @return True if the column is synthesized, false otherwise. - */ - public boolean isSynthesizedColumn(HiveColumnHandle columnHandle) - { - return isSynthesizedColumn(columnHandle.getName()); - } - - /** - * Retrieves the strategy for a given synthesized column. - * - * @param columnHandle The Hive column handle. - * @return The corresponding column strategy, or null if not found. - */ - public SynthesizedColumnStrategy getColumnStrategy(HiveColumnHandle columnHandle) - { - return strategies.get(columnHandle.getName()); - } - - /** - * Retrieves the count of synthesized column strategies currently present. - * - * @return The number of synthesized column strategies. - */ - public int getSynthesizedColumnCount() - { - return strategies.size(); - } - - /** - * Converts partition key-value pairs into a partition name string. - * - * @param partitionKeyVals Map of partition key-value pairs. - * @return Partition name string. - */ - private static String toPartitionName(Map partitionKeyVals) - { - return makePartName(List.copyOf(partitionKeyVals.keySet()), List.copyOf(partitionKeyVals.values())); - } - - /** - * Creates a {@link Block} for the given synthesized column, typically a {@link RunLengthEncodedBlock} as the synthesized value is constant for all positions within a split. - * - * @param columnHandle The handle of the synthesized column to create a block for. - * @param positionCount The number of positions (rows) the resulting block should represent. - * @return A {@link Block} containing the synthesized values. - */ - public Block createRleSynthesizedBlock(HiveColumnHandle columnHandle, int positionCount) - { - Type columnType = columnHandle.getType(); - - if (positionCount == 0) { - return columnType.createBlockBuilder(null, 0).build(); - } - - SynthesizedColumnStrategy strategy = getColumnStrategy(columnHandle); - - // Because this builder will only hold the single constant value - int expectedEntriesForValueBlock = 1; - BlockBuilder valueBuilder = columnType.createBlockBuilder(null, expectedEntriesForValueBlock); - - if (strategy == null) { - valueBuilder.appendNull(); - } - else { - // Apply the strategy to write the single value into the builder - strategy.appendToBlock(valueBuilder, columnType); - } - Block valueBlock = valueBuilder.build(); - - return RunLengthEncodedBlock.create(valueBlock, positionCount); - } - - /** - * Represents metadata about split being processed. - * Splits are assumed to be in the same partition. - */ - public static class SplitMetadata - { - private final Map partitionKeyVals; - private final String filePath; - private final long fileSize; - private final long modifiedTime; - - /** - * Creates SplitMetadata from a Hudi split and partition key list. - */ - public static SplitMetadata of(HudiSplit hudiSplit) - { - return new SplitMetadata(hudiSplit); - } - - public SplitMetadata(HudiSplit hudiSplit) - { - this.partitionKeyVals = hudiSplit.getPartitionKeys().stream() - .collect(Collectors.toMap(HivePartitionKey::name, HivePartitionKey::value)); - // Parquet files will be prioritised over log files - HudiFile hudiFile = hudiSplit.getBaseFile().isPresent() - ? hudiSplit.getBaseFile().get() - : hudiSplit.getLogFiles().getFirst(); - this.filePath = hudiFile.getPath(); - this.fileSize = hudiFile.getFileSize(); - this.modifiedTime = hudiFile.getModificationTime(); - } - - public Map getPartitionKeyVals() - { - return partitionKeyVals; - } - - public String getFilePath() - { - return filePath; - } - - public long getFileSize() - { - return fileSize; - } - - public long getFileModificationTime() - { - return modifiedTime; - } - } - - /** - * Helper function to prefill BlockBuilders with values from PartitionKeys which are in the String type. - * This function handles the casting of String type the actual column type. - */ - private static void appendPartitionKey(Type targetType, String value, BlockBuilder blockBuilder) - { - if (value == null) { - blockBuilder.appendNull(); - return; - } - - if (targetType instanceof VarcharType varcharType) { - varcharType.writeSlice(blockBuilder, utf8Slice(value)); - } - else if (targetType instanceof IntegerType integerType) { - integerType.writeInt(blockBuilder, Integer.parseInt(value)); - } - else if (targetType instanceof BigintType bigintType) { - bigintType.writeLong(blockBuilder, Long.parseLong(value)); - } - else if (targetType instanceof BooleanType booleanType) { - booleanType.writeBoolean(blockBuilder, Boolean.parseBoolean(value)); - } - else if (targetType instanceof DecimalType decimalType) { - SqlDecimal sqlDecimal = SqlDecimal.decimal(value, decimalType); - BigDecimal bigDecimal = sqlDecimal.toBigDecimal(); - - if (decimalType.isShort()) { - // For short decimals, get the unscaled long value - // SqlDecimal.toBigDecimal() is used for consistency with the original SqlDecimal path - // The unscaled value of a Trino short decimal (precision <= 18) fits in a long - writeShortDecimal(blockBuilder, bigDecimal.unscaledValue().longValue()); - } - else { - // For long decimals, use the BigDecimal representation obtained from SqlDecimal. - writeBigDecimal(decimalType, blockBuilder, bigDecimal); - } - } - else if (targetType instanceof DateType dateType) { - try { - // Parse the date string using "YYYY-MM-DD" format - LocalDate localDate = LocalDate.parse(value); - // Convert LocalDate to days since epoch where LocalDate#toEpochDay() returns a long - int daysSinceEpoch = toIntExact(localDate.toEpochDay()); - dateType.writeInt(blockBuilder, daysSinceEpoch); - } - catch (DateTimeParseException e) { - // Handle parsing error - throw new TrinoException(GENERIC_INTERNAL_ERROR, - format("Invalid date string format for DATE type: '%s'. Expected format like YYYY-MM-DD. Details: %s", - value, e.getMessage()), e); - } - catch (ArithmeticException e) { - // Handle potential overflow if toEpochDay() result is outside int range - throw new TrinoException(GENERIC_INTERNAL_ERROR, - format("Date string '%s' results in a day count out of integer range for DATE type. Details: %s", - value, e.getMessage()), e); - } - } - else { - throw new TrinoException(GENERIC_INTERNAL_ERROR, format("Unknown target type '%s' for column '%s'", targetType, value)); - } - } -} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnStrategy.java b/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnStrategy.java deleted file mode 100644 index 916e6cb83e4fd..0000000000000 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/SynthesizedColumnStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi.util; - -import io.trino.spi.block.BlockBuilder; -import io.trino.spi.type.Type; - -/** - * Strategy interface for handling different types of synthesized columns - */ -public interface SynthesizedColumnStrategy -{ - void appendToBlock(BlockBuilder blockBuilder, Type type); -} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java b/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java deleted file mode 100644 index 7e938ba3aed1e..0000000000000 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi; - -import io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer; -import io.trino.testing.QueryRunner; - -public class TestHudiConnectorParquetColumnNamesTest - extends TestHudiSmokeTest -{ - @Override - protected QueryRunner createQueryRunner() - throws Exception - { - return HudiQueryRunner.builder() - .addConnectorProperty("hudi.parquet.use-column-names", "false") - .setDataLoader(new ResourceHudiTablesInitializer()) - .build(); - } -} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java b/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java deleted file mode 100644 index 8966553939742..0000000000000 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java +++ /dev/null @@ -1,242 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi; - -import io.trino.metastore.HiveType; -import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.spi.type.BigintType; -import io.trino.spi.type.Type; -import org.apache.parquet.schema.LogicalTypeAnnotation; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Types; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Optional; - -import static io.trino.plugin.hudi.HudiPageSourceProvider.remapColumnIndicesToPhysical; -import static io.trino.spi.type.DoubleType.DOUBLE; -import static io.trino.spi.type.IntegerType.INTEGER; -import static io.trino.spi.type.VarcharType.VARCHAR; -import static org.apache.parquet.schema.Type.Repetition.OPTIONAL; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class TestHudiPageSourceProviderTest -{ - @Test - public void testRemapSimpleMatchCaseInsensitive() - { - // Physical Schema: [col_a (int), col_b (string)] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); - - // Requested Columns (same order, different case) - List requestedColumns = List.of( - createDummyHandle("COL_A", 0, HiveType.HIVE_INT, INTEGER), - createDummyHandle("COL_B", 1, HiveType.HIVE_STRING, VARCHAR)); - - // Perform remapping (case-insensitive) - List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); - - assertThat(remapped).hasSize(2); - // First requested column "COL_A" should map to physical index 0 - assertHandle(remapped.get(0), "COL_A", 0, HiveType.HIVE_INT, INTEGER); - // Second requested column "COL_B" should map to physical index 1 - assertHandle(remapped.get(1), "COL_B", 1, HiveType.HIVE_STRING, VARCHAR); - } - - @Test - public void testRemapSimpleMatchCaseSensitive() - { - // Physical Schema: [col_a (int), Col_B (string)] - Note the case difference - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("Col_B")); - - // Requested Columns (matching case) - List requestedColumns = List.of( - createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER), - createDummyHandle("Col_B", 1, HiveType.HIVE_STRING, VARCHAR)); - - // Perform remapping (case-sensitive) - List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, true); - - assertThat(remapped).hasSize(2); - assertHandle(remapped.get(0), "col_a", 0, HiveType.HIVE_INT, INTEGER); - assertHandle(remapped.get(1), "Col_B", 1, HiveType.HIVE_STRING, VARCHAR); - } - - @Test - public void testRemapCaseSensitiveMismatch() - { - // Physical Schema: [col_a (int), col_b (string)] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); - - // Requested Columns (different case) - List requestedColumns = List.of( - createDummyHandle("COL_A", 0, HiveType.HIVE_INT, INTEGER), // This will mismatch - createDummyHandle("col_b", 1, HiveType.HIVE_STRING, VARCHAR)); - - // Perform remapping (case-sensitive) - Expect NPE because "COL_A" won't be found - assertThatThrownBy(() -> remapColumnIndicesToPhysical(fileSchema, requestedColumns, true)) - .isInstanceOf(NullPointerException.class); // Check the exception type - } - - @Test - public void testRemapDifferentOrder() - { - // Physical Schema: [id (int), name (string), timestamp (long)] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("id"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("name"), - Types.primitive(PrimitiveType.PrimitiveTypeName.INT64, OPTIONAL).named("timestamp")); - - // Requested Columns (different order) - List requestedColumns = List.of( - // Original index irrelevant - createDummyHandle("name", 99, HiveType.HIVE_STRING, VARCHAR), - createDummyHandle("timestamp", 5, HiveType.HIVE_LONG, BigintType.BIGINT), - createDummyHandle("id", 0, HiveType.HIVE_INT, INTEGER)); - - // Perform remapping (case-insensitive) - List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); - - assertThat(remapped).hasSize(3); - // First requested "name" -> physical index 1 - assertHandle(remapped.get(0), "name", 1, HiveType.HIVE_STRING, VARCHAR); - // Second requested "timestamp" -> physical index 2 - assertHandle(remapped.get(1), "timestamp", 2, HiveType.HIVE_LONG, BigintType.BIGINT); - // Third requested "id" -> physical index 0 - assertHandle(remapped.get(2), "id", 0, HiveType.HIVE_INT, INTEGER); - } - - @Test - public void testRemapSubset() - { - // Physical Schema: [col_a, col_b, col_c, col_d] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BOOLEAN, OPTIONAL).named("col_c"), - Types.primitive(PrimitiveType.PrimitiveTypeName.DOUBLE, OPTIONAL).named("col_d")); - - // Requested Columns (subset and different order) - List requestedColumns = List.of( - createDummyHandle("col_d", 1, HiveType.HIVE_DOUBLE, DOUBLE), - createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER)); - - // Perform remapping (case-insensitive) - List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); - - assertThat(remapped).hasSize(2); - // First requested "col_d" -> physical index 3 - assertHandle(remapped.get(0), "col_d", 3, HiveType.HIVE_DOUBLE, DOUBLE); - // Second requested "col_a" -> physical index 0 - assertHandle(remapped.get(1), "col_a", 0, HiveType.HIVE_INT, INTEGER); - } - - @Test - public void testRemapEmptyRequested() - { - // Physical Schema: [col_a, col_b] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), - Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); - - // Requested Columns (empty list) - List requestedColumns = List.of(); - - // Perform remapping - List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); - - assertThat(remapped).isEmpty(); - } - - @Test - public void testRemapColumnNotFound() - { - // Physical Schema: [col_a] - MessageType fileSchema = new MessageType("file_schema", - Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a")); - - // Requested Columns (includes a non-existent column) - List requestedColumns = List.of( - createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER), - // Not in schema - createDummyHandle("col_x", 1, HiveType.HIVE_STRING, VARCHAR)); - - // Perform remapping (case-insensitive) - Expect NPE because "col_x" won't be found - assertThatThrownBy(() -> remapColumnIndicesToPhysical(fileSchema, requestedColumns, false)) - .isInstanceOf(NullPointerException.class); - } - - /** - * Creates a basic HiveColumnHandle for testing. - * Assumes REGULAR column type and no projection info or comments. - * The initial hiveColumnIndex is often irrelevant for this specific test, as we are testing the remapping logic. - * - * @param name Name of the column handle - * @param initialIndex The original index before remapping which might not be the physical one - * @param hiveType Hive type of column handle - * @param trinoType Trino type of column handle - */ - private HiveColumnHandle createDummyHandle( - String name, - int initialIndex, - HiveType hiveType, - Type trinoType) - { - return new HiveColumnHandle( - name, - initialIndex, - hiveType, - trinoType, - Optional.empty(), - HiveColumnHandle.ColumnType.REGULAR, - Optional.empty()); - } - - /** - * Asserts that a HiveColumnHandle has the expected properties after remapping. - */ - private void assertHandle( - HiveColumnHandle handle, - String expectedBaseName, - int expectedPhysicalIndex, - HiveType expectedHiveType, - Type expectedTrinoType) - { - assertThat(handle.getBaseColumnName()) - .as("BaseColumnName mismatch for %s", expectedBaseName) - .isEqualTo(expectedBaseName); - assertThat(handle.getBaseHiveColumnIndex()) - .as("BaseHiveColumnIndex (physical) mismatch for %s", expectedBaseName) - .isEqualTo(expectedPhysicalIndex); - assertThat(handle.getBaseHiveType()) - .as("BaseHiveType mismatch for %s", expectedBaseName) - .isEqualTo(expectedHiveType); - assertThat(handle.getType()) - .as("Trino Type mismatch for %s", expectedBaseName) - .isEqualTo(expectedTrinoType); - // Assert that other fields if they are relevant - assertThat(handle.getColumnType()) - .as("ColumnType mismatch for %s", expectedBaseName) - .isEqualTo(HiveColumnHandle.ColumnType.REGULAR); - } -} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java b/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java deleted file mode 100644 index c711a324b06d9..0000000000000 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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. - */ -package io.trino.plugin.hudi; - -import com.google.common.collect.ImmutableList; -import io.trino.plugin.hive.parquet.ParquetReaderConfig; -import io.trino.spi.connector.ConnectorSession; -import io.trino.testing.TestingConnectorSession; -import org.junit.jupiter.api.Test; - -import static io.trino.plugin.hudi.HudiSessionProperties.getColumnsToHide; -import static org.assertj.core.api.Assertions.assertThat; - -public class TestHudiSessionProperties -{ - @Test - public void testSessionPropertyColumnsToHide() - { - HudiConfig config = new HudiConfig() - .setColumnsToHide(ImmutableList.of("col1", "col2")); - HudiSessionProperties sessionProperties = new HudiSessionProperties(config, new ParquetReaderConfig()); - ConnectorSession session = TestingConnectorSession.builder() - .setPropertyMetadata(sessionProperties.getSessionProperties()) - .build(); - assertThat(getColumnsToHide(session)) - .containsExactlyInAnyOrderElementsOf(ImmutableList.of("col1", "col2")); - } -} diff --git a/hudi-trino-plugin/.mvn/modernizer/violations-production-code-only.xml b/hudi-trino/.mvn/modernizer/violations-production-code-only.xml similarity index 100% rename from hudi-trino-plugin/.mvn/modernizer/violations-production-code-only.xml rename to hudi-trino/.mvn/modernizer/violations-production-code-only.xml diff --git a/hudi-trino-plugin/.mvn/modernizer/violations.xml b/hudi-trino/.mvn/modernizer/violations.xml similarity index 100% rename from hudi-trino-plugin/.mvn/modernizer/violations.xml rename to hudi-trino/.mvn/modernizer/violations.xml diff --git a/hudi-trino/README.md b/hudi-trino/README.md new file mode 100644 index 0000000000000..72c848ba2c180 --- /dev/null +++ b/hudi-trino/README.md @@ -0,0 +1,99 @@ + + +# hudi-trino + +Hudi connector for Trino (RFC-105). Published as `org.apache.hudi:hudi-trino` -- a regular non-shaded JAR. The Trino-side `trino-hudi` plugin module depends on this artifact and Trino's URLClassLoader isolates the plugin's transitive deps from the rest of the server, so no shading is required. + +## Build + +Excluded from default builds. Activate the `hudi-trino` Maven profile: + +``` +# tests need Trino test-jars not on Maven Central (see Running tests); skip them in the default build +mvn -Phudi-trino -pl hudi-trino install -Dmaven.test.skip=true +``` + +Requires JDK 25 (enforced via `maven-enforcer-plugin`). + +## Running tests + +Tests depend on Trino test-jars (`trino-spi`, `trino-filesystem`, `trino-hive`, `trino-main` at the `tests` classifier). Trino does not publish three of those to Maven Central, so the test deps live behind the `hudi-trino-tests` profile, off by default. + +To run the tests: + +1. Build the matching Trino version locally so its `*-tests.jar` artifacts land in your `~/.m2` (see `trino.version` in the root pom for the version to build). +2. Activate both profiles: + +``` +mvn -Phudi-trino,hudi-trino-tests -pl hudi-trino test +``` + +CI follows the same two steps: `.github/workflows/hudi_trino_ci.yml` installs the test-jars from a source checkout of the pinned Trino tag, then runs with both profiles enabled. + +## End-to-end tests (docker) + +The testcontainers E2E suite (`hudi-integ-test`, classes `ITTestTrino*` under +`org.apache.hudi.integ2.testcontainers.trino`) runs Trino queries against a real +HDFS + Hive metastore + Spark stack. The Trino container image bakes in a plugin +directory assembled by the in-repo shim at `docker/trino/shim/` -- a standalone Maven +project mirroring the upstream `trinodb/trino` `plugin/trino-hudi` shim planned by +RFC-105 (not yet released upstream). CI runs the same flow via +`.github/workflows/hudi_trino_e2e.yml`. + +Local flow: + +``` +# 1. JDK 17: full reactor incl. the integ-test bundles the containers mount +mvn clean install -T 2 -Dscala-2.13 -Dscala.binary.version=2.13 -Dspark4.0 -Dflink1.20 \ + -Pintegration-tests -DskipTests=true -Ddocker.compose.skip=true + +# 2. JDK 25: the connector +mvn -Phudi-trino -pl hudi-trino install -Dmaven.test.skip=true + +# 3. JDK 25: assemble the plugin dir (package, NOT install -- installing would +# shadow the real io.trino:trino-hudi release coordinates in the local m2). +# dep.hudi.version comes from the reactor pom: the shim sits outside the +# reactor, so cut_release_branch.sh cannot bump its literal default. +HUDI_VERSION=$(mvn -q -ntp help:evaluate -Dexpression=project.version -DforceStdout) +mvn -f docker/trino/shim/pom.xml clean package -DskipTests -Ddep.hudi.version="$HUDI_VERSION" + +# 4. Build the Trino image (locally tagged; never published) +docker/trino/build_image.sh --plugin-dir docker/trino/shim/target/trino-hudi-481 + +# 5. JDK 17: run the suite (only the spark402 compose pair has the trino service) +mvn verify -pl hudi-integ-test -Dscala-2.13 -Dscala.binary.version=2.13 -Dspark4.0 \ + -Pintegration-tests -DskipITs=false -Ddocker.compose.skip=true \ + -Dit.test='ITTestTrino*' -Dcompose.profiles=trino \ + -Dspark.docker.compose.prefix=docker-compose_hadoop340_hive2310_spark402 +``` + +Fast iteration loop: after changing connector code, redo steps 2-3, then add +`-Dtrino.plugin.dir=$PWD/docker/trino/shim/target/trino-hudi-481` to step 5. The +container's overlay entrypoint swaps the freshly built plugin dir in at start, so the +image rebuild (step 4) is skipped. + +## IDE setup + +Only this module needs JDK 25. Leave the rest of Hudi on its native JDK (11 or 17) so you are not toggling the project default. + +1. Activate the `hudi-trino` Maven profile so the IDE picks up the module. Tick `hudi-trino-tests` too if you want the test classpath to resolve. + - IntelliJ: Maven tool window, Profiles, tick both `hudi-trino` and `hudi-trino-tests`. +2. Override the SDK for the `hudi-trino` module only, to Temurin 25 with Language level 25. + - IntelliJ: `File > Project Structure > Modules > hudi-trino > Dependencies > Module SDK`. + +The enforcer rule only runs during `mvn`, not during the IDE's incremental compile. diff --git a/hudi-trino/pom.xml b/hudi-trino/pom.xml new file mode 100644 index 0000000000000..5eb66412eba5f --- /dev/null +++ b/hudi-trino/pom.xml @@ -0,0 +1,728 @@ + + + + 4.0.0 + + + org.apache.hudi + hudi + 1.2.0 + ../pom.xml + + + hudi-trino + jar + Hudi connector for Trino (RFC-105) + + + + 1.15.2 + + 25 + ${project.parent.basedir} + true + + -Xmx4g -Xms128m -XX:-OmitStackTraceInFastThrow --add-modules jdk.incubator.vector + + + + + + + io.trino + trino-root + ${trino.version} + pom + import + + + + com.fasterxml.jackson.core + jackson-core + 2.21.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.21.3 + + + + com.fasterxml.jackson.core + jackson-annotations + 2.21 + + + + org.eclipse.jetty + jetty-server + 12.1.9 + + + + joda-time + joda-time + 2.14.2 + + + + org.glassfish.jersey.core + jersey-server + 4.0.2 + + + org.glassfish.jersey.core + jersey-client + 4.0.2 + + + org.glassfish.jersey.media + jersey-media-jaxb + 4.0.2 + + + + org.junit.platform + junit-platform-commons + ${junit.platform.version} + + + org.junit.platform + junit-platform-engine + ${junit.platform.version} + + + org.junit.platform + junit-platform-launcher + ${junit.platform.version} + + + + + + + com.esotericsoftware + kryo + 4.0.2 + compile + + + + com.google.errorprone + error_prone_annotations + true + + + + com.google.guava + guava + + + + + com.google.inject + guice + classes + + + + io.airlift + bootstrap + + + + io.airlift + concurrent + + + + io.airlift + configuration + + + + io.airlift + json + + + + io.airlift + log + + + + io.airlift + units + + + + io.trino + trino-cache + + + + io.trino + trino-filesystem + + + + io.trino + trino-filesystem-manager + + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + + io.trino + trino-hive + + + + io.trino + trino-hive-formats + + + + io.trino + trino-memory-context + + + + io.trino + trino-metastore + + + + io.trino + trino-parquet + + + + io.trino + trino-plugin-toolkit + + + + jakarta.validation + jakarta.validation-api + + + + joda-time + joda-time + + + + org.apache.avro + avro + + + + org.apache.hudi + hudi-common + ${project.version} + + + io.dropwizard.metrics + * + + + org.apache.hbase + * + + + org.apache.httpcomponents + * + + + org.apache.orc + * + + + + org.eclipse.jetty + * + + + + org.rocksdb + * + + + org.apache.arrow + * + + + org.lance + * + + + + + + org.apache.hudi + hudi-hive-sync + ${project.version} + + + org.apache.hudi + hudi-hadoop-common + + + + + + org.apache.hudi + hudi-io + ${project.version} + shaded + + + com.google.protobuf + protobuf-java + + + org.rocksdb + * + + + org.apache.arrow + * + + + org.lance + * + + + + + + org.apache.hudi + hudi-sync-common + ${project.version} + + + org.apache.hudi + hudi-hadoop-common + + + + + + org.apache.parquet + parquet-column + + + + org.weakref + jmxutils + + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + + io.airlift + slice + provided + + + + io.opentelemetry + opentelemetry-api + provided + + + + io.opentelemetry + opentelemetry-api-incubator + provided + + + + io.opentelemetry + opentelemetry-context + provided + + + + io.trino + trino-spi + provided + + + + org.openjdk.jol + jol-core + provided + + + + com.github.ben-manes.caffeine + caffeine + runtime + + + + io.airlift + log-manager + runtime + + + + io.dropwizard.metrics + metrics-core + runtime + + + + io.opentelemetry + opentelemetry-sdk-trace + runtime + + + + org.jetbrains + annotations + runtime + + + + io.airlift + configuration-testing + test + + + + io.airlift + junit-extensions + test + + + + io.airlift + testing + test + + + + io.trino + trino-client + test + + + + io.trino + trino-hdfs + test + + + + io.trino + trino-main + test + + + + io.trino + trino-parser + test + + + + io.trino + trino-testing + test + + + + io.trino + trino-testing-containers + test + + + + io.trino + trino-testing-services + test + + + + io.trino + trino-tpch + test + + + + io.trino.hadoop + hadoop-apache + test + + + + io.trino.tpch + tpch + test + + + + org.apache.parquet + parquet-avro + ${trino.parquet.version} + test + + + + org.apache.parquet + parquet-hadoop + test + + + + org.assertj + assertj-core + test + + + + org.json + json + 20250107 + test + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.junit.platform + junit-platform-launcher + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + + + + org.basepom.maven + duplicate-finder-maven-plugin + + + + log4j.properties + log4j-surefire.properties + google/protobuf/.* + + + + org.apache.parquet.conf.ParquetConfiguration + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + ${hudi.trino.java.version} + ${hudi.trino.java.version} + ${hudi.trino.java.version} + + none + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + ${hudi.trino.java.version} + + true + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-jdk25 + + enforce + + + + + [${hudi.trino.java.version},) + hudi-trino requires JDK ${hudi.trino.java.version} or newer. + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + com.esotericsoftware:kryo + + + + + + + + + + hudi-trino-tests + + + io.trino + trino-filesystem + ${trino.version} + test-jar + test + + + io.trino + trino-hive + ${trino.version} + test-jar + test + + + io.trino + trino-main + ${trino.version} + test-jar + test + + + io.trino + trino-spi + ${trino.version} + test-jar + test + + + org.apache.hudi + hudi-client-common + ${project.version} + test + + + * + * + + + + org.apache.hudi + hudi-timeline-service + + + + + org.apache.hudi + hudi-hadoop-common + ${project.version} + test + + + * + * + + + + + org.apache.hudi + hudi-java-client + ${project.version} + test + + + org.apache.hudi + * + + + + + + + diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/ForHudiSplitManager.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/ForHudiSplitManager.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/ForHudiSplitManager.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/ForHudiSplitManager.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/ForHudiSplitSource.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/ForHudiSplitSource.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/ForHudiSplitSource.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/ForHudiSplitSource.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java similarity index 77% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java index 1180638a7cc47..b809f005a2dda 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiBaseFileOnlyPageSource.java @@ -15,10 +15,11 @@ import com.google.common.collect.ImmutableList; import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hudi.util.SynthesizedColumnHandler; +import io.trino.plugin.hudi.util.PrefilledColumnValues; import io.trino.spi.Page; import io.trino.spi.block.Block; import io.trino.spi.connector.ConnectorPageSource; +import io.trino.spi.connector.SourcePage; import java.io.IOException; import java.util.HashMap; @@ -37,8 +38,8 @@ public class HudiBaseFileOnlyPageSource { private final ConnectorPageSource dataPageSource; private final List allOutputColumns; - private final SynthesizedColumnHandler synthesizedColumnHandler; - // Maps output channel to physical source channel, or -1 if synthesized + private final PrefilledColumnValues prefilledColumnValues; + // Maps output channel to physical source channel, or -1 if prefilled private final int[] physicalSourceChannelMap; public HudiBaseFileOnlyPageSource( @@ -46,12 +47,13 @@ public HudiBaseFileOnlyPageSource( List allOutputColumns, // Columns provided by dataPageSource List dataColumns, - // Handler to manage synthesized/virtual in Hudi tables such as partition columns and metadata, i.e. file size (not hudi metadata) - SynthesizedColumnHandler synthesizedColumnHandler) + // Per-split constant values for columns not present in the data file, such as partition + // columns and Trino's hidden metadata columns, e.g. file size (not hudi metadata) + PrefilledColumnValues prefilledColumnValues) { this.dataPageSource = requireNonNull(dataPageSource, "dataPageSource is null"); this.allOutputColumns = ImmutableList.copyOf(requireNonNull(allOutputColumns, "allOutputColumns is null")); - this.synthesizedColumnHandler = requireNonNull(synthesizedColumnHandler, "synthesizedColumnHandler is null"); + this.prefilledColumnValues = requireNonNull(prefilledColumnValues, "prefilledColumnValues is null"); // Create a mapping from the channel index in the output page to the channel index in the physicalDataPageSource's page this.physicalSourceChannelMap = new int[allOutputColumns.size()]; @@ -84,16 +86,16 @@ public boolean isFinished() } @Override - public Page getNextPage() + public SourcePage getNextSourcePage() { - Page physicalSourcePage = dataPageSource.getNextPage(); + SourcePage physicalSourcePage = dataPageSource.getNextSourcePage(); if (physicalSourcePage == null) { return null; } int positionCount = physicalSourcePage.getPositionCount(); - if (positionCount == 0 && synthesizedColumnHandler.getSynthesizedColumnCount() == 0) { - // If only physical columns and page is empty + if (allOutputColumns.isEmpty()) { + // Forward the zero-block page so positionCount survives -- new Page(new Block[0]) would infer positionCount=0. return physicalSourcePage; } @@ -104,11 +106,11 @@ public Page getNextPage() outputBlocks[i] = physicalSourcePage.getBlock(physicalSourceChannelMap[i]); } else { - // Column is synthesized - outputBlocks[i] = synthesizedColumnHandler.createRleSynthesizedBlock(outputColumn, positionCount); + // Column is not in the data file; fill with the split's constant value + outputBlocks[i] = prefilledColumnValues.toRleBlock(outputColumn, positionCount); } } - return new Page(outputBlocks); + return SourcePage.create(new Page(outputBlocks)); } @Override diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConfig.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConfig.java similarity index 94% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConfig.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConfig.java index 2355744b6976b..c5ce5ce0e23d9 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConfig.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConfig.java @@ -19,6 +19,7 @@ import io.airlift.configuration.DefunctConfig; import io.airlift.units.DataSize; import io.airlift.units.Duration; +import io.airlift.units.MinDataSize; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Min; @@ -38,6 +39,7 @@ public class HudiConfig { private List columnsToHide = ImmutableList.of(); + private List recordMergerImpls = ImmutableList.of(); private boolean tableStatisticsEnabled = true; private int tableStatisticsExecutorParallelism = 4; private boolean metadataEnabled = true; @@ -55,7 +57,7 @@ public class HudiConfig private boolean queryPartitionFilterRequired; private boolean ignoreAbsentPartitions; private Duration dynamicFilteringWaitTimeout = new Duration(1, SECONDS); - private boolean resolveColumnNameCasingEnabled = true; + private boolean resolveColumnNameCasingEnabled; // Internal configuration for debugging and testing private boolean isRecordLevelIndexEnabled = true; @@ -84,6 +86,21 @@ public HudiConfig setColumnsToHide(List columnsToHide) return this; } + public List getRecordMergerImpls() + { + return recordMergerImpls; + } + + @Config("hudi.record-merger-impls") + @ConfigDescription("Comma-separated list of fully qualified HoodieRecordMerger implementation class names used to " + + "resolve a custom record merger for Merge-On-Read tables whose record merge mode is CUSTOM. " + + "The merger must produce Avro records (HoodieRecordType.AVRO). By default, no custom mergers are registered.") + public HudiConfig setRecordMergerImpls(List recordMergerImpls) + { + this.recordMergerImpls = ImmutableList.copyOf(recordMergerImpls); + return this; + } + @Config("hudi.table-statistics-enabled") @ConfigDescription("Enable table statistics for query planning.") public HudiConfig setTableStatisticsEnabled(boolean tableStatisticsEnabled) @@ -204,6 +221,7 @@ public HudiConfig setTargetSplitSize(DataSize targetSplitSize) } @NotNull + @MinDataSize("1B") public DataSize getTargetSplitSize() { return targetSplitSize; diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConnector.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConnector.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConnector.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConnector.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java similarity index 79% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java index 0db65192baf8f..cb5a565691c48 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiConnectorFactory.java @@ -22,18 +22,14 @@ import io.airlift.bootstrap.LifeCycleManager; import io.airlift.configuration.AbstractConfigurationAwareModule; import io.airlift.json.JsonModule; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.trace.Tracer; import io.trino.filesystem.manager.FileSystemModule; +import io.trino.plugin.base.ConnectorContextModule; import io.trino.plugin.base.classloader.ClassLoaderSafeConnectorPageSourceProvider; import io.trino.plugin.base.classloader.ClassLoaderSafeConnectorSplitManager; import io.trino.plugin.base.classloader.ClassLoaderSafeNodePartitioningProvider; import io.trino.plugin.base.jmx.MBeanServerModule; import io.trino.plugin.base.session.SessionPropertiesProvider; -import io.trino.plugin.hive.NodeVersion; import io.trino.plugin.hive.metastore.HiveMetastoreModule; -import io.trino.spi.NodeManager; -import io.trino.spi.catalog.CatalogName; import io.trino.spi.classloader.ThreadContextClassLoader; import io.trino.spi.connector.Connector; import io.trino.spi.connector.ConnectorContext; @@ -41,7 +37,6 @@ import io.trino.spi.connector.ConnectorNodePartitioningProvider; import io.trino.spi.connector.ConnectorPageSourceProvider; import io.trino.spi.connector.ConnectorSplitManager; -import io.trino.spi.type.TypeManager; import org.weakref.jmx.guice.MBeanModule; import java.util.Map; @@ -80,18 +75,11 @@ public static Connector createConnector( new MBeanModule(), new JsonModule(), new HudiModule(), - new HiveMetastoreModule(Optional.empty()), + new HiveMetastoreModule(Optional.empty(), false), new HudiFileSystemModule(catalogName, context), new MBeanServerModule(), - module.orElse(EMPTY_MODULE), - binder -> { - binder.bind(OpenTelemetry.class).toInstance(context.getOpenTelemetry()); - binder.bind(Tracer.class).toInstance(context.getTracer()); - binder.bind(NodeVersion.class).toInstance(new NodeVersion(context.getNodeManager().getCurrentNode().getVersion())); - binder.bind(NodeManager.class).toInstance(context.getNodeManager()); - binder.bind(TypeManager.class).toInstance(context.getTypeManager()); - binder.bind(CatalogName.class).toInstance(new CatalogName(catalogName)); - }); + new ConnectorContextModule(catalogName, context), + module.orElse(EMPTY_MODULE)); Injector injector = app .doNotInitializeLogging() @@ -123,21 +111,19 @@ private static class HudiFileSystemModule extends AbstractConfigurationAwareModule { private final String catalogName; - private final NodeManager nodeManager; - private final OpenTelemetry openTelemetry; + private final ConnectorContext context; public HudiFileSystemModule(String catalogName, ConnectorContext context) { this.catalogName = requireNonNull(catalogName, "catalogName is null"); - this.nodeManager = context.getNodeManager(); - this.openTelemetry = context.getOpenTelemetry(); + this.context = requireNonNull(context, "context is null"); } @Override protected void setup(Binder binder) { boolean metadataCacheEnabled = buildConfigObject(HudiConfig.class).isMetadataCacheEnabled(); - install(new FileSystemModule(catalogName, nodeManager, openTelemetry, metadataCacheEnabled)); + install(new FileSystemModule(catalogName, context, metadataCacheEnabled)); } } } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiErrorCode.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiErrorCode.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiErrorCode.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiErrorCode.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiFileStatus.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiFileStatus.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiFileStatus.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiFileStatus.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiMetadata.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiMetadata.java similarity index 94% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiMetadata.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiMetadata.java index 917c94ea43d9f..beb4525a17251 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiMetadata.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiMetadata.java @@ -38,6 +38,7 @@ import io.trino.spi.connector.ConnectorTableVersion; import io.trino.spi.connector.Constraint; import io.trino.spi.connector.ConstraintApplicationResult; +import io.trino.spi.connector.LimitApplicationResult; import io.trino.spi.connector.RelationColumnsMetadata; import io.trino.spi.connector.SchemaTableName; import io.trino.spi.connector.SchemaTablePrefix; @@ -47,8 +48,8 @@ import io.trino.spi.statistics.Estimate; import io.trino.spi.statistics.TableStatistics; import io.trino.spi.type.TypeManager; -import org.apache.avro.Schema; import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.versioning.v2.InstantComparatorV2; @@ -64,6 +65,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -91,6 +93,8 @@ import static io.trino.plugin.hudi.HudiTableProperties.PARTITIONED_BY_PROPERTY; import static io.trino.plugin.hudi.HudiUtil.buildTableMetaClient; import static io.trino.plugin.hudi.HudiUtil.getLatestTableSchema; +import static io.trino.plugin.hudi.HudiSessionProperties.getRecordMergerImpls; +import static io.trino.plugin.hudi.HudiUtil.getMergeRequiredColumnHandles; import static io.trino.spi.StandardErrorCode.NOT_SUPPORTED; import static io.trino.spi.StandardErrorCode.QUERY_REJECTED; import static io.trino.spi.StandardErrorCode.UNSUPPORTED_TABLE_TYPE; @@ -162,7 +166,7 @@ public HudiTableHandle getTableHandle(ConnectorSession session, SchemaTableName String inputFormat = table.getStorage().getStorageFormat().getInputFormat(); HoodieTableType hoodieTableType = HudiTableTypeUtils.fromInputFormat(inputFormat); Lazy lazyMetaClient = Lazy.lazily(() -> buildTableMetaClient(fileSystem, tableName.toString(), basePath)); - Optional> hudiTableSchema = isResolveColumnNameCasingEnabled(session) ? + Optional> hudiTableSchema = isResolveColumnNameCasingEnabled(session) ? Optional.of(Lazy.lazily(() -> getLatestTableSchema(lazyMetaClient.get(), tableName.getTableName()))) : Optional.empty(); return new HudiTableHandle( @@ -173,9 +177,11 @@ public HudiTableHandle getTableHandle(ConnectorSession session, SchemaTableName table.getStorage().getLocation(), hoodieTableType, getPartitionKeyColumnHandles(table, typeManager), + Lazy.lazily(() -> getMergeRequiredColumnHandles(table, typeManager, lazyMetaClient, getRecordMergerImpls(session), NANOSECONDS)), ImmutableSet.of(), TupleDomain.all(), TupleDomain.all(), + OptionalLong.empty(), hudiTableSchema); } @@ -259,6 +265,22 @@ public Optional> applyFilter(C false)); } + @Override + public Optional> applyLimit(ConnectorSession session, ConnectorTableHandle handle, long limit) + { + HudiTableHandle table = (HudiTableHandle) handle; + + if (table.getLimit().isPresent() && table.getLimit().getAsLong() <= limit) { + return Optional.empty(); + } + + // limitGuaranteed=false: the connector can't bound row count across splits without + // coordinator-side coordination. Trino keeps the Limit operator above the TableScan. + // The stored limit is consumed by HudiSplitSource for split-listing short-circuit when + // row-count estimates from the Hudi metadata table become available (TODO). + return Optional.of(new LimitApplicationResult<>(table.withLimit(limit), false, false)); + } + @Override public Map getColumnHandles(ConnectorSession session, ConnectorTableHandle tableHandle) { @@ -276,7 +298,7 @@ public ColumnMetadata getColumnMetadata(ConnectorSession session, ConnectorTable } @Override - public Optional getInfo(ConnectorTableHandle tableHandle) + public Optional getInfo(ConnectorSession session, ConnectorTableHandle tableHandle) { HudiTableHandle table = (HudiTableHandle) tableHandle; return Optional.of(new HudiTableInfo(table.getSchemaTableName(), table.getTableType().name(), table.getBasePath())); diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiMetadataFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiMetadataFactory.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiMetadataFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiMetadataFactory.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiModule.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiModule.java similarity index 95% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiModule.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiModule.java index bd3c1923ebadf..cd17429b90d36 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiModule.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiModule.java @@ -26,7 +26,6 @@ import io.trino.plugin.hive.HideDeltaLakeTables; import io.trino.plugin.hive.HiveNodePartitioningProvider; import io.trino.plugin.hive.HiveTransactionHandle; -import io.trino.plugin.hive.metastore.thrift.TranslateHiveViews; import io.trino.plugin.hive.parquet.ParquetReaderConfig; import io.trino.plugin.hive.parquet.ParquetWriterConfig; import io.trino.plugin.hudi.cache.HudiCacheKeyProvider; @@ -44,7 +43,7 @@ import static com.google.inject.multibindings.OptionalBinder.newOptionalBinder; import static io.airlift.concurrent.Threads.daemonThreadsNamed; import static io.airlift.configuration.ConfigBinder.configBinder; -import static io.trino.plugin.base.ClosingBinder.closingBinder; +import static io.airlift.bootstrap.ClosingBinder.closingBinder; import static java.util.concurrent.Executors.newCachedThreadPool; import static java.util.concurrent.Executors.newScheduledThreadPool; import static org.weakref.jmx.guice.ExportBinder.newExporter; @@ -59,7 +58,6 @@ public void configure(Binder binder) configBinder(binder).bindConfig(HudiConfig.class); - binder.bind(boolean.class).annotatedWith(TranslateHiveViews.class).toInstance(false); binder.bind(boolean.class).annotatedWith(HideDeltaLakeTables.class).toInstance(false); newSetBinder(binder, SessionPropertiesProvider.class).addBinding().to(HudiSessionProperties.class).in(Scopes.SINGLETON); diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSource.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSource.java similarity index 50% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSource.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSource.java index e81887414425b..ee7753edf34e1 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPageSource.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSource.java @@ -14,15 +14,17 @@ package io.trino.plugin.hudi; import io.trino.plugin.hive.HiveColumnHandle; -import io.trino.plugin.hudi.reader.HudiTrinoReaderContext; import io.trino.plugin.hudi.util.HudiAvroSerializer; -import io.trino.plugin.hudi.util.SynthesizedColumnHandler; +import io.trino.plugin.hudi.util.PrefilledColumnValues; import io.trino.spi.Page; import io.trino.spi.PageBuilder; +import io.trino.spi.TrinoException; import io.trino.spi.connector.ConnectorPageSource; +import io.trino.spi.connector.SourcePage; import io.trino.spi.metrics.Metrics; import org.apache.avro.generic.IndexedRecord; import org.apache.hudi.common.table.read.HoodieFileGroupReader; +import org.apache.hudi.common.util.collection.ClosableIterator; import java.io.IOException; import java.util.List; @@ -30,32 +32,60 @@ import java.util.concurrent.CompletableFuture; import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.getCausalChain; +import static com.google.common.base.Throwables.throwIfUnchecked; public class HudiPageSource implements ConnectorPageSource { - HoodieFileGroupReader fileGroupReader; - // TODO: Remove pageSource here, Hudi doesn't use this page source to read - ConnectorPageSource pageSource; - HudiTrinoReaderContext readerContext; - PageBuilder pageBuilder; - HudiAvroSerializer avroSerializer; - List columnHandles; + private final HoodieFileGroupReader fileGroupReader; + // Reads flow through fileGroupReader; pageSource is kept for stats/isBlocked delegation + private final ConnectorPageSource pageSource; + private final PageBuilder pageBuilder; + private final HudiAvroSerializer avroSerializer; + private final ClosableIterator recordIterator; public HudiPageSource( ConnectorPageSource pageSource, HoodieFileGroupReader fileGroupReader, - HudiTrinoReaderContext readerContext, List columnHandles, - SynthesizedColumnHandler synthesizedColumnHandler) + PrefilledColumnValues prefilledColumnValues) { this.pageSource = pageSource; this.fileGroupReader = fileGroupReader; - this.initFileGroupReader(); - this.readerContext = readerContext; - this.columnHandles = columnHandles; this.pageBuilder = new PageBuilder(columnHandles.stream().map(HiveColumnHandle::getType).toList()); - this.avroSerializer = new HudiAvroSerializer(columnHandles, synthesizedColumnHandler); + this.avroSerializer = new HudiAvroSerializer(columnHandles, prefilledColumnValues); + try { + this.recordIterator = fileGroupReader.getClosableIterator(); + } + catch (Throwable e) { + // Hudi's log scanning wraps failures in a generic HoodieException ("Exception when + // reading log file"), which buries connector errors; if the cause chain holds a + // TrinoException, throw that one instead so its error code and actionable message + // surface as the query failure. + Throwable toThrow = getCausalChain(e).stream() + .filter(TrinoException.class::isInstance) + .findFirst() + .orElse(e); + // getClosableIterator() can fail with checked (IOException) or unchecked + // (HoodieIOException, NPE/IAE from schema/file validation) exceptions; clean up + // in all cases so we don't leak the reader/page-source handles. + try { + fileGroupReader.close(); + } + catch (Exception closeException) { + toThrow.addSuppressed(closeException); + } + try { + pageSource.close(); + } + catch (Exception closeException) { + toThrow.addSuppressed(closeException); + } + // Preserve the original exception type (RuntimeException/Error) instead of masking it. + throwIfUnchecked(toThrow); + throw new RuntimeException("Failed to initialize file group reader!", toThrow); + } } @Override @@ -79,30 +109,20 @@ public long getReadTimeNanos() @Override public boolean isFinished() { - try { - return !fileGroupReader.hasNext(); - } - catch (IOException e) { - throw new RuntimeException(e); - } + return !recordIterator.hasNext(); } @Override - public Page getNextPage() + public SourcePage getNextSourcePage() { checkState(pageBuilder.isEmpty(), "PageBuilder is not empty at the beginning of a new page"); - try { - while (fileGroupReader.hasNext()) { - avroSerializer.buildRecordInPage(pageBuilder, fileGroupReader.next()); - } - } - catch (IOException e) { - throw new RuntimeException(e); + while (recordIterator.hasNext()) { + avroSerializer.buildRecordInPage(pageBuilder, recordIterator.next()); } Page newPage = pageBuilder.build(); pageBuilder.reset(); - return newPage; + return SourcePage.create(newPage); } @Override @@ -115,8 +135,10 @@ public long getMemoryUsage() public void close() throws IOException { - fileGroupReader.close(); - pageSource.close(); + // recordIterator is the outermost wrapper; closing it cascades down through the file + // group reader to the underlying Trino pageSource, releasing each resource exactly once. + // Closing fileGroupReader/pageSource here too would double-close the same handles. + recordIterator.close(); } @Override @@ -130,14 +152,4 @@ public Metrics getMetrics() { return pageSource.getMetrics(); } - - protected void initFileGroupReader() - { - try { - this.fileGroupReader.initRecordIterators(); - } - catch (IOException e) { - throw new RuntimeException("Failed to initialize file group reader!", e); - } - } } diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java new file mode 100644 index 0000000000000..2803f8ae231a7 --- /dev/null +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java @@ -0,0 +1,675 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.inject.Inject; +import io.airlift.log.Logger; +import io.trino.filesystem.Location; +import io.trino.filesystem.TrinoFileSystem; +import io.trino.filesystem.TrinoFileSystemFactory; +import io.trino.filesystem.TrinoInputFile; +import io.trino.memory.context.AggregatedMemoryContext; +import io.trino.parquet.ParquetCorruptionException; +import io.trino.parquet.ParquetDataSource; +import io.trino.parquet.ParquetDataSourceId; +import io.trino.parquet.ParquetReaderOptions; +import io.trino.parquet.metadata.FileMetadata; +import io.trino.parquet.metadata.ParquetMetadata; +import io.trino.parquet.predicate.TupleDomainParquetPredicate; +import io.trino.parquet.reader.MetadataReader; +import io.trino.parquet.reader.ParquetReader; +import io.trino.parquet.reader.RowGroupInfo; +import io.trino.plugin.base.metrics.FileFormatDataSourceStats; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HiveColumnProjectionInfo; +import io.trino.plugin.hive.parquet.ParquetReaderConfig; +import io.trino.plugin.hudi.file.HudiBaseFile; +import io.trino.plugin.hudi.reader.HudiTrinoReaderContext; +import io.trino.plugin.hudi.util.PrefilledColumnValues; +import io.trino.spi.TrinoException; +import io.trino.spi.connector.ColumnHandle; +import io.trino.spi.connector.ConnectorPageSource; +import io.trino.spi.connector.ConnectorPageSourceProvider; +import io.trino.spi.connector.ConnectorSession; +import io.trino.spi.connector.ConnectorSplit; +import io.trino.spi.connector.ConnectorTableHandle; +import io.trino.spi.connector.ConnectorTransactionHandle; +import io.trino.spi.connector.DynamicFilter; +import io.trino.spi.connector.EmptyPageSource; +import io.trino.spi.predicate.Domain; +import io.trino.spi.predicate.TupleDomain; +import org.apache.avro.Schema; +import org.apache.avro.generic.IndexedRecord; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.read.HoodieFileGroupReader; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.storage.StoragePath; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.Type; +import org.joda.time.DateTimeZone; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.stream.Collectors; + +import static io.trino.memory.context.AggregatedMemoryContext.newSimpleAggregatedMemoryContext; +import static io.trino.parquet.ParquetTypeUtils.getColumnIO; +import static io.trino.parquet.ParquetTypeUtils.getDescriptors; +import static io.trino.parquet.predicate.PredicateUtils.buildPredicate; +import static io.trino.parquet.predicate.PredicateUtils.getFilteredRowGroups; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.ParquetReaderProvider; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createDataSource; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createParquetPageSource; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.getParquetMessageType; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.getParquetTupleDomain; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CANNOT_OPEN_SPLIT; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CURSOR_ERROR; +import static io.trino.plugin.hudi.HudiSessionProperties.getParquetMaxReadBlockRowCount; +import static io.trino.plugin.hudi.HudiSessionProperties.getParquetMaxReadBlockSize; +import static io.trino.plugin.hudi.HudiSessionProperties.getParquetSmallFileThreshold; +import static io.trino.plugin.hudi.HudiSessionProperties.getRecordMergerImpls; +import static io.trino.plugin.hudi.HudiSessionProperties.isParquetIgnoreStatistics; +import static io.trino.plugin.hudi.HudiSessionProperties.isParquetUseColumnIndex; +import static io.trino.plugin.hudi.HudiSessionProperties.isParquetVectorizedDecodingEnabled; +import static io.trino.plugin.hudi.HudiSessionProperties.shouldUseParquetColumnNames; +import static io.trino.plugin.hudi.HudiSessionProperties.useParquetBloomFilter; +import static io.trino.plugin.hudi.HudiUtil.appendMissingMergeRequiredColumns; +import static io.trino.plugin.hudi.HudiUtil.appendMissingSchemaColumns; +import static io.trino.plugin.hudi.HudiUtil.buildTableMetaClient; +import static io.trino.plugin.hudi.HudiUtil.constructSchema; +import static io.trino.plugin.hudi.HudiUtil.convertToFileSlice; +import static io.trino.plugin.hudi.HudiUtil.getLatestTableSchema; +import static io.trino.plugin.hudi.HudiUtil.prependHudiMetaAndMergeRequiredColumns; +import static io.trino.plugin.hudi.HudiUtil.resolveMergeModeAndStrategyId; +import static io.trino.plugin.hudi.HudiUtil.usesNonProjectionCompatibleMerger; +import static io.trino.spi.StandardErrorCode.NOT_SUPPORTED; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_DEPRECATED_WRITE_CONFIG_KEY; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY; + +public class HudiPageSourceProvider + implements ConnectorPageSourceProvider +{ + private static final Logger log = Logger.get(HudiPageSourceProvider.class); + private static final int DOMAIN_COMPACTION_THRESHOLD = 1000; + + private final TrinoFileSystemFactory fileSystemFactory; + private final FileFormatDataSourceStats dataSourceStats; + private final ParquetReaderOptions options; + private final DateTimeZone timeZone = DateTimeZone.forID("UTC"); + + @Inject + public HudiPageSourceProvider( + TrinoFileSystemFactory fileSystemFactory, + FileFormatDataSourceStats dataSourceStats, + ParquetReaderConfig parquetReaderConfig) + { + this.fileSystemFactory = requireNonNull(fileSystemFactory, "fileSystemFactory is null"); + this.dataSourceStats = requireNonNull(dataSourceStats, "dataSourceStats is null"); + this.options = requireNonNull(parquetReaderConfig, "parquetReaderConfig is null").toParquetReaderOptions(); + } + + @Override + public ConnectorPageSource createPageSource( + ConnectorTransactionHandle transaction, + ConnectorSession session, + ConnectorSplit connectorSplit, + ConnectorTableHandle connectorTable, + List columns, + DynamicFilter dynamicFilter) + { + HudiTableHandle hudiTableHandle = (HudiTableHandle) connectorTable; + HudiSplit hudiSplit = (HudiSplit) connectorSplit; + Optional hudiBaseFileOpt = hudiSplit.getBaseFile(); + + String dataFilePath = hudiBaseFileOpt.isPresent() + ? hudiBaseFileOpt.get().getPath() + : hudiSplit.getLogFiles().getFirst().getPath(); + // Filter out metadata table splits + // TODO: Move this check into a higher calling stack, such that the split is not created at all + if (dataFilePath.contains(new StoragePath( + ((HudiTableHandle) connectorTable).getBasePath()).toUri().getPath() + "/.hoodie/metadata")) { + return new EmptyPageSource(); + } + + // Handle MERGE_ON_READ tables to be read in read_optimized mode + // IMPORTANT: These tables will have a COPY_ON_WRITE table, see: `HudiTableTypeUtils#fromInputFormat` + // TODO: Move this check into a higher calling stack, such that the split is not created at all + if (hudiTableHandle.getTableType().equals(HoodieTableType.COPY_ON_WRITE) && !hudiSplit.getLogFiles().isEmpty()) { + if (hudiBaseFileOpt.isEmpty()) { + // Handle hasLogFiles=true, hasBaseFile = false + // Ignoring log files without base files, no data required to be read + return new EmptyPageSource(); + } + } + + long start = 0; + long length = 10; + if (hudiBaseFileOpt.isPresent()) { + start = hudiBaseFileOpt.get().getStart(); + length = hudiBaseFileOpt.get().getLength(); + } + + // Enable predicate pushdown for splits containing only base files + boolean isBaseFileOnly = hudiSplit.getLogFiles().isEmpty(); + // Convert columns to HiveColumnHandles + List hiveColumnHandles = getHiveColumns(columns); + + // Get non-synthesized columns (columns that are available in data file) + List dataColumnHandles = hiveColumnHandles.stream() + .filter(columnHandle -> !columnHandle.isPartitionKey() && !columnHandle.isHidden()) + .collect(Collectors.toList()); + // The `columns` list could be empty when count(*) is issued, + // prepending hoodie meta columns for Hudi split with log files + // to allow a non-empty dataPageSource to be returned + List hudiMetaAndDataColumnHandles = prependHudiMetaAndMergeRequiredColumns(hudiTableHandle, dataColumnHandles); + + TrinoFileSystem fileSystem = fileSystemFactory.create(session); + ParquetReaderOptions sessionOptions = ParquetReaderOptions.builder(options) + .withIgnoreStatistics(isParquetIgnoreStatistics(session)) + .withMaxReadBlockSize(getParquetMaxReadBlockSize(session)) + .withMaxReadBlockRowCount(getParquetMaxReadBlockRowCount(session)) + .withSmallFileThreshold(getParquetSmallFileThreshold(session)) + .withUseColumnIndex(isParquetUseColumnIndex(session)) + .withBloomFilter(useParquetBloomFilter(session)) + .withVectorizedDecodingEnabled(isParquetVectorizedDecodingEnabled(session)) + .build(); + PrefilledColumnValues prefilledColumnValues = PrefilledColumnValues.create(hudiSplit); + + // Avoid avro serialization if split/filegroup only contains base files + if (isBaseFileOnly) { + return new HudiBaseFileOnlyPageSource( + createBaseFilePageSource(session, dataColumnHandles, hudiSplit, fileSystem, sessionOptions, start, length, dynamicFilter, true), + hiveColumnHandles, + dataColumnHandles, + prefilledColumnValues); + } + + // The merge path below is built around a base-file page source; fail log-only file slices with a + // clear error instead of an opaque NoSuchElementException from getBaseFile().orElseThrow(). + // TODO: support log-only file slices by feeding the file-group reader an empty base page source. + if (hudiBaseFileOpt.isEmpty()) { + throw new TrinoException(NOT_SUPPORTED, "Hudi splits with log files but no base file are not supported: " + + hudiSplit.getLogFiles().getFirst().getPath()); + } + + // TODO: Move this into HudiTableHandle + HoodieTableMetaClient metaClient = buildTableMetaClient( + fileSystemFactory.create(session), hudiTableHandle.getSchemaTableName().toString(), hudiTableHandle.getBasePath()); + HoodieSchema dataSchema = + Optional.ofNullable(hudiTableHandle.getTableSchema()) + .orElseGet(() -> getLatestTableSchema(metaClient, hudiTableHandle.getTableName())); + TypedProperties readerProps = buildReaderProperties(session, metaClient); + + // A non-projection-compatible CUSTOM merger makes the file-group reader demand the FULL table + // schema as requiredSchema for this split (it has log files), for the base and log reads alike. + // Expand the read projection to the full schema up front so the base page source carries every + // merge column; the log page sources resolve their columns from the same expanded handles. + List readColumnHandles; + if (requiresFullSchemaRead(metaClient.getTableConfig(), readerProps)) { + log.debug("Expanding the read projection of %s to the full table schema: the resolved record merger is not projection compatible", + hudiTableHandle.getSchemaTableName()); + readColumnHandles = appendMissingSchemaColumns(dataSchema, hudiMetaAndDataColumnHandles); + } + else { + // The metastore may lack merge-required columns the table schema carries (e.g. hive sync with + // omit_metadata_fields=true drops _hoodie_operation); recover them from the already-resolved + // schema so the base read is not starved of them. + readColumnHandles = appendMissingMergeRequiredColumns(dataSchema, hudiMetaAndDataColumnHandles, metaClient.getTableConfig(), readerProps); + } + + ConnectorPageSource dataPageSource = + createBaseFilePageSource(session, readColumnHandles, hudiSplit, fileSystem, sessionOptions, start, length, dynamicFilter, false); + // Build native (RFC-103) delta-log parquet page sources on demand, projected on the file-group + // reader's requiredSchema with predicate pushdown OFF so every log record is read and merged. + HudiTrinoReaderContext.LogFileParquetPageSourceFactory logPageSourceFactory = + (logPath, logStart, logLength, projection) -> createPageSource( + session, + projection, + hudiSplit, + fileSystem.newInputFile(Location.of(logPath)), + logPath, + logStart, + logLength, + OptionalLong.empty(), + dataSourceStats, + sessionOptions, + timeZone, + DynamicFilter.EMPTY, + false); + HudiTrinoReaderContext readerContext = new HudiTrinoReaderContext( + metaClient.getStorageConf(), + metaClient.getTableConfig(), + dataPageSource, + readColumnHandles, + prefilledColumnValues, + logPageSourceFactory); + + Schema requestedSchema = constructSchema(dataSchema.toAvroSchema(), readColumnHandles.stream().map(HiveColumnHandle::getName).toList()); + FileSlice fileSlice = convertToFileSlice(hudiSplit, hudiTableHandle.getBasePath()); + HoodieFileGroupReader fileGroupReader = + HoodieFileGroupReader.builder() + .withReaderContext(readerContext) + .withHoodieTableMetaClient(metaClient) + .withBaseFileOption(fileSlice.getBaseFile()) + .withLogFiles(fileSlice.getLogFiles()) + .withPartitionPath(fileSlice.getPartitionPath()) + .withDataSchema(dataSchema) + .withRequestedSchema(HoodieSchema.fromAvroSchema(requestedSchema)) + .withLatestCommitTime(hudiTableHandle.getLatestCommitTime()) + .withProps(readerProps) + .withShouldUseRecordPosition(false) + .withStart(start) + .withLength(length) + .build(); + return new HudiPageSource( + dataPageSource, + fileGroupReader, + hiveColumnHandles, + prefilledColumnValues); + } + + private ConnectorPageSource createBaseFilePageSource( + ConnectorSession session, + List columns, + HudiSplit hudiSplit, + TrinoFileSystem fileSystem, + ParquetReaderOptions sessionOptions, + long start, + long length, + DynamicFilter dynamicFilter, + boolean enablePredicatePushDown) + { + HudiBaseFile baseFile = hudiSplit.getBaseFile().orElseThrow(); + return createPageSource( + session, + columns, + hudiSplit, + fileSystem.newInputFile(Location.of(baseFile.getPath()), baseFile.getFileSize()), + baseFile.getPath(), + start, + length, + OptionalLong.of(baseFile.getFileSize()), + dataSourceStats, + sessionOptions, + timeZone, + dynamicFilter, + enablePredicatePushDown); + } + + /** + * Mirrors {@code FileGroupReaderSchemaHandler.generateRequiredSchema}'s full-schema decision for + * CUSTOM merge mode: resolves the merge mode and strategy id with the version-gated inference the + * file-group reader applies ({@link HudiUtil#resolveMergeModeAndStrategyId}) and asks the same + * resolved merger whether it is projection compatible. Only this decision is mirrored exactly; the + * mandatory-fields side ({@link HudiUtil#getMergeRequiredColumnHandles}) is a superset prediction, + * and the {@code HudiTrinoReaderContext.getFileRecordIterator} guard catches any residual drift. + */ + private static boolean requiresFullSchemaRead(HoodieTableConfig tableConfig, TypedProperties readerProps) + { + Pair mergeModeAndStrategyId = resolveMergeModeAndStrategyId(tableConfig); + String mergeImplClasses = readerProps.getString(RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY, + readerProps.getString(RECORD_MERGE_IMPL_CLASSES_DEPRECATED_WRITE_CONFIG_KEY, "")); + return usesNonProjectionCompatibleMerger(mergeModeAndStrategyId.getLeft(), mergeModeAndStrategyId.getRight(), mergeImplClasses); + } + + /** + * Builds the properties passed to the {@link HoodieFileGroupReader}, starting from the persisted table config + * and layering in any custom record merger implementation classes configured on the connector or session. + * The merger impl classes are a read/write config and are not persisted in {@code hoodie.properties}, so they + * must be supplied here for {@link HudiTrinoReaderContext#getRecordMerger} to resolve a CUSTOM record merger. + */ + private static TypedProperties buildReaderProperties(ConnectorSession session, HoodieTableMetaClient metaClient) + { + TypedProperties props = new TypedProperties(); + TypedProperties.putAll(props, metaClient.getTableConfig().getProps()); + List recordMergerImpls = getRecordMergerImpls(session); + if (!recordMergerImpls.isEmpty()) { + props.setProperty(RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY, String.join(",", recordMergerImpls)); + } + return props; + } + + static ConnectorPageSource createPageSource( + ConnectorSession session, + List columns, + HudiSplit hudiSplit, + TrinoInputFile inputFile, + String path, + long start, + long length, + OptionalLong estimatedFileSize, + FileFormatDataSourceStats dataSourceStats, + ParquetReaderOptions options, + DateTimeZone timeZone, + DynamicFilter dynamicFilter, + boolean enablePredicatePushDown) + { + ParquetDataSource dataSource = null; + boolean useColumnNames = shouldUseParquetColumnNames(session); + try { + AggregatedMemoryContext memoryContext = newSimpleAggregatedMemoryContext(); + dataSource = createDataSource(inputFile, estimatedFileSize, options, memoryContext, dataSourceStats); + ParquetMetadata parquetMetadata = MetadataReader.readFooter(dataSource, Optional.empty()); + FileMetadata fileMetaData = parquetMetadata.getFileMetaData(); + MessageType fileSchema = fileMetaData.getSchema(); + + // When not using columnNames, physical indexes are used and there could be cases when the physical index in HiveColumnHandle is different from the fileSchema of the + // parquet files. This could happen when schema evolution happened. In such a case, we will need to remap the column indices in the HiveColumnHandles. + // The projection and the predicate resolve the same names against the same file, so the name-to-position + // map is built once per split and shared: one lookup table means the two can never disagree about which + // physical column a name denotes, and a wide table pays for the lower-casing pass only once. + // HiveColumnHandle names are in lower case, case-insensitive + Optional> physicalIndexMap = Optional.empty(); + if (!useColumnNames) { + Map indexMap = buildPhysicalIndexMap(fileSchema, false); + columns = remapColumnIndicesToPhysical(fileSchema, columns, indexMap, false); + physicalIndexMap = Optional.of(indexMap); + } + + Optional message = getParquetMessageType(columns, useColumnNames, fileSchema); + + MessageType requestedSchema = message.orElse(new MessageType(fileSchema.getName(), ImmutableList.of())); + MessageColumnIO messageColumn = getColumnIO(fileSchema, requestedSchema); + + Map, ColumnDescriptor> descriptorsByPath = getDescriptors(fileSchema, requestedSchema); + + TupleDomain parquetTupleDomain = options.isIgnoreStatistics() || !enablePredicatePushDown + ? TupleDomain.all() + : getParquetTupleDomain(descriptorsByPath, getPushdownPredicate(hudiSplit, dynamicFilter, physicalIndexMap), fileSchema, useColumnNames); + + TupleDomainParquetPredicate parquetPredicate = buildPredicate(requestedSchema, parquetTupleDomain, descriptorsByPath, timeZone); + + List rowGroups = getFilteredRowGroups( + start, + length, + dataSource, + parquetMetadata, + ImmutableList.of(parquetTupleDomain), + ImmutableList.of(parquetPredicate), + descriptorsByPath, + timeZone, + DOMAIN_COMPACTION_THRESHOLD, + options); + + ParquetDataSourceId dataSourceId = dataSource.getId(); + ParquetDataSource finalDataSource = dataSource; + ParquetReaderProvider parquetReaderProvider = (fields, appendRowNumberColumn) -> new ParquetReader( + Optional.ofNullable(fileMetaData.getCreatedBy()), + fields, + appendRowNumberColumn, + rowGroups, + finalDataSource, + timeZone, + memoryContext, + options, + exception -> handleException(dataSourceId, exception), + Optional.of(parquetPredicate), + Optional.empty(), + parquetMetadata.getDecryptionContext()); + return createParquetPageSource(columns, fileSchema, messageColumn, useColumnNames, parquetReaderProvider); + } + catch (IOException | RuntimeException e) { + try { + if (dataSource != null) { + dataSource.close(); + } + } + catch (IOException _) { + } + if (e instanceof TrinoException) { + throw (TrinoException) e; + } + if (e instanceof ParquetCorruptionException) { + throw new TrinoException(HUDI_BAD_DATA, e); + } + String message = "Error opening Hudi split %s (offset=%s, length=%s): %s".formatted(path, start, length, e.getMessage()); + throw new TrinoException(HUDI_CANNOT_OPEN_SPLIT, message, e); + } + } + + private static TrinoException handleException(ParquetDataSourceId dataSourceId, Exception exception) + { + if (exception instanceof TrinoException) { + return (TrinoException) exception; + } + if (exception instanceof ParquetCorruptionException) { + return new TrinoException(HUDI_BAD_DATA, exception); + } + return new TrinoException(HUDI_CURSOR_ERROR, format("Failed to read Parquet file: %s", dataSourceId), exception); + } + + /** + * Creates a new list of ColumnHandles where the index associated with each handle corresponds to its physical position within the provided fileSchema (MessageType). + * This is necessary when a downstream component relies on the handle's index for physical data access, and the logical schema order (potentially reflected in the + * original handles) differs from the physical file layout. + *

    + * A requested column the file schema does not carry is mapped one past the last physical field instead of failing: base files written before the column was added + * legitimately lack it (schema evolution), and so do old records for a merge-required column. {@code ParquetPageSourceFactory} reads an index-based column through + * {@code getBaseColumnParquetType}, which reports any index at or beyond the file's field count as absent, so the parquet reader skips it and the page source emits + * a null block -- the same result the name-based path ({@code hudi.parquet.use-column-names=true}) produces. + * + * @param fileSchema The MessageType representing the physical schema of the Parquet file. + * @param requestedColumns The original list of Trino ColumnHandles as received from the engine. + * @param caseSensitive Whether the lookup between Trino column names (from handles) and Parquet field names (from fileSchema) should be case-sensitive. + * @return A new list of HiveColumnHandle, preserving the original order, but with each handle containing the correct physical index relative to fileSchema. + */ + @VisibleForTesting + public static List remapColumnIndicesToPhysical( + MessageType fileSchema, + List requestedColumns, + boolean caseSensitive) + { + // Create a map from column name to its physical index in the fileSchema. + return remapColumnIndicesToPhysical(fileSchema, requestedColumns, buildPhysicalIndexMap(fileSchema, caseSensitive), caseSensitive); + } + + /** + * {@link #remapColumnIndicesToPhysical(MessageType, List, boolean)} against a {@code physicalIndexMap} the caller + * already built, so a split that remaps both its projection and its predicate builds the map once. + * {@code caseSensitive} must be the one the map was built with, or the lookups miss. + */ + private static List remapColumnIndicesToPhysical( + MessageType fileSchema, + List requestedColumns, + Map physicalIndexMap, + boolean caseSensitive) + { + // Iterate through the columns requested by Trino IN ORDER. + List remappedHandles = new ArrayList<>(requestedColumns.size()); + for (HiveColumnHandle originalHandle : requestedColumns) { + // Find the physical index from the file schema map constructed from fileSchema. A column the file + // does not carry keeps an index one past the last field, which the parquet reader null-fills. + Integer physicalIndex = physicalIndexMap.get(normalizeColumnName(originalHandle.getBaseColumnName(), caseSensitive)); + remappedHandles.add(withPhysicalIndex(originalHandle, physicalIndex == null ? fileSchema.getFieldCount() : physicalIndex)); + } + + return remappedHandles; + } + + /** + * Rebuilds a predicate's column handles on physical file ordinals, the predicate-side counterpart of + * {@link #remapColumnIndicesToPhysical}. With {@code hudi.parquet.use-column-names=false}, + * {@code ParquetPageSourceFactory.getParquetTupleDomain} resolves a predicate column positionally, as + * {@code fileSchema.getType(handle.getBaseHiveColumnIndex())}, but the handles reaching it carry METASTORE + * ordinals: a metastore that omits the Hudi meta fields (hive sync with {@code omit_metadata_fields=true}) + * shifts every data column, and so does reordering or dropping one. Left unremapped, the domain attaches to + * whichever column happens to sit at the stale ordinal and row groups are pruned on that column's statistics, + * silently dropping rows. + *

    + * Resolution is by name, so the predicate ends up bound to exactly the column the projection reads - which is + * the property that matters, since the two are compared against each other. It is not a defence against a + * column being dropped and re-added under full schema evolution: name-based binding will match the new column + * to the old one, exactly as the projection remap and the whole {@code use-column-names=true} mode already do. + *

    + * A column the file does not carry is dropped from the predicate rather than mapped to the + * {@link #remapColumnIndicesToPhysical} sentinel, which every absent column would share. Dropping loses row + * group pruning but never a row: the static half of the predicate is handed back to the engine in full as + * {@code HudiMetadata.applyFilter}'s remaining filter, and the dynamic half is by construction redundant with + * the join above the scan. It is also what already happens today for a predicate column the query does not + * read, since {@code descriptorsByPath} is derived from the projection and + * {@code getParquetTupleDomain} skips any column it cannot resolve. + * + * @param fileSchema The MessageType representing the physical schema of the Parquet file. + * @param predicate The predicate to push down, keyed on handles carrying metastore ordinals. + * @param caseSensitive Whether the lookup between Trino column names (from handles) and Parquet field names (from fileSchema) should be case-sensitive. + * @return The same domains, keyed on handles carrying physical ordinals, minus the columns the file lacks. + */ + @VisibleForTesting + public static TupleDomain remapPredicateColumnIndicesToPhysical( + MessageType fileSchema, + TupleDomain predicate, + boolean caseSensitive) + { + return remapPredicateColumnIndicesToPhysical(predicate, buildPhysicalIndexMap(fileSchema, caseSensitive), caseSensitive); + } + + /** + * {@link #remapPredicateColumnIndicesToPhysical(MessageType, TupleDomain, boolean)} against a + * {@code physicalIndexMap} the caller already built, so a split that remaps both its projection and its predicate + * builds the map once. {@code caseSensitive} must be the one the map was built with, or the lookups miss. + */ + private static TupleDomain remapPredicateColumnIndicesToPhysical( + TupleDomain predicate, + Map physicalIndexMap, + boolean caseSensitive) + { + if (predicate.isAll() || predicate.isNone()) { + return predicate; + } + + Set>> pushedFields = new HashSet<>(); + Map remappedDomains = new LinkedHashMap<>(); + for (Map.Entry entry : predicate.getDomains().orElseThrow().entrySet()) { + Integer physicalIndex = physicalIndexMap.get(normalizeColumnName(entry.getKey().getBaseColumnName(), caseSensitive)); + if (physicalIndex == null) { + continue; + } + // Deduplicate on what getParquetTupleDomain resolves the handle to rather than on the handle itself: two + // handles whose names differ only by case resolve to one file column while staying unequal to each other, + // and pushing both down would hand it the same ColumnDescriptor twice, which it rejects by failing the + // split. The base column alone is too coarse a key, because a dereference handle carries its subfield + // path into the descriptor, so the projection is part of the key and two projections of one base column + // both survive. Neither collision is reachable today - the case one needs a metastore holding two such + // columns, which Hive's name normalisation rules out, and trino-parquet lower-cases every field name when + // it builds the MessageType from the footer anyway - but keeping only the first domain is a cheap + // guarantee that the read can never be made worse than pushing nothing down. + if (pushedFields.add(Map.entry(physicalIndex, entry.getKey().getHiveColumnProjectionInfo()))) { + remappedDomains.put(withPhysicalIndex(entry.getKey(), physicalIndex), entry.getValue()); + } + } + return TupleDomain.withColumnDomains(remappedDomains); + } + + /** + * Maps each of {@code fileSchema}'s top-level field names to its physical position. + */ + private static Map buildPhysicalIndexMap(MessageType fileSchema, boolean caseSensitive) + { + Map physicalIndexMap = new HashMap<>(); + List fileFields = fileSchema.getFields(); + for (int i = 0; i < fileFields.size(); i++) { + physicalIndexMap.put(normalizeColumnName(fileFields.get(i).getName(), caseSensitive), i); + } + return physicalIndexMap; + } + + private static String normalizeColumnName(String columnName, boolean caseSensitive) + { + return caseSensitive ? columnName : columnName.toLowerCase(Locale.ROOT); + } + + /** + * Copies {@code handle} with its base column index replaced by a physical one, every other attribute carried + * over unchanged. Note that the constructor's fourth argument is the BASE type: it differs from + * {@code getType()} only for a dereference handle, whose {@code getType()} is the projected subfield's type + * rather than the column's, and {@code createParquetPageSource} reads the base type throughout. + *

    + * Copying a dereference handle's projection across matters: {@code createParquetPageSource} branches on + * {@code isBaseColumn()} and dereferences through {@code getHiveColumnProjectionInfo}, and it reads the base + * column's stored {@code baseType} on the way. The connector never produces such a handle today, because + * {@code HudiMetadata} does not implement {@code applyProjection}. + */ + private static HiveColumnHandle withPhysicalIndex(HiveColumnHandle handle, int physicalIndex) + { + return new HiveColumnHandle( + handle.getBaseColumnName(), + physicalIndex, + handle.getBaseHiveType(), + handle.getBaseType(), + handle.getHiveColumnProjectionInfo(), + handle.getColumnType(), + handle.getComment()); + } + + /** + * Resolves the predicate handed to {@code ParquetPageSourceFactory.getParquetTupleDomain}. Only the + * positional mode needs the handles rebuilt; when columns are resolved by name the metastore ordinals are + * never read, which is exactly when {@code physicalIndexMap} is empty. Being handed the very map the projection + * was remapped with is what makes it structural, rather than a convention, that the two agree about which + * physical column a name denotes. + */ + private static TupleDomain getPushdownPredicate( + HudiSplit hudiSplit, + DynamicFilter dynamicFilter, + Optional> physicalIndexMap) + { + TupleDomain combinedPredicate = getCombinedPredicate(hudiSplit, dynamicFilter); + return physicalIndexMap + .map(indexMap -> remapPredicateColumnIndicesToPhysical(combinedPredicate, indexMap, false)) + .orElse(combinedPredicate); + } + + private static TupleDomain getCombinedPredicate(HudiSplit hudiSplit, DynamicFilter dynamicFilter) + { + // Combine static and dynamic predicates + TupleDomain staticPredicate = hudiSplit.getPredicate(); + TupleDomain dynamicPredicate = dynamicFilter.getCurrentPredicate() + .transformKeys(HiveColumnHandle.class::cast); + TupleDomain combinedPredicate = staticPredicate.intersect(dynamicPredicate); + + if (!combinedPredicate.isAll()) { + log.debug("Combined predicate for Parquet read (Split: %s): %s", hudiSplit, combinedPredicate); + } + return combinedPredicate; + } + + private static List getHiveColumns(List columns) + { + return columns.stream() + .map(HiveColumnHandle.class::cast) + .toList(); + } +} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPlugin.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPlugin.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPlugin.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPlugin.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPredicates.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPredicates.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiPredicates.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPredicates.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java similarity index 94% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java index e3645e0582c2f..8a47e3a8b03d8 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSessionProperties.java @@ -31,6 +31,7 @@ import static io.trino.plugin.base.session.PropertyMetadataUtil.dataSizeProperty; import static io.trino.plugin.base.session.PropertyMetadataUtil.durationProperty; import static io.trino.plugin.base.session.PropertyMetadataUtil.validateMaxDataSize; +import static io.trino.plugin.base.session.PropertyMetadataUtil.validateMinDataSize; import static io.trino.plugin.hive.parquet.ParquetReaderConfig.PARQUET_READER_MAX_SMALL_FILE_THRESHOLD; import static io.trino.spi.StandardErrorCode.INVALID_SESSION_PROPERTY; import static io.trino.spi.session.PropertyMetadata.booleanProperty; @@ -44,6 +45,7 @@ public class HudiSessionProperties implements SessionPropertiesProvider { private static final String COLUMNS_TO_HIDE = "columns_to_hide"; + static final String RECORD_MERGER_IMPLS = "record_merger_impls"; static final String TABLE_STATISTICS_ENABLED = "table_statistics_enabled"; static final String METADATA_TABLE_ENABLED = "metadata_enabled"; private static final String USE_PARQUET_COLUMN_NAMES = "use_parquet_column_names"; @@ -93,6 +95,18 @@ public HudiSessionProperties(HudiConfig hudiConfig, ParquetReaderConfig parquetR .map(name -> ((String) name).toLowerCase(ENGLISH)) .collect(toImmutableList()), value -> value), + new PropertyMetadata<>( + RECORD_MERGER_IMPLS, + "Fully qualified HoodieRecordMerger implementation class names used to resolve a custom record " + + "merger for Merge-On-Read tables whose record merge mode is CUSTOM", + new ArrayType(VARCHAR), + List.class, + hudiConfig.getRecordMergerImpls(), + false, + value -> ((Collection) value).stream() + .map(String.class::cast) + .collect(toImmutableList()), + value -> value), booleanProperty( TABLE_STATISTICS_ENABLED, "Expose table statistics", @@ -175,6 +189,7 @@ public HudiSessionProperties(HudiConfig hudiConfig, ParquetReaderConfig parquetR TARGET_SPLIT_SIZE, "The target split size", hudiConfig.getTargetSplitSize(), + value -> validateMinDataSize(TARGET_SPLIT_SIZE, value, DataSize.ofBytes(1)), false), integerProperty( MAX_SPLITS_PER_SECOND, @@ -265,6 +280,12 @@ public static List getColumnsToHide(ConnectorSession session) return (List) session.getProperty(COLUMNS_TO_HIDE, List.class); } + @SuppressWarnings("unchecked") + public static List getRecordMergerImpls(ConnectorSession session) + { + return (List) session.getProperty(RECORD_MERGER_IMPLS, List.class); + } + public static boolean isTableStatisticsEnabled(ConnectorSession session) { return session.getProperty(TABLE_STATISTICS_ENABLED, Boolean.class); diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplit.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplit.java similarity index 99% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplit.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplit.java index e2de10ca98a4d..32f1d32aa3f64 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplit.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplit.java @@ -80,7 +80,6 @@ public HudiSplit( this.cachingHostAddresses = requireNonNull(cachingHostAddresses, "cachingHostAddresses is null"); } - @Override public Map getSplitInfo() { return ImmutableMap.builder() diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java similarity index 93% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java index 79f03ccd07b27..8f774e3f5f156 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitManager.java @@ -17,7 +17,6 @@ import com.google.common.collect.ImmutableMap; import com.google.inject.Inject; import io.airlift.log.Logger; -import io.trino.filesystem.cache.CachingHostAddressProvider; import io.trino.metastore.HiveMetastore; import io.trino.metastore.Partition; import io.trino.metastore.StorageFormat; @@ -61,19 +60,16 @@ public class HudiSplitManager private final BiFunction metastoreProvider; private final ExecutorService executor; private final ScheduledExecutorService splitLoaderExecutorService; - private final CachingHostAddressProvider cachingHostAddressProvider; @Inject public HudiSplitManager( BiFunction metastoreProvider, @ForHudiSplitManager ExecutorService executor, - @ForHudiSplitSource ScheduledExecutorService splitLoaderExecutorService, - CachingHostAddressProvider cachingHostAddressProvider) + @ForHudiSplitSource ScheduledExecutorService splitLoaderExecutorService) { this.metastoreProvider = requireNonNull(metastoreProvider, "metastoreProvider is null"); this.executor = requireNonNull(executor, "executor is null"); this.splitLoaderExecutorService = requireNonNull(splitLoaderExecutorService, "splitLoaderExecutorService is null"); - this.cachingHostAddressProvider = requireNonNull(cachingHostAddressProvider, "cachingHostAddressProvider is null"); } @Override @@ -103,8 +99,7 @@ public ConnectorSplitSource getSplits( getMaxOutstandingSplits(session), lazyAllPartitions, dynamicFilter, - getDynamicFilteringWaitTimeout(session), - cachingHostAddressProvider); + getDynamicFilteringWaitTimeout(session)); return new ClassLoaderSafeConnectorSplitSource(splitSource, HudiSplitManager.class.getClassLoader()); } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java similarity index 95% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java index 278ca2e463c7e..7da9621a720b4 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiSplitSource.java @@ -19,7 +19,6 @@ import io.airlift.log.Logger; import io.airlift.units.DataSize; import io.airlift.units.Duration; -import io.trino.filesystem.cache.CachingHostAddressProvider; import io.trino.metastore.Partition; import io.trino.plugin.hive.HiveColumnHandle; import io.trino.plugin.hive.HivePartitionKey; @@ -45,6 +44,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.HoodieTimer; import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.metadata.NativeTableMetadataFactory; import org.apache.hudi.util.Lazy; import java.util.HashMap; @@ -92,8 +92,7 @@ public HudiSplitSource( int maxOutstandingSplits, Lazy> lazyPartitions, DynamicFilter dynamicFilter, - Duration dynamicFilteringWaitTimeoutMillis, - CachingHostAddressProvider cachingHostAddressProvider) + Duration dynamicFilteringWaitTimeoutMillis) { boolean enableMetadataTable = isHudiMetadataTableEnabled(session); Lazy lazyTableMetadata = Lazy.lazily(() -> { @@ -104,9 +103,11 @@ public HudiSplitSource( HoodieTableMetaClient metaClient = tableHandle.getMetaClient(); HoodieEngineContext engineContext = new HoodieLocalEngineContext(metaClient.getStorage().getConf()); - HoodieTableMetadata tableMetadata = HoodieTableMetadata.create( - engineContext, - tableHandle.getMetaClient().getStorage(), metadataConfig, metaClient.getBasePath().toString(), true); + // Defer to the native factory, which creates a HoodieBackedTableMetadata when the + // metadata table is enabled and initialized, and falls back to FileSystemBackedTableMetadata + // otherwise. + HoodieTableMetadata tableMetadata = NativeTableMetadataFactory.getInstance().create( + engineContext, metaClient.getStorage(), metadataConfig, metaClient.getBasePath().toString(), true); log.info("Loaded table metadata for table: %s in %s ms", tableHandle.getSchemaTableName(), timer.endTimer()); return tableMetadata; }); @@ -128,7 +129,6 @@ public HudiSplitSource( lazyPartitions, enableMetadataTable, lazyTableMetadata, - cachingHostAddressProvider, throwable -> { trinoException.compareAndSet(null, new TrinoException(HUDI_CANNOT_OPEN_SPLIT, "Failed to generate splits for " + tableHandle.getSchemaTableName(), throwable)); diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java similarity index 78% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java index 75ae962286a41..4e2649f3ed5cf 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableHandle.java @@ -24,8 +24,8 @@ import io.trino.spi.connector.ConnectorTableHandle; import io.trino.spi.connector.SchemaTableName; import io.trino.spi.predicate.TupleDomain; -import org.apache.avro.Schema; import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.util.StringUtils; @@ -33,6 +33,7 @@ import java.util.List; import java.util.Optional; +import java.util.OptionalLong; import java.util.Set; import java.util.function.Supplier; @@ -49,11 +50,13 @@ public class HudiTableHandle private final String basePath; private final HoodieTableType tableType; private final List partitionColumns; + private final Lazy> lazyMergeRequiredColumns; // Used only for validation when config property hudi.query-partition-filter-required is enabled private final Set constraintColumns; private final TupleDomain partitionPredicates; private final TupleDomain regularPredicates; - private final Optional> hudiTableSchema; + private final OptionalLong limit; + private final Optional> hudiTableSchema; // Coordinator-only private final transient Optional

    table; private final transient Optional> lazyMetaClient; @@ -66,13 +69,15 @@ public HudiTableHandle( @JsonProperty("basePath") String basePath, @JsonProperty("tableType") HoodieTableType tableType, @JsonProperty("partitionColumns") List partitionColumns, + @JsonProperty("mergeRequiredColumns") List mergeRequiredColumns, @JsonProperty("partitionPredicates") TupleDomain partitionPredicates, @JsonProperty("regularPredicates") TupleDomain regularPredicates, + @JsonProperty("limit") OptionalLong limit, @JsonProperty("tableSchemaStr") String tableSchemaStr, @JsonProperty("latestCommitTime") String latestCommitTime) { - this(Optional.empty(), Optional.empty(), schemaName, tableName, basePath, tableType, partitionColumns, ImmutableSet.of(), - partitionPredicates, regularPredicates, buildTableSchema(tableSchemaStr), () -> latestCommitTime); + this(Optional.empty(), Optional.empty(), schemaName, tableName, basePath, tableType, partitionColumns, Lazy.eagerly(mergeRequiredColumns), ImmutableSet.of(), + partitionPredicates, regularPredicates, limit, buildTableSchema(tableSchemaStr), () -> latestCommitTime); } public HudiTableHandle( @@ -83,10 +88,12 @@ public HudiTableHandle( String basePath, HoodieTableType tableType, List partitionColumns, + Lazy> lazyMergeRequiredColumns, Set constraintColumns, TupleDomain partitionPredicates, TupleDomain regularPredicates, - Optional> hudiTableSchema) + OptionalLong limit, + Optional> hudiTableSchema) { this( Optional.of(table), @@ -96,9 +103,11 @@ public HudiTableHandle( basePath, tableType, partitionColumns, + lazyMergeRequiredColumns, constraintColumns, partitionPredicates, regularPredicates, + limit, hudiTableSchema, () -> lazyMetaClient .get() @@ -120,10 +129,12 @@ public HudiTableHandle( String basePath, HoodieTableType tableType, List partitionColumns, + Lazy> lazyMergeRequiredColumns, Set constraintColumns, TupleDomain partitionPredicates, TupleDomain regularPredicates, - Optional> hudiTableSchema, + OptionalLong limit, + Optional> hudiTableSchema, Supplier latestCommitTimeSupplier) { this.table = requireNonNull(table, "table is null"); @@ -133,27 +144,29 @@ public HudiTableHandle( this.basePath = requireNonNull(basePath, "basePath is null"); this.tableType = requireNonNull(tableType, "tableType is null"); this.partitionColumns = requireNonNull(partitionColumns, "partitionColumns is null"); + this.lazyMergeRequiredColumns = requireNonNull(lazyMergeRequiredColumns, "lazyMergeRequiredColumns is null"); this.constraintColumns = requireNonNull(constraintColumns, "constraintColumns is null"); this.partitionPredicates = requireNonNull(partitionPredicates, "partitionPredicates is null"); this.regularPredicates = requireNonNull(regularPredicates, "regularPredicates is null"); + this.limit = requireNonNull(limit, "limit is null"); this.hudiTableSchema = requireNonNull(hudiTableSchema, "hudiTableSchema is null"); this.lazyLatestCommitTime = Lazy.lazily(latestCommitTimeSupplier); } /** - * Builds a lazily-parsed Avro schema from the given schema string. + * Builds a lazily-parsed schema from the given Avro schema JSON string. *

    * Returns {@code Optional.empty()} if the input string is null/empty * or if parsing the schema fails. */ - private static Optional> buildTableSchema(String tableSchemaStr) + private static Optional> buildTableSchema(String tableSchemaStr) { if (StringUtils.isNullOrEmpty(tableSchemaStr)) { return Optional.empty(); } try { - Lazy lazySchema = Lazy.lazily(() -> new Schema.Parser().parse(tableSchemaStr)); + Lazy lazySchema = Lazy.lazily(() -> HoodieSchema.parse(tableSchemaStr)); return Optional.of(lazySchema); } catch (Exception e) { @@ -225,12 +238,12 @@ public String getTableSchemaStr() { return hudiTableSchema .map(Lazy::get) - .map(Schema::toString) + .map(HoodieSchema::toString) .orElse(""); } @JsonIgnore - public Schema getTableSchema() + public HoodieSchema getTableSchema() { return hudiTableSchema.map(Lazy::get).orElse(null); } @@ -248,6 +261,23 @@ public TupleDomain getRegularPredicates() return regularPredicates; } + @JsonProperty + public OptionalLong getLimit() + { + return limit; + } + + /** + * Columns that must be read for the file group reader to merge correctly: the ordering columns, plus any + * mandatory fields declared by a configured custom record merger. See + * {@link HudiUtil#getMergeRequiredColumnHandles}. + */ + @JsonProperty + public List getMergeRequiredColumns() + { + return lazyMergeRequiredColumns.get(); + } + public SchemaTableName getSchemaTableName() { return schemaTableName(schemaName, tableName); @@ -266,9 +296,30 @@ HudiTableHandle applyPredicates( basePath, tableType, partitionColumns, + lazyMergeRequiredColumns, constraintColumns, partitionPredicates.intersect(partitionTupleDomain), regularPredicates.intersect(regularTupleDomain), + limit, + hudiTableSchema, + this::getLatestCommitTime); + } + + HudiTableHandle withLimit(long newLimit) + { + return new HudiTableHandle( + table, + lazyMetaClient, + schemaName, + tableName, + basePath, + tableType, + partitionColumns, + lazyMergeRequiredColumns, + constraintColumns, + partitionPredicates, + regularPredicates, + OptionalLong.of(newLimit), hudiTableSchema, this::getLatestCommitTime); } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableInfo.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableInfo.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableInfo.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableInfo.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableName.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableName.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableName.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableName.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableProperties.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableProperties.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTableProperties.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTableProperties.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTransactionManager.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTransactionManager.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/HudiTransactionManager.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/HudiTransactionManager.java diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java new file mode 100644 index 0000000000000..d94beaa2d8be3 --- /dev/null +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java @@ -0,0 +1,767 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.cache.Cache; +import com.google.common.cache.Weigher; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.airlift.log.Logger; +import io.trino.cache.EvictableCacheBuilder; +import io.trino.filesystem.FileIterator; +import io.trino.filesystem.Location; +import io.trino.filesystem.TrinoFileSystem; +import io.trino.hive.formats.avro.AvroTypeException; +import io.trino.hive.formats.avro.NativeLogicalTypesAvroTypeBlockHandler; +import io.trino.metastore.Column; +import io.trino.metastore.HivePartition; +import io.trino.metastore.HiveType; +import io.trino.metastore.Table; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HivePartitionKey; +import io.trino.plugin.hive.HiveTimestampPrecision; +import io.trino.plugin.hive.avro.AvroHiveFileUtils; +import io.trino.plugin.hive.util.HiveTypeTranslator; +import io.trino.plugin.hudi.storage.HudiTrinoStorage; +import io.trino.plugin.hudi.storage.TrinoStorageConfiguration; +import io.trino.spi.TrinoException; +import io.trino.spi.connector.ColumnHandle; +import io.trino.spi.connector.SchemaTableName; +import io.trino.spi.predicate.Domain; +import io.trino.spi.predicate.NullableValue; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.type.Type; +import io.trino.spi.type.TypeManager; +import io.trino.spi.type.VarcharType; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.data.HoodiePairData; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.FileSlice; +import org.apache.hudi.common.model.HoodieBaseFile; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieFileGroupId; +import org.apache.hudi.common.model.HoodieLogFile; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.common.table.view.HoodieTableFileSystemView; +import org.apache.hudi.common.util.CollectionUtils; +import org.apache.hudi.common.util.HoodieRecordUtils; +import org.apache.hudi.common.util.HoodieTimer; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.common.util.collection.Triple; +import org.apache.hudi.exception.TableNotFoundException; +import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.util.Lazy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static io.airlift.slice.SizeOf.estimatedSizeOf; +import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR; +import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn; +import static io.trino.plugin.hive.HiveErrorCode.HIVE_INVALID_METADATA; +import static io.trino.plugin.hive.util.HiveTypeUtil.getType; +import static io.trino.plugin.hive.util.HiveTypeUtil.typeSupported; +import static io.trino.plugin.hive.util.HiveUtil.checkCondition; +import static io.trino.plugin.hive.util.HiveUtil.parsePartitionValue; +import static io.trino.plugin.hive.util.SerdeConstants.LIST_COLUMNS; +import static io.trino.plugin.hive.util.SerdeConstants.LIST_COLUMN_TYPES; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_FILESYSTEM_ERROR; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_META_CLIENT_ERROR; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_SCHEMA_ERROR; +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_UNSUPPORTED_FILE_FORMAT; +import static java.lang.Math.toIntExact; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_DEPRECATED_WRITE_CONFIG_KEY; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER; +import static org.apache.hudi.common.model.HoodieRecord.HOODIE_IS_DELETED_FIELD; +import static org.apache.hudi.common.model.HoodieRecord.OPERATION_METADATA_FIELD; +import static org.apache.hudi.common.model.HoodieRecord.PARTITION_PATH_METADATA_FIELD; +import static org.apache.hudi.common.model.HoodieRecord.RECORD_KEY_METADATA_FIELD; + +public final class HudiUtil +{ + // Minimal meta-column subset the file-group reader/merger requires, distinct from the upstream + // 5-field HoodieRecord.HOODIE_META_COLUMNS (commit time, seqno, key, partition path, file name). + public static final List HUDI_REQUIRED_META_COLUMNS = + CollectionUtils.createImmutableList(RECORD_KEY_METADATA_FIELD, PARTITION_PATH_METADATA_FIELD); + + private static final Logger log = Logger.get(HudiUtil.class); + // Maps Avro schemas (incl. logical types) to Trino types for toColumnHandle. Stateless, so shared. + private static final NativeLogicalTypesAvroTypeBlockHandler AVRO_TYPE_HANDLER = new NativeLogicalTypesAvroTypeBlockHandler(); + private static final Cache> SCHEMA_FIELD_CACHE = + EvictableCacheBuilder.newBuilder() + .maximumWeight(10L * 1000L * 1024L) // 10MB + .weigher((Weigher>) (schema, fieldMap) -> { + // approximate size estimation of schema size + long schemaSize = estimatedSizeOf(schema.toString()); + + long fieldsSize = fieldMap.entrySet().stream() + .mapToLong(e -> estimatedSizeOf(e.getKey()) + estimatedSizeOf(e.getValue().name())) + .sum(); + + return toIntExact(schemaSize + fieldsSize); + }) + .expireAfterWrite(5, TimeUnit.MINUTES) + .shareNothingWhenDisabled() + .build(); + + private HudiUtil() {} + + public static HoodieFileFormat getHudiFileFormat(String path) + { + String extension = getFileExtension(path); + if (extension.equals(HoodieFileFormat.PARQUET.getFileExtension())) { + return HoodieFileFormat.PARQUET; + } + if (extension.equals(HoodieFileFormat.HOODIE_LOG.getFileExtension())) { + return HoodieFileFormat.HOODIE_LOG; + } + if (extension.equals(HoodieFileFormat.ORC.getFileExtension())) { + return HoodieFileFormat.ORC; + } + if (extension.equals(HoodieFileFormat.HFILE.getFileExtension())) { + return HoodieFileFormat.HFILE; + } + throw new TrinoException(HUDI_UNSUPPORTED_FILE_FORMAT, "Hoodie InputFormat not implemented for base file of type " + extension); + } + + private static String getFileExtension(String fullName) + { + String fileName = Location.of(fullName).fileName(); + int dotIndex = fileName.lastIndexOf('.'); + return dotIndex == -1 ? "" : fileName.substring(dotIndex); + } + + public static boolean hudiMetadataExists(TrinoFileSystem trinoFileSystem, Location baseLocation) + { + try { + Location metaLocation = baseLocation.appendPath(HoodieTableMetaClient.METAFOLDER_NAME); + FileIterator iterator = trinoFileSystem.listFiles(metaLocation); + // If there is at least one file in the .hoodie directory, it's a valid Hudi table + return iterator.hasNext(); + } + catch (IOException e) { + throw new TrinoException(HUDI_FILESYSTEM_ERROR, "Failed to check for Hudi table at location: " + baseLocation, e); + } + } + + public static boolean partitionMatchesPredicates( + SchemaTableName tableName, + String hivePartitionName, + List partitionColumnHandles, + List partitionValues, + TupleDomain constraintSummary) + { + HivePartition partition = parsePartition( + tableName, hivePartitionName, partitionColumnHandles, partitionValues); + + return partitionMatches(partitionColumnHandles, constraintSummary, partition); + } + + /** + * Copied from {@link io.trino.plugin.hive.HivePartitionManager#parsePartition} + * to keep partition parsing logic self-contained within {@code trino-hudi}. + */ + private static HivePartition parsePartition( + SchemaTableName tableName, + String partitionName, + List partitionColumns, + List partitionValues) + { + ImmutableMap.Builder builder = ImmutableMap.builderWithExpectedSize(partitionColumns.size()); + for (int i = 0; i < partitionColumns.size(); i++) { + HiveColumnHandle column = partitionColumns.get(i); + NullableValue parsedValue = parsePartitionValue(partitionName, partitionValues.get(i), column.getType()); + builder.put(column, parsedValue); + } + Map values = builder.buildOrThrow(); + return new HivePartition(tableName, partitionName, values); + } + + public static boolean partitionMatches(List partitionColumns, TupleDomain constraintSummary, HivePartition partition) + { + if (constraintSummary.isNone()) { + return false; + } + Map domains = constraintSummary.getDomains().orElseGet(ImmutableMap::of); + for (HiveColumnHandle column : partitionColumns) { + NullableValue value = partition.getKeys().get(column); + Domain allowedDomain = domains.get(column); + if (allowedDomain != null && !allowedDomain.includesNullableValue(value.getValue())) { + return false; + } + } + return true; + } + + public static List buildPartitionKeys(List keys, List values) + { + checkCondition(keys.size() == values.size(), HIVE_INVALID_METADATA, + "Expected %s partition key values, but got %s. Keys: %s, Values: %s.", + keys.size(), values.size(), keys, values); + ImmutableList.Builder partitionKeys = ImmutableList.builder(); + for (int i = 0; i < keys.size(); i++) { + String name = keys.get(i).getName(); + String value = values.get(i); + partitionKeys.add(new HivePartitionKey(name, value)); + } + return partitionKeys.build(); + } + + public static HoodieTableMetaClient buildTableMetaClient( + TrinoFileSystem fileSystem, + String tableName, + String basePath) + { + try { + return HoodieTableMetaClient.builder() + .setStorage(new HudiTrinoStorage(fileSystem, new TrinoStorageConfiguration())) + .setBasePath(basePath) + .build(); + } + catch (TableNotFoundException e) { + throw new TrinoException(HUDI_BAD_DATA, + "Location of table %s does not contain Hudi table metadata: %s".formatted(tableName, basePath)); + } + catch (Throwable e) { + throw new TrinoException(HUDI_META_CLIENT_ERROR, + "Unable to load Hudi meta client for table %s (%s)".formatted(tableName, basePath)); + } + } + + public static Schema constructSchema(List columnNames, List columnTypes) + { + // Convert lists into the format expected by the utility class + String columnNamesString = String.join(",", columnNames); + String columnTypesString = columnTypes.stream() + .map(HiveType::getHiveTypeName) + .map(Object::toString) + .collect(Collectors.joining(":")); + + // Create the properties map + Map properties = new HashMap<>(); + properties.put(LIST_COLUMNS, columnNamesString); + properties.put(LIST_COLUMN_TYPES, columnTypesString); + + // Call the public static method to build the schema + try { + // Pass null for the file system as we are not reading from a URL + return AvroHiveFileUtils.determineSchemaOrThrowException(null, properties); + } + catch (IOException e) { + // The IOException is declared on the method, but this path shouldn't throw it + throw new UncheckedIOException("Failed to construct Avro schema", e); + } + } + + public static Schema constructSchema(Schema dataSchema, List columnNames) + { + SchemaBuilder.RecordBuilder schemaBuilder = SchemaBuilder.record("baseRecord"); + SchemaBuilder.FieldAssembler fieldBuilder = schemaBuilder.fields(); + for (String columnName : columnNames) { + Schema.Field field = getFieldFromSchema(columnName, dataSchema); + Schema originalFieldSchema = field.schema(); + + Schema typeForNewField; + + // Check if the original field schema is already nullable (i.e., a UNION containing NULL) + if (originalFieldSchema.isNullable()) { + typeForNewField = originalFieldSchema; + } + else { + typeForNewField = Schema.createUnion(Schema.create(Schema.Type.NULL), originalFieldSchema); + } + + fieldBuilder = fieldBuilder + .name(field.name()) + .type(typeForNewField) + .withDefault(null); + } + return fieldBuilder.endRecord(); + } + + private static Map buildFieldLookup(Schema schema) + { + return schema.getFields().stream() + .collect(Collectors.toMap( + f -> f.name().toLowerCase(Locale.ROOT), + f -> f)); + } + + /** + * Retrieves a field from the given Avro schema by column name. + *

    + * The lookup proceeds in two steps: + *

      + *
    • First, attempts an exact match on the column name.
    • + *
    • If not found, falls back to a case-insensitive match using a cached lookup table
    • + *
    + * + * @param columnName Column name to search for. + * @param schema Avro {@link Schema} in which to search. + * @return The matching {@link Schema.Field}, if found. + * @throws TrinoException if no field matches the given column name. + */ + public static Schema.Field getFieldFromSchema(String columnName, Schema schema) + { + Schema.Field field = schema.getField(columnName); + if (field != null) { + return field; + } + + try { + field = SCHEMA_FIELD_CACHE + .get(schema, () -> buildFieldLookup(schema)).get(columnName.toLowerCase(Locale.ROOT)); + if (field != null) { + return field; + } + } + catch (ExecutionException e) { + throw new TrinoException(HUDI_SCHEMA_ERROR, + "Failed to build field lookup for schema", e); + } + + throw new TrinoException(HUDI_SCHEMA_ERROR, + "Failed to get column " + columnName + " from table schema"); + } + + /** + * Builds a {@link HiveColumnHandle} for a table-schema field, typing it from the field's Avro schema. + * Used to resolve columns the file-group reader requires for merging but the connector projection does + * not carry (e.g. {@code _hoodie_commit_time} or a delete-marker column on a narrow query). + *

    + * The handle's {@code hiveColumnIndex} is a placeholder: page sources built from these handles resolve + * parquet columns by NAME (directly when {@code hudi.parquet.use-column-names=true}, otherwise + * {@link HudiPageSourceProvider#remapColumnIndicesToPhysical} rebuilds every index from the file + * schema by name), so the ordinal is never used to locate data. + *

    + * Avro types whose Trino mapping has no Hive counterpart (uuid, time-millis/micros) fail with + * NOT_SUPPORTED from {@link HiveTypeTranslator#toHiveType}; such columns are equally unreadable + * through the metastore schema, so this surfaces the same limitation with a clear error. + */ + public static HiveColumnHandle toColumnHandle(HoodieSchemaField field) + { + Type trinoType; + try { + trinoType = AVRO_TYPE_HANDLER.typeFor(field.schema().toAvroSchema()); + } + catch (AvroTypeException e) { + throw new TrinoException(HUDI_SCHEMA_ERROR, + "Failed to map Avro type of column " + field.name() + " to a Trino type", e); + } + return new HiveColumnHandle( + field.name(), + 0, + HiveTypeTranslator.toHiveType(trinoType), + trinoType, + Optional.empty(), + HiveColumnHandle.ColumnType.REGULAR, + Optional.empty()); + } + + /** + * Resolves the merge mode and merge strategy id the file-group reader will use for this table, + * applying hudi-common's version-gated inference: the merge MODE is inferred for any table below + * version 9 ({@code FileGroupReaderSchemaHandler.generateRequiredSchema}), while the STRATEGY ID + * the merger is resolved with is inferred only below version 8 + * ({@code HoodieReaderContext.initRecordMerger}). The asymmetry is deliberate and must mirror + * hudi-common exactly: pre-v9 tables may persist neither config (a 0.x table with a custom payload + * class infers CUSTOM mode with the payload-based strategy id), so a connector-side decision built + * on the raw configs would disagree with the file-group reader's. + */ + public static Pair resolveMergeModeAndStrategyId(HoodieTableConfig tableConfig) + { + RecordMergeMode mergeMode = tableConfig.getRecordMergeMode(); + String mergeStrategyId = tableConfig.getRecordMergeStrategyId(); + if (tableConfig.getTableVersion().lesserThan(HoodieTableVersion.NINE)) { + Triple inferred = HoodieTableConfig.inferMergingConfigsForPreV9Table( + tableConfig.getRecordMergeMode(), + tableConfig.getPayloadClass(), + tableConfig.getRecordMergeStrategyId(), + tableConfig.getOrderingFieldsStr().orElse(null), + tableConfig.getTableVersion()); + mergeMode = inferred.getLeft(); + if (tableConfig.getTableVersion().lesserThan(HoodieTableVersion.EIGHT)) { + mergeStrategyId = inferred.getRight(); + } + } + return Pair.of(mergeMode, mergeStrategyId); + } + + /** + * Returns whether reads of this table go through a CUSTOM record merger that is NOT projection + * compatible. For such mergers the file-group reader demands the FULL table schema as its required + * schema on any split with log files ({@code FileGroupReaderSchemaHandler.generateRequiredSchema}), + * so the connector must read every table column, not just the query projection. + *

    + * Callers must pass the merge mode and strategy id resolved by {@link #resolveMergeModeAndStrategyId} + * so the {@link HoodieRecordUtils#createValidRecordMerger} call here sees the same inputs as the + * file-group reader's own resolution. A CUSTOM mode without a strategy id returns false: no projection + * expansion is possible without a merger to ask, and {@code HudiTrinoReaderContext.getRecordMerger} + * rejects the read with an actionable error when the merger is actually needed -- hudi-common's + * {@link HoodieRecordUtils#createValidRecordMerger} would otherwise NPE on the null id. + *

    + * Note the payload-based strategy id resolves {@code HoodieAvroRecordMerger}, which keeps the + * interface default {@code isProjectionCompatible() == false} -- so EVERY custom-payload table takes + * the full-schema read path, not just tables with exotic custom mergers. That matches the file-group + * reader (and Spark); the extra I/O is inherent to payload-based merging. + */ + public static boolean usesNonProjectionCompatibleMerger(RecordMergeMode mergeMode, String mergeStrategyId, String mergeImplClasses) + { + if (mergeMode != RecordMergeMode.CUSTOM || StringUtils.isNullOrEmpty(mergeStrategyId)) { + return false; + } + Option merger = HoodieRecordUtils.createValidRecordMerger(EngineType.JAVA, mergeImplClasses, mergeStrategyId); + return merger.isPresent() && !merger.get().isProjectionCompatible(); + } + + /** + * Rejects a CUSTOM-mode read that has no merge strategy id to resolve a record merger with. The + * strategy id is only inferred below table version 8 ({@link #resolveMergeModeAndStrategyId}) and + * passed through unchanged from there up, so any table at version 8 or later without a persisted id + * reaches merger resolution with a null id, which + * {@link HoodieRecordUtils#createValidRecordMerger} dereferences straight away. + */ + public static void validateCustomMergeStrategyId(String mergeStrategyId) + { + if (StringUtils.isNullOrEmpty(mergeStrategyId)) { + String strategyIdKey = HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key(); + throw new TrinoException(HUDI_BAD_DATA, + ("Table resolved to %s merge mode but persists no `%s`, so no record merger can be resolved for merging log files. " + + "Tables at version 8 or later written without a persisted strategy id resolve this way; set `%s` on the table to the " + + "strategy id declared by one of the configured record merger implementations.") + .formatted(RecordMergeMode.CUSTOM, strategyIdKey, strategyIdKey)); + } + } + + /** + * Returns {@code projection} extended with a handle for every {@code dataSchema} field it does not + * already carry (matched case-insensitively), typed from the field's Avro schema via + * {@link #toColumnHandle}. Projection handles keep their order and instances; missing fields are + * appended in schema order. + */ + public static List appendMissingSchemaColumns(HoodieSchema dataSchema, List projection) + { + Set existingColumns = projection.stream() + .map(handle -> handle.getName().toLowerCase(Locale.ROOT)) + .collect(Collectors.toCollection(HashSet::new)); + + List columns = new ArrayList<>(projection); + for (HoodieSchemaField field : dataSchema.getFields()) { + if (existingColumns.add(field.name().toLowerCase(Locale.ROOT))) { + columns.add(toColumnHandle(field)); + } + } + return columns; + } + + public static List prependHudiMetaAndMergeRequiredColumns(HudiTableHandle tableHandle, List dataColumns) + { + Set existingColumns = dataColumns.stream() + .map(HiveColumnHandle::getName) + .collect(Collectors.toCollection(HashSet::new)); + + List columns = new ArrayList<>(); + + // Add missing Hudi meta columns first + for (int i = 0; i < HUDI_REQUIRED_META_COLUMNS.size(); i++) { + String metaColumn = HUDI_REQUIRED_META_COLUMNS.get(i); + if (existingColumns.add(metaColumn)) { // add() returns false if already present + columns.add(new HiveColumnHandle( + metaColumn, + i, + HiveType.HIVE_STRING, + VarcharType.VARCHAR, + Optional.empty(), + HiveColumnHandle.ColumnType.REGULAR, + Optional.empty())); + } + } + + // Add missing merge-required columns next (ordering columns, plus a custom merger's mandatory fields) + tableHandle.getMergeRequiredColumns().stream() + .filter(col -> existingColumns.add(col.getName())) + .forEach(columns::add); + + // Add all the original data columns after the new meta columns + columns.addAll(dataColumns); + + return columns; + } + + public static FileSlice convertToFileSlice(HudiSplit split, String basePath) + { + String dataFilePath = split.getBaseFile().isPresent() + ? split.getBaseFile().get().getPath() + : split.getLogFiles().getFirst().getPath(); + String fileId = FSUtils.getFileIdFromFileName(new StoragePath(dataFilePath).getName()); + HoodieBaseFile baseFile = split.getBaseFile().isPresent() + ? new HoodieBaseFile(dataFilePath, fileId, split.getCommitTime(), null) + : null; + + return new FileSlice( + new HoodieFileGroupId(FSUtils.getRelativePartitionPath(new StoragePath(basePath), new StoragePath(dataFilePath)), fileId), + split.getCommitTime(), + baseFile, + split.getLogFiles().stream().map(lf -> new HoodieLogFile(lf.getPath())).toList()); + } + + public static HoodieTableFileSystemView getFileSystemView( + HoodieTableMetadata tableMetadata, + HoodieTableMetaClient metaClient) + { + return new HoodieTableFileSystemView( + tableMetadata, metaClient, metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants()); + } + + public static HoodieSchema getLatestTableSchema(HoodieTableMetaClient metaClient, String tableName) + { + try { + HoodieTimer timer = HoodieTimer.start(); + HoodieSchema schema = new TableSchemaResolver(metaClient).getTableSchema(); + log.info("Fetched table schema for table %s in %s ms", tableName, timer.endTimer()); + return schema; + } + catch (Exception e) { + // failed to read schema + throw new TrinoException(HUDI_FILESYSTEM_ERROR, e); + } + } + + /** + * Returns the column handles that must be present in the read schema for the file group reader to merge + * correctly, mirroring {@code FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging}: the ordering + * columns, the delete-marker and operation columns when the table carries them, the record-key data + * columns when meta fields are not populated, plus the mandatory merge columns declared by a configured + * custom record merger (via {@link HoodieRecordMerger#getMandatoryFieldsForMerging}). + *

    + * For a CUSTOM merge mode with a registered merger, it includes any data columns the merger reads at + * merge time (e.g. an arbitrary decision column) so that those columns are read from the base file even + * when the query does not project them -- without this the merger would see null for an un-projected + * column. + */ + public static List getMergeRequiredColumnHandles( + Table table, + TypeManager typeManager, + Lazy lazyMetaClient, + List recordMergerImpls, + HiveTimestampPrecision timestampPrecision) + { + HoodieTableMetaClient metaClient = lazyMetaClient.get(); + HoodieTableConfig tableConfig = metaClient.getTableConfig(); + // Resolve the merge mode/strategy exactly as the file-group reader does. Pre-v9 tables may persist + // neither config, so deciding on the raw values would e.g. miss the ordering columns of a 0.x + // event-time table entirely. + Pair mergeModeAndStrategyId = resolveMergeModeAndStrategyId(tableConfig); + RecordMergeMode recordMergeMode = mergeModeAndStrategyId.getLeft(); + String mergeStrategyId = mergeModeAndStrategyId.getRight(); + + LinkedHashSet requiredColumnNames = mergeRequiredColumnNames(tableConfig, recordMergeMode); + + // For a CUSTOM merge mode, ask the configured merger which fields it needs at merge time and include them + // so they are read even when not projected. Only the merger's declared columns are added (not all columns), + // so non-custom tables and mergers that only use the key/ordering fields incur no extra reads. + if (recordMergeMode == RecordMergeMode.CUSTOM && recordMergerImpls != null && !recordMergerImpls.isEmpty() + && !StringUtils.isNullOrEmpty(mergeStrategyId)) { + Option merger = HoodieRecordUtils.createValidRecordMerger( + EngineType.JAVA, String.join(",", recordMergerImpls), mergeStrategyId); + if (merger.isPresent()) { + TypedProperties props = new TypedProperties(); + props.putAll(tableConfig.getProps()); + props.setProperty(RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY, String.join(",", recordMergerImpls)); + HoodieSchema tableSchema; + try { + tableSchema = new TableSchemaResolver(metaClient).getTableSchema(); + } + catch (Exception e) { + throw new TrinoException(HUDI_SCHEMA_ERROR, "Failed to resolve table schema for merge column resolution", e); + } + String[] mandatoryFields = merger.get().getMandatoryFieldsForMerging(tableSchema, tableConfig, props); + if (mandatoryFields != null) { + Collections.addAll(requiredColumnNames, mandatoryFields); + } + } + } + + return buildColumnHandles(table, typeManager, requiredColumnNames, timestampPrecision); + } + + /** + * The merge-mandatory column names that do not depend on a custom merger, mirroring the non-CUSTOM + * branch of {@code FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging}. The delete-marker and + * operation fields are added unconditionally: {@code buildColumnHandles} keeps only metastore data + * columns, and the merge read path recovers any name the metastore does not carry from the resolved + * table schema ({@link #appendMissingMergeRequiredColumns}). For those two fields that mirrors the + * file-group reader's own schema gate, so tables whose schema has neither read nothing extra; the + * ordering, record-key and delete-key names the reader adds without a schema check, but they are + * table columns whenever the table config is coherent. + */ + @VisibleForTesting + static LinkedHashSet mergeRequiredColumnNames(HoodieTableConfig tableConfig, RecordMergeMode recordMergeMode) + { + LinkedHashSet requiredColumnNames = new LinkedHashSet<>(); + if (recordMergeMode != null && recordMergeMode != RecordMergeMode.COMMIT_TIME_ORDERING) { + requiredColumnNames.addAll(tableConfig.getOrderingFields()); + } + // Without populated meta fields the file-group reader keys records on the record-key data columns + if (!tableConfig.populateMetaFields()) { + tableConfig.getRecordKeyFields().ifPresent(fields -> requiredColumnNames.addAll(Arrays.asList(fields))); + } + // Delete markers and the operation field decide record deletion at merge time + requiredColumnNames.add(HOODIE_IS_DELETED_FIELD); + requiredColumnNames.add(OPERATION_METADATA_FIELD); + // Resolve the delete key/marker the way the file-group reader does (ConfigUtils.getMergeProps): + // table merge properties first -- v9+ tables persist these PREFIXED (hoodie.record.merge.property.*) + // and getTableMergeProperties strips the prefix and bridges legacy delete payloads (keyed on the + // payload class, which for the read path resolves from the table config) -- falling back to the raw + // table props, where pre-prefix tables and reader/write configs carry the plain keys. + Map tableMergeProps = tableConfig.getTableMergeProperties(tableConfig.getPayloadClass()); + String deleteKey = tableMergeProps.getOrDefault(DELETE_KEY, tableConfig.getProps().getProperty(DELETE_KEY)); + String deleteMarker = tableMergeProps.getOrDefault(DELETE_MARKER, tableConfig.getProps().getProperty(DELETE_MARKER)); + // DeleteContext only honors a custom delete key when the marker value is also set + if (!StringUtils.isNullOrEmpty(deleteKey) && !StringUtils.isNullOrEmpty(deleteMarker)) { + requiredColumnNames.add(deleteKey); + } + return requiredColumnNames; + } + + /** + * Returns {@code projection} extended with handles for the merge-required column names it does not + * already carry (matched case-insensitively) and the metastore could not resolve, typed from + * {@code dataSchema} via {@link #toColumnHandle}: the table-config-derived names + * ({@link #mergeRequiredColumnNames}) plus, under a CUSTOM merge mode, the mandatory fields declared + * by the same resolved merger the file-group reader will use. Names absent from the schema as well are + * dropped -- for {@code _hoodie_is_deleted} and {@code _hoodie_operation} that mirrors the file-group + * reader's own schema gate; the rest are table columns whenever the table config is coherent. A + * metastore that omits a field the table schema carries -- hive sync with + * {@code omit_metadata_fields=true} drops {@code _hoodie_operation}, hand-written external DDL can drop + * any data column -- must not starve the base-file read of it; the + * {@code HudiTrinoReaderContext.getFileRecordIterator} guard would otherwise fail the read. Called on + * the merge read path only, where the table schema is already resolved, so the recovery costs no I/O. + */ + public static List appendMissingMergeRequiredColumns( + HoodieSchema dataSchema, + List projection, + HoodieTableConfig tableConfig, + TypedProperties readerProps) + { + Pair mergeModeAndStrategyId = resolveMergeModeAndStrategyId(tableConfig); + LinkedHashSet requiredNames = mergeRequiredColumnNames(tableConfig, mergeModeAndStrategyId.getLeft()); + // A CUSTOM merger can declare mandatory fields of its own (getMandatoryFieldsForMerging); ask the + // same resolved merger the file-group reader will use, against the same resolved schema, so its + // declarations are recovered too. Resolution is classloading only -- no I/O. + if (mergeModeAndStrategyId.getLeft() == RecordMergeMode.CUSTOM && !StringUtils.isNullOrEmpty(mergeModeAndStrategyId.getRight())) { + String mergeImplClasses = readerProps.getString(RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY, + readerProps.getString(RECORD_MERGE_IMPL_CLASSES_DEPRECATED_WRITE_CONFIG_KEY, "")); + Option merger = HoodieRecordUtils.createValidRecordMerger(EngineType.JAVA, mergeImplClasses, mergeModeAndStrategyId.getRight()); + if (merger.isPresent()) { + String[] mandatoryFields = merger.get().getMandatoryFieldsForMerging(dataSchema, tableConfig, readerProps); + if (mandatoryFields != null) { + Collections.addAll(requiredNames, mandatoryFields); + } + } + } + Set existingColumns = projection.stream() + .map(handle -> handle.getName().toLowerCase(Locale.ROOT)) + .collect(Collectors.toCollection(HashSet::new)); + Map schemaFields = dataSchema.getFields().stream() + .collect(Collectors.toMap(field -> field.name().toLowerCase(Locale.ROOT), field -> field, (first, second) -> first)); + + List columns = new ArrayList<>(projection); + for (String name : requiredNames) { + HoodieSchemaField field = schemaFields.get(name.toLowerCase(Locale.ROOT)); + if (field != null && existingColumns.add(field.name().toLowerCase(Locale.ROOT))) { + columns.add(toColumnHandle(field)); + } + } + return columns; + } + + /** + * Builds {@link HiveColumnHandle}s, preserving physical (data-column) index, for the data columns whose names + * appear in {@code columnNames}. Names that are not data columns (e.g. Hudi meta fields) or whose types are not + * supported by the storage format are skipped. Matching is case-sensitive: a predicted merge column that carries + * the table schema's field casing (e.g. the {@code Op} delete key of DMS tables) misses the lowercased metastore + * name here and is recovered from the table schema by {@link #appendMissingMergeRequiredColumns} on the merge + * read path. + */ + private static List buildColumnHandles(Table table, TypeManager typeManager, Set columnNames, HiveTimestampPrecision timestampPrecision) + { + ImmutableList.Builder columns = ImmutableList.builder(); + int hiveColumnIndex = 0; + for (Column field : table.getDataColumns()) { + if (columnNames.contains(field.getName())) { + HiveType hiveType = field.getType(); + // ignore unsupported types rather than failing + if (typeSupported(hiveType.getTypeInfo(), table.getStorage().getStorageFormat())) { + columns.add(createBaseColumn(field.getName(), hiveColumnIndex, hiveType, getType(hiveType, typeManager, timestampPrecision), REGULAR, field.getComment())); + } + } + hiveColumnIndex++; + } + return columns.build(); + } + + /** + * Converts the given {@link HoodiePairData} into a {@link Map}. + *

    + * Special handling is applied for null keys: + *

      + *
    • If a key is null, it is stored in the map as a {@code null} entry.
    • + *
    • If multiple entries share the same key (including null), the latest value overwrites the previous one.
    • + *
    + * + * @param pairData the HoodiePairData containing key-value pairs + * @param the type of keys maintained by the resulting map + * @param the type of mapped values + * @return a {@link Map} containing all key-value pairs from the input data + */ + public static Map collectAsMap(HoodiePairData pairData) + { + // HashMap allows null keys, so put directly; on duplicate keys the later entry wins. + Map result = new HashMap<>(); + pairData.collectAsList().forEach(pair -> result.put(pair.getKey(), pair.getValue())); + return result; + } +} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/TableType.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/TableType.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/TableType.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/TableType.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/TimelineTable.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/TimelineTable.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/TimelineTable.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/TimelineTable.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/cache/HudiCacheKeyProvider.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/cache/HudiCacheKeyProvider.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/cache/HudiCacheKeyProvider.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/cache/HudiCacheKeyProvider.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiBaseFile.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiBaseFile.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiBaseFile.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiBaseFile.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiFile.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiFile.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiFile.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiFile.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiLogFile.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiLogFile.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/file/HudiLogFile.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/file/HudiLogFile.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java similarity index 61% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java index 7e8bb967ffc72..699992cefc8e8 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java @@ -13,15 +13,17 @@ */ package io.trino.plugin.hudi.io; -import org.apache.avro.Schema; import org.apache.hudi.common.config.HoodieConfig; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.util.Option; +import org.apache.hudi.io.storage.HFileReaderFactory; import org.apache.hudi.io.storage.HoodieAvroBootstrapFileReader; import org.apache.hudi.io.storage.HoodieFileReader; import org.apache.hudi.io.storage.HoodieFileReaderFactory; import org.apache.hudi.io.storage.HoodieNativeAvroHFileReader; import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; import java.io.IOException; @@ -42,10 +44,14 @@ protected HoodieFileReader newParquetFileReader(StoragePath path) @Override protected HoodieFileReader newHFileFileReader(HoodieConfig hoodieConfig, StoragePath path, - Option schemaOption) + Option schemaOption) throws IOException { - return new HoodieNativeAvroHFileReader(storage, path, schemaOption); + HFileReaderFactory readerFactory = HFileReaderFactory.builder() + .withStorage(storage).withProps(hoodieConfig.getProps()) + .withPath(path).build(); + return HoodieNativeAvroHFileReader.builder() + .readerFactory(readerFactory).path(path).schema(schemaOption).build(); } @Override @@ -53,10 +59,25 @@ protected HoodieFileReader newHFileFileReader(HoodieConfig hoodieConfig, StoragePath path, HoodieStorage storage, byte[] content, - Option schemaOption) + Option schemaOption) throws IOException { - return new HoodieNativeAvroHFileReader(this.storage, content, schemaOption); + HFileReaderFactory readerFactory = HFileReaderFactory.builder() + .withStorage(storage).withProps(hoodieConfig.getProps()) + .withContent(content).build(); + return HoodieNativeAvroHFileReader.builder() + .readerFactory(readerFactory).path(path).schema(schemaOption).build(); + } + + @Override + protected HoodieFileReader newHFileFileReader(HoodieConfig hoodieConfig, StoragePathInfo pathInfo, Option schemaOption) + throws IOException + { + HFileReaderFactory readerFactory = HFileReaderFactory.builder() + .withStorage(storage).withProps(hoodieConfig.getProps()) + .withPath(pathInfo.getPath()).build(); + return HoodieNativeAvroHFileReader.builder() + .readerFactory(readerFactory).path(pathInfo.getPath()).schema(schemaOption).build(); } @Override diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java similarity index 70% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java index 8044e92b58ec7..2f940a9a13c20 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoIOFactory.java @@ -17,6 +17,7 @@ import org.apache.hudi.common.model.HoodieFileFormat; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.util.FileFormatUtils; +import org.apache.hudi.common.util.HFileUtils; import org.apache.hudi.io.storage.HoodieFileReaderFactory; import org.apache.hudi.io.storage.HoodieFileWriterFactory; import org.apache.hudi.io.storage.HoodieIOFactory; @@ -46,7 +47,19 @@ public HoodieFileWriterFactory getWriterFactory(HoodieRecord.HoodieRecordType re @Override public FileFormatUtils getFileFormatUtils(HoodieFileFormat fileFormat) { - throw new UnsupportedOperationException("FileFormatUtils not supported in HudiTrinoIOFactory"); + if (fileFormat == HoodieFileFormat.PARQUET) { + // Parquet needs a Trino-native implementation: hudi's own ParquetUtils lives in + // hudi-hadoop-common, which is excluded from the Trino runtime. + return new HudiTrinoParquetFileFormatUtils(); + } + if (fileFormat == HoodieFileFormat.HFILE) { + // hudi-common's HFileUtils is hadoop-free (it decodes via hudi-io's native HFile + // reader), so it is safe in the Trino runtime. This is what lets the connector read + // uncompacted metadata-table deltas, which are native HFILE log files. + return new HFileUtils(); + } + throw new UnsupportedOperationException( + "Native " + fileFormat + " log files are not supported by the Hudi Trino connector"); } @Override diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoParquetFileFormatUtils.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoParquetFileFormatUtils.java new file mode 100644 index 0000000000000..9607bf83595c1 --- /dev/null +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoParquetFileFormatUtils.java @@ -0,0 +1,210 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.io; + +import io.trino.filesystem.TrinoFileSystem; +import io.trino.filesystem.TrinoInputFile; +import io.trino.parquet.ParquetDataSource; +import io.trino.parquet.reader.MetadataReader; +import io.trino.plugin.base.metrics.FileFormatDataSourceStats; +import io.trino.plugin.hive.parquet.ParquetReaderConfig; +import io.trino.plugin.hudi.storage.HudiTrinoStorage; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.util.FileFormatUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.keygen.BaseKeyGenerator; +import org.apache.hudi.metadata.HoodieIndexVersion; +import org.apache.hudi.stats.HoodieColumnRangeMetadata; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StoragePath; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Properties; +import java.util.Set; + +import static io.trino.memory.context.AggregatedMemoryContext.newSimpleAggregatedMemoryContext; +import static io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createDataSource; + +/** + * {@link FileFormatUtils} for the Hudi Trino connector, backed by Trino's own Parquet reader. + *

    + * The connector reads Parquet data through Trino's engine-native reader rather than Hudi's Hadoop-based + * reader, so {@code hudi-hadoop-common} (and its {@code ParquetUtils}) is deliberately excluded from the + * runtime. The only Hudi read path that still needs a {@link FileFormatUtils} is reading the log-block + * header out of an RFC-103 native (Parquet) delta-log file footer, which goes through + * {@link #readFooter(HoodieStorage, boolean, StoragePath, String...)}. Every other method is unused on the + * read path and throws. + */ +public class HudiTrinoParquetFileFormatUtils + extends FileFormatUtils +{ + private static final String UNSUPPORTED_MESSAGE = + "HudiTrinoParquetFileFormatUtils only supports reading Parquet footer metadata"; + + @Override + public Map readFooter(HoodieStorage storage, boolean required, StoragePath filePath, String... footerNames) + { + Map footerVals = new HashMap<>(); + TrinoFileSystem fileSystem = (TrinoFileSystem) storage.getFileSystem(); + try { + long fileSize = storage.getPathInfo(filePath).getLength(); + TrinoInputFile inputFile = fileSystem.newInputFile(HudiTrinoStorage.convertToLocation(filePath), fileSize); + try (ParquetDataSource dataSource = createDataSource( + inputFile, + OptionalLong.of(fileSize), + new ParquetReaderConfig().toParquetReaderOptions(), + newSimpleAggregatedMemoryContext(), + new FileFormatDataSourceStats())) { + Map metadata = MetadataReader.readFooter(dataSource, Optional.empty()) + .getFileMetaData() + .getKeyValueMetaData(); + for (String footerName : footerNames) { + if (metadata.containsKey(footerName)) { + footerVals.put(footerName, metadata.get(footerName)); + } + else if (required) { + throw new HoodieException("Could not find footer key " + footerName + " in Parquet file " + filePath); + } + } + } + } + catch (IOException e) { + throw new HoodieIOException("Failed to read Parquet footer from " + filePath, e); + } + return footerVals; + } + + @Override + public HoodieFileFormat getFormat() + { + return HoodieFileFormat.PARQUET; + } + + @Override + public List readAvroRecords(HoodieStorage storage, StoragePath filePath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public List readAvroRecords(HoodieStorage storage, StoragePath filePath, HoodieSchema schema) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public long getRowCount(HoodieStorage storage, StoragePath filePath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public Set> filterRowKeys(HoodieStorage storage, StoragePath filePath, Set filter) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public ClosableIterator> fetchRecordKeysWithPositions(HoodieStorage storage, StoragePath filePath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public ClosableIterator getHoodieKeyIterator(HoodieStorage storage, + StoragePath filePath, + Option keyGeneratorOpt, + Option partitionPath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public ClosableIterator getHoodieKeyIterator(HoodieStorage storage, StoragePath filePath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public ClosableIterator> fetchRecordKeysWithPositions(HoodieStorage storage, + StoragePath filePath, + Option keyGeneratorOpt, + Option partitionPath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public HoodieSchema readSchema(HoodieStorage storage, StoragePath filePath) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + @SuppressWarnings("rawtype") + public List> readColumnStatsFromMetadata(HoodieStorage storage, + StoragePath filePath, + List columnList, + HoodieIndexVersion indexVersion) + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public void writeMetaFile(HoodieStorage storage, StoragePath filePath, Properties props) + throws IOException + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public ByteArrayOutputStream serializeRecordsToLogBlock(HoodieStorage storage, + List records, + HoodieSchema writerSchema, + HoodieSchema readerSchema, + String keyFieldName, + Map paramsMap) + throws IOException + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } + + @Override + public Pair serializeRecordsToLogBlock(HoodieStorage storage, + Iterator records, + HoodieRecord.HoodieRecordType recordType, + HoodieSchema writerSchema, + HoodieSchema readerSchema, + String keyFieldName, + Map paramsMap) + throws IOException + { + throw new UnsupportedOperationException(UNSUPPORTED_MESSAGE); + } +} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java similarity index 98% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java index f75ec7a55f90b..b67b0012905bc 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/InlineSeekableDataInputStream.java @@ -26,7 +26,7 @@ * Example InlineFS URL: *

      * inlinefs://tests_7af7f087-c807-4f5e-a759-65fd9c21063b/hudi_multi_fg_pt_v8_mor/.hoodie/metadata/column_stats/
    - * .col-stats-0001-0_20250429145946675.log.1_1-120-382/local/?start_offset=8036&length=6959
    + * .col-stats-0001-0_20250429145946675.log.1_1-120-382/local/?start_offset=8036&length=6959
      * 
    *

    * Key behaviors: diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/TrinoSeekableDataInputStream.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/TrinoSeekableDataInputStream.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/io/TrinoSeekableDataInputStream.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/io/TrinoSeekableDataInputStream.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HiveHudiPartitionInfo.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HiveHudiPartitionInfo.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HiveHudiPartitionInfo.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HiveHudiPartitionInfo.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfo.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfo.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfo.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfo.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfoLoader.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfoLoader.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfoLoader.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/partition/HudiPartitionInfoLoader.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/HudiDirectoryLister.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/HudiDirectoryLister.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/HudiDirectoryLister.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/HudiDirectoryLister.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java similarity index 68% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java index f177282bf439e..e56b3dc2ea1fb 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/HudiSnapshotDirectoryLister.java @@ -21,12 +21,13 @@ import io.trino.plugin.hudi.query.index.IndexSupportFactory; import io.trino.spi.connector.ConnectorSession; import io.trino.spi.connector.SchemaTableName; +import org.apache.hudi.common.engine.HoodieLocalEngineContext; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.HoodieTimer; -import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.util.Lazy; +import org.apache.hudi.metadata.HoodieTableMetadata; import java.util.List; import java.util.Optional; @@ -54,9 +55,22 @@ public HudiSnapshotDirectoryLister( this.lazyFileSystemView = Lazy.lazily(() -> { HoodieTimer timer = HoodieTimer.start(); HoodieTableMetaClient metaClient = tableHandle.getMetaClient(); - HoodieTableFileSystemView fileSystemView = getFileSystemView(lazyTableMetadata.get(), metaClient); - if (enableMetadataTable) { - fileSystemView.loadAllPartitions(); + HoodieTableFileSystemView fileSystemView; + try { + fileSystemView = getFileSystemView(lazyTableMetadata.get(), metaClient); + if (enableMetadataTable) { + fileSystemView.loadAllPartitions(); + } + } + catch (Exception e) { + // A failure here is a metadata-table read failure (the metastore/table itself is + // fine), so fall back to direct file listing instead of failing the query. The + // failed view is deliberately not closed: closing it would also close the shared + // HoodieTableMetadata behind lazyTableMetadata, which the split loader and the + // index supports still read through. + log.error(e, "Failed to load the file system view of table %s via the metadata table, falling back to direct file listing", + schemaTableName); + fileSystemView = createDirectListingFileSystemView(metaClient); } log.info("Created file system view of table %s in %s ms", schemaTableName, timer.endTimer()); return fileSystemView; @@ -67,6 +81,19 @@ public HudiSnapshotDirectoryLister( IndexSupportFactory.createIndexSupport(tableHandle, lazyMetaClient, lazyTableMetadata, tableHandle.getRegularPredicates(), session) : Optional.empty(); } + /** + * Builds a file system view that lists files directly from storage, bypassing the metadata table. + * Used as the fallback when the metadata-table-backed view cannot be loaded (e.g. an MDT read + * failure); it lists lazily per partition, so no {@code loadAllPartitions()} here. + */ + private static HoodieTableFileSystemView createDirectListingFileSystemView(HoodieTableMetaClient metaClient) + { + return HoodieTableFileSystemView.fileListingBasedFileSystemView( + new HoodieLocalEngineContext(metaClient.getStorage().getConf()), + metaClient, + metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants()); + } + @Override public List listStatus(HudiPartitionInfo partitionInfo, boolean useIndex) { diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java similarity index 51% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java index 044908f9c49f4..d75960e008828 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiBaseIndexSupport.java @@ -15,9 +15,14 @@ import io.airlift.log.Logger; import io.trino.spi.connector.SchemaTableName; +import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieIndexDefinition; +import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.util.Lazy; import java.util.List; @@ -63,4 +68,43 @@ protected Map getAllIndexDefinitions() return lazyMetaClient.get().getIndexMetadata().get().getIndexDefinitions(); } + + /** + * Resolves the columns covered by a stats index partition (column stats or partition stats). + * + *

    Prefers the registered index definition, but tables written by this release line do not + * register one for the built-in stats partitions -- only secondary, expression and record + * indexes get a definition here. So fall back to deriving the columns the same way the writer + * chooses them, which is what the Spark reader on this branch does as well: it gates only on + * the metadata partition being present and resolves the indexed columns separately. + * + *

    A column wrongly treated as indexed is safe: no stats come back for it, and both + * {@code shouldSkipFileSlice} and {@code evaluateStatisticPredicate} keep the file when stats + * are missing. The cost of being wrong is a missed pruning opportunity, never a wrong result. + * + * @return the indexed columns, or an empty list if they cannot be determined (index disabled) + */ + protected List resolveStatsIndexedColumns(String indexPartitionPath) + { + HoodieIndexDefinition definition = getAllIndexDefinitions().get(indexPartitionPath); + if (definition != null && definition.getSourceFields() != null && !definition.getSourceFields().isEmpty()) { + return definition.getSourceFields(); + } + + HoodieTableMetaClient metaClient = lazyMetaClient.get(); + try { + Option tableSchema = Option.of(new TableSchemaResolver(metaClient).getTableSchema()); + return List.copyOf(HoodieTableMetadataUtil.getColumnsToIndex( + metaClient.getTableConfig(), + HoodieMetadataConfig.newBuilder().enable(true).build(), + Lazy.eagerly(tableSchema), + Option.empty(), + HoodieTableMetadataUtil.existingIndexVersionOrDefault(indexPartitionPath, metaClient)) + .keySet()); + } + catch (Exception e) { + log.warn(e, "Could not derive the indexed columns of %s for table %s, skipping the index", indexPartitionPath, schemaTableName); + return List.of(); + } + } } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java similarity index 94% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java index 0d1c9eaa6cd06..01a4a3e64e467 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiColumnStatsIndexSupport.java @@ -30,13 +30,13 @@ import io.trino.spi.type.VarcharType; import org.apache.avro.generic.GenericRecord; import org.apache.hudi.avro.model.HoodieMetadataColumnStats; +import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.model.BaseFile; import org.apache.hudi.common.model.FileSlice; -import org.apache.hudi.common.model.HoodieIndexDefinition; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.util.HoodieTimer; -import org.apache.hudi.common.util.hash.ColumnIndexID; +import org.apache.hudi.metadata.ColumnStatsIndexPrefixRawKey; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.util.Lazy; @@ -92,9 +92,9 @@ public HudiColumnStatsIndexSupport(Logger log, ConnectorSession session, SchemaT } else { // Get filter columns - List encodedTargetColumnNames = regularColumns + List rawKeys = regularColumns .stream() - .map(col -> new ColumnIndexID(col).asBase64EncodedString()).collect(Collectors.toList()); + .map(ColumnStatsIndexPrefixRawKey::new).toList(); Map columnTypes = regularColumnPredicates.getDomains().get().entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getType())); @@ -107,7 +107,8 @@ public HudiColumnStatsIndexSupport(Logger log, ConnectorSession session, SchemaT } Map> domainsWithStats = - lazyTableMetadata.get().getRecordsByKeyPrefixes(encodedTargetColumnNames, + lazyTableMetadata.get().getRecordsByKeyPrefixes( + HoodieListData.lazy(rawKeys), HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS, true) .collectAsList() .stream() @@ -170,15 +171,13 @@ public boolean canApply(TupleDomain tupleDomain) return isIndexSupported; } - Map indexDefinitions = getAllIndexDefinitions(); - HoodieIndexDefinition colStatsDefinition = indexDefinitions.get(HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS); - if (colStatsDefinition == null || colStatsDefinition.getSourceFields() == null || colStatsDefinition.getSourceFields().isEmpty()) { - log.warn("Column stats index definition is missing or has no source fields defined"); + List sourceFields = resolveStatsIndexedColumns(HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS); + if (sourceFields.isEmpty()) { + log.warn("Could not determine the columns covered by the column stats index"); return false; } // Optimization applied: Only consider applicable if predicates reference indexed columns - List sourceFields = colStatsDefinition.getSourceFields(); boolean applicable = TupleDomainUtils.areSomeFieldsReferenced(tupleDomain, sourceFields); if (applicable) { diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiIndexSupport.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiIndexSupport.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiNoOpIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiNoOpIndexSupport.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiNoOpIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiNoOpIndexSupport.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java similarity index 88% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java index 3491d10cccfe4..3f4d002086f9f 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiPartitionStatsIndexSupport.java @@ -21,10 +21,10 @@ import io.trino.spi.predicate.TupleDomain; import io.trino.spi.type.Type; import org.apache.hudi.avro.model.HoodieMetadataColumnStats; -import org.apache.hudi.common.model.HoodieIndexDefinition; +import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.HoodieTimer; -import org.apache.hudi.common.util.hash.ColumnIndexID; +import org.apache.hudi.metadata.ColumnStatsIndexPrefixRawKey; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; import org.apache.hudi.util.Lazy; @@ -66,15 +66,15 @@ public Optional> prunePartitions( List regularColumns = new ArrayList<>(filteredRegularPredicates.getDomains().get().keySet()); // Get columns to filter on - List encodedTargetColumnNames = regularColumns.stream() - .map(col -> new ColumnIndexID(col).asBase64EncodedString()).toList(); + List columnStatsIndexPrefixRawKeys = regularColumns.stream() + .map(ColumnStatsIndexPrefixRawKey::new).toList(); Map columnTypes = regularColumnPredicates.getDomains().get().entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getType())); // Map of domains with partition stats keyed by partition name and column name Map> domainsWithStats = lazyMetadataTable.get().getRecordsByKeyPrefixes( - encodedTargetColumnNames, + HoodieListData.eager(columnStatsIndexPrefixRawKeys), HoodieTableMetadataUtil.PARTITION_NAME_PARTITION_STATS, true) .collectAsList() .stream() @@ -127,15 +127,14 @@ public boolean canApply(TupleDomain tupleDomain) return false; } - Map indexDefinitions = getAllIndexDefinitions(); - HoodieIndexDefinition partitionsStatsIndex = indexDefinitions.get(HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS); - if (partitionsStatsIndex == null || partitionsStatsIndex.getSourceFields() == null || partitionsStatsIndex.getSourceFields().isEmpty()) { - log.warn("Partition stats index definition is missing or has no source fields defined"); + // Partition stats cover the same columns as column stats, so resolve against that partition + List sourceFields = resolveStatsIndexedColumns(HoodieTableMetadataUtil.PARTITION_NAME_COLUMN_STATS); + if (sourceFields.isEmpty()) { + log.warn("Could not determine the columns covered by the partition stats index"); return false; } // Optimization applied: Only consider applicable if predicates reference indexed columns - List sourceFields = partitionsStatsIndex.getSourceFields(); boolean applicable = TupleDomainUtils.areSomeFieldsReferenced(tupleDomain, sourceFields); if (applicable) { diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java similarity index 98% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java index 78f503f4b21ea..e438e5686ebb3 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupport.java @@ -22,6 +22,7 @@ import io.trino.spi.connector.SchemaTableName; import io.trino.spi.predicate.Domain; import io.trino.spi.predicate.TupleDomain; +import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieRecordGlobalLocation; import org.apache.hudi.common.table.HoodieTableMetaClient; @@ -47,6 +48,7 @@ import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA; import static io.trino.plugin.hudi.HudiSessionProperties.getRecordIndexWaitTimeout; +import static io.trino.plugin.hudi.HudiUtil.collectAsMap; import static java.util.concurrent.TimeUnit.MILLISECONDS; public class HudiRecordLevelIndexSupport @@ -94,7 +96,7 @@ public HudiRecordLevelIndexSupport(ConnectorSession session, SchemaTableName sch // Perform index lookup in metadataTable // TODO: document here what this map is keyed by - Map recordIndex = lazyTableMetadata.get().readRecordIndex(recordKeys); + Map recordIndex = collectAsMap(lazyTableMetadata.get().readRecordIndexLocationsWithKeys(HoodieListData.eager(recordKeys))); if (recordIndex.isEmpty()) { log.debug("Record level index lookup took %s ms but returned no locations for the given keys %s for table %s", timer.endTimer(), recordKeys, schemaTableName); diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java similarity index 97% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java index 23121902f72c4..eddf4d48f5477 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/HudiSecondaryIndexSupport.java @@ -19,10 +19,12 @@ import io.trino.spi.connector.ConnectorSession; import io.trino.spi.connector.SchemaTableName; import io.trino.spi.predicate.TupleDomain; +import org.apache.hudi.common.data.HoodieListData; import org.apache.hudi.common.model.FileSlice; import org.apache.hudi.common.model.HoodieIndexDefinition; import org.apache.hudi.common.model.HoodieRecordGlobalLocation; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.util.HoodieDataUtils; import org.apache.hudi.common.util.HoodieTimer; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.metadata.HoodieTableMetadataUtil; @@ -85,7 +87,7 @@ public HudiSecondaryIndexSupport(ConnectorSession session, SchemaTableName schem // Perform index lookup in metadataTable // TODO: document here what this map is keyed by - Map recordKeyLocationsMap = lazyTableMetadata.get().readSecondaryIndex(secondaryKeys, indexName); + Map recordKeyLocationsMap = HoodieDataUtils.dedupeAndCollectAsMap(lazyTableMetadata.get().readSecondaryIndexLocationsWithKeys(HoodieListData.eager(secondaryKeys), indexName)); if (recordKeyLocationsMap.isEmpty()) { log.debug("Took %s ms, but secondary index lookup returned no locations for the given keys for table %s", timer.endTimer(), schemaTableName); // Return all original fileSlices diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java similarity index 99% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java index 5d98177896c44..e31f3202a76e8 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/query/index/IndexSupportFactory.java @@ -131,7 +131,7 @@ private static TupleDomain transformTupleDomain(ConnectorSession session if (isResolveColumnNameCasingEnabled(session)) { // if column case reconciliation is enabled, transform the tuple domain keys to match the column names from the Hudi table. return tupleDomain.transformKeys(hiveColumnHandle -> - getFieldFromSchema(hiveColumnHandle.getName(), hudiTableHandle.getTableSchema()).name()); + getFieldFromSchema(hiveColumnHandle.getName(), hudiTableHandle.getTableSchema().toAvroSchema()).name()); } return tupleDomain.transformKeys(HiveColumnHandle::getName); } diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java new file mode 100644 index 0000000000000..22a3e46ab38ac --- /dev/null +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java @@ -0,0 +1,289 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.reader; + +import com.google.common.collect.ImmutableSet; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hudi.HudiUtil; +import io.trino.plugin.hudi.util.HudiAvroSerializer; +import io.trino.plugin.hudi.util.PrefilledColumnValues; +import io.trino.spi.Page; +import io.trino.spi.TrinoException; +import io.trino.spi.connector.ConnectorPageSource; +import io.trino.spi.connector.SourcePage; +import org.apache.avro.generic.IndexedRecord; +import org.apache.hudi.avro.AvroRecordContext; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.engine.EngineType; +import org.apache.hudi.common.engine.HoodieReaderContext; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.HoodieAvroRecordMerger; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.model.OverwriteWithLatestMerger; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.util.HoodieRecordUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.ClosableIterator; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.storage.HoodieStorage; +import org.apache.hudi.storage.StorageConfiguration; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; +import org.apache.hudi.storage.inline.InLineFSUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; + +import static io.trino.plugin.hudi.HudiErrorCode.HUDI_SCHEMA_ERROR; +import static java.lang.String.format; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY; + +public class HudiTrinoReaderContext + extends HoodieReaderContext +{ + private final ConnectorPageSource pageSource; + private final List columnHandles; + private final PrefilledColumnValues prefilledColumnValues; + private final LogFileParquetPageSourceFactory logPageSourceFactory; + private final Map colNameToHandle; + private final Set baseProjectionNames; + + /** + * Factory for building a Trino parquet page source over a native (RFC-103) delta-log file on demand. + * Kept as an injected interface so this reader context stays free of Trino-parquet/session types. + */ + @FunctionalInterface + public interface LogFileParquetPageSourceFactory + { + ConnectorPageSource create(String path, long start, long length, List projection); + } + + public HudiTrinoReaderContext( + StorageConfiguration storageConfiguration, + HoodieTableConfig tableConfig, + ConnectorPageSource pageSource, + List columnHandles, + PrefilledColumnValues prefilledColumnValues, + LogFileParquetPageSourceFactory logPageSourceFactory) + { + super(storageConfiguration, tableConfig, Option.empty(), Option.empty(), new AvroRecordContext(tableConfig, tableConfig.getPayloadClass())); + this.pageSource = pageSource; + this.prefilledColumnValues = prefilledColumnValues; + this.columnHandles = columnHandles; + this.logPageSourceFactory = logPageSourceFactory; + this.colNameToHandle = new HashMap<>(); + for (HiveColumnHandle handle : columnHandles) { + colNameToHandle.put(handle.getBaseColumnName().toLowerCase(Locale.ROOT), handle); + } + // Immutable snapshot of the read projection for the base-read guard in getFileRecordIterator; + // colNameToHandle cannot serve that purpose because buildRequiredColumnHandles adds log-side + // handles to it on demand. + this.baseProjectionNames = ImmutableSet.copyOf(colNameToHandle.keySet()); + } + + @Override + public ClosableIterator getFileRecordIterator( + StoragePath storagePath, + long start, + long length, + HoodieSchema dataSchema, + HoodieSchema requiredSchema, + HoodieStorage storage) + { + return getFileRecordIterator(storagePath, start, length, requiredSchema); + } + + @Override + public ClosableIterator getFileRecordIterator( + StoragePathInfo storagePathInfo, + long start, + long length, + HoodieSchema dataSchema, + HoodieSchema requiredSchema, + HoodieStorage storage) + { + return getFileRecordIterator(storagePathInfo.getPath(), start, length, requiredSchema); + } + + /** + * Reads the given file and projects {@code requiredSchema}. For a native (RFC-103) delta-log parquet file a + * fresh page source is built on demand with predicate pushdown disabled so every log record is read and + * merged; for the base file the pre-built base page source is reused. Classic Avro log blocks never reach + * here (they deserialize inline). + *

    + * Both paths emit records that CARRY {@code requiredSchema}: the file-group reader tracks that schema for + * every buffered record, and payload-based merging round-trips records through Avro binary with it + * ({@code BaseAvroPayload}), so a record whose own schema differs (in field order or nullability) would + * decode into garbage values there. + */ + private ClosableIterator getFileRecordIterator( + StoragePath path, + long start, + long length, + HoodieSchema requiredSchema) + { + if (FSUtils.isLogFile(path)) { + // Inline parquet log blocks (inlinefs:// scheme) need a separate inline-aware reader; out of scope here. + if (InLineFSUtils.SCHEME.equals(path.toUri().getScheme())) { + throw new UnsupportedOperationException("Inline log blocks are not supported by the Hudi Trino connector: " + path); + } + List logProjection = buildRequiredColumnHandles(requiredSchema); + ConnectorPageSource logSource = logPageSourceFactory.create(path.toString(), start, length, logProjection); + HudiAvroSerializer logSerializer = new HudiAvroSerializer(logProjection, prefilledColumnValues, requiredSchema.toAvroSchema()); + return createRecordIterator(logSource, logSerializer); + } + // The base read reuses the pre-built page source, so it can only satisfy requiredSchema fields the + // read projection carries. The connector predicts the file-group reader's demands up front + // (HudiUtil.getMergeRequiredColumnHandles, HudiPageSourceProvider.requiresFullSchemaRead); if the + // two ever drift, fail loudly here instead of silently merging with null column values. + List missingColumns = requiredSchema.getFields().stream() + .map(HoodieSchemaField::name) + .filter(name -> !baseProjectionNames.contains(name.toLowerCase(Locale.ROOT))) + .toList(); + if (!missingColumns.isEmpty()) { + throw new TrinoException(HUDI_SCHEMA_ERROR, format( + "The file-group reader requires columns %s for merging, but the base-file read projection " + + "does not carry them. The connector's merge projection is out of sync with " + + "FileGroupReaderSchemaHandler.generateRequiredSchema.", + missingColumns)); + } + // Every requiredSchema field is in the base projection (checked above) and every projected column is + // a requiredSchema field (the file-group reader's required schema always contains the full requested + // schema), so the by-name channel mapping resolves in both directions. + return createRecordIterator(pageSource, new HudiAvroSerializer(columnHandles, prefilledColumnValues, requiredSchema.toAvroSchema())); + } + + /** + * Resolves the {@link HiveColumnHandle} for each field of {@code requiredSchema} so the on-demand log + * page source reads exactly the columns the file-group reader needs to merge. Fields the connector + * projection carries resolve to their projection handles (planner-authoritative types); the file-group + * reader can also require fields the projection does not carry (e.g. {@code _hoodie_commit_time} or a + * delete-marker column on a narrow query) -- every such field originates from the table schema and + * carries its real Avro type, so its handle is typed directly from the field and cached. + */ + private List buildRequiredColumnHandles(HoodieSchema requiredSchema) + { + List handles = new ArrayList<>(); + for (HoodieSchemaField field : requiredSchema.getFields()) { + // A projection handle may carry the lowercased metastore column name (e.g. 'op' for the + // DMS delete key 'Op'); that is fine on this path: parquet columns resolve + // case-insensitively, the serializer maps channels into requiredSchema positions + // case-insensitively, and hudi-common reads merge fields off the record's own schema, + // which serialize() stamps with the table casing. + handles.add(colNameToHandle.computeIfAbsent( + field.name().toLowerCase(Locale.ROOT), + _ -> HudiUtil.toColumnHandle(field))); + } + return handles; + } + + private ClosableIterator createRecordIterator(ConnectorPageSource source, HudiAvroSerializer serializer) + { + return new ClosableIterator<>() + { + private Page currentPage; + private int currentPosition; + + @Override + public void close() + { + try { + source.close(); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean hasNext() + { + // If all records in the current page are consume, try to get next page + if (currentPage == null || currentPosition >= currentPage.getPositionCount()) { + if (source.isFinished()) { + return false; + } + + // Get next page and reset currentPosition. Unwrap the SourcePage to the + // underlying Page so the serializer's Block accessors keep working. + SourcePage nextSourcePage = source.getNextSourcePage(); + currentPage = nextSourcePage == null ? null : nextSourcePage.getPage(); + currentPosition = 0; + + // If no more pages are available + return currentPage != null; + } + + return true; + } + + @Override + public IndexedRecord next() + { + if (!hasNext()) { + throw new NoSuchElementException("No more records in the iterator"); + } + + IndexedRecord record = serializer.serialize(currentPage, currentPosition); + currentPosition++; + return record; + } + }; + } + + @Override + protected Option getRecordMerger(RecordMergeMode mergeMode, String mergeStrategyId, String mergeImplClasses) + { + // Dispatch on the table's merge mode, mirroring HoodieAvroReaderContext. The Trino reader + // operates on IndexedRecord, so the Avro mergers apply directly. The CUSTOM arm's return + // value drives merging (combineAndGetUpdateValue at read time, covered end-to-end by + // TestHudiMorPayloadSemantics; apache/hudi#18898). The ordering arms' mergers are reached + // through partialMerge when a log block carries IS_PARTIAL (BufferedRecordMergerFactory), + // which these suites do not cover; TestHudiMorMergeModeSemantics pins the mode semantics + // themselves. TODO(apache/hudi#19413): cover the ordering arms' partialMerge path + // (IS_PARTIAL log blocks) once the Avro mergers implement it. + switch (mergeMode) { + case EVENT_TIME_ORDERING: + return Option.of(new HoodieAvroRecordMerger()); + case COMMIT_TIME_ORDERING: + return Option.of(new OverwriteWithLatestMerger()); + case CUSTOM: + default: + // createValidRecordMerger dereferences the strategy id on its first line, so a table that + // resolved to CUSTOM without persisting one must be rejected before it reaches hudi-common. + HudiUtil.validateCustomMergeStrategyId(mergeStrategyId); + Option recordMerger = HoodieRecordUtils.createValidRecordMerger(EngineType.JAVA, mergeImplClasses, mergeStrategyId); + if (recordMerger.isEmpty()) { + throw new IllegalArgumentException("No valid merger implementation set for `" + RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY + "`"); + } + return recordMerger; + } + } + + @Override + public ClosableIterator mergeBootstrapReaders(ClosableIterator skeletonFileIterator, HoodieSchema skeletonRequiredSchema, ClosableIterator dataFileIterator, HoodieSchema dataRequiredSchema, List> requiredPartitionFieldAndValues) + { + // Bootstrap merge is not exercised by the Trino connector; reads of bootstrap tables go + // through the regular page-source path. Throwing surfaces accidental use loudly. + throw new UnsupportedOperationException("HudiTrinoReaderContext does not support bootstrap merge"); + } +} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java similarity index 93% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java index 999e36f1ea0d8..e2b6ac265fdd8 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiBackgroundSplitLoader.java @@ -19,7 +19,6 @@ import io.airlift.concurrent.BoundedExecutor; import io.airlift.log.Logger; import io.trino.filesystem.Location; -import io.trino.filesystem.cache.CachingHostAddressProvider; import io.trino.metastore.Column; import io.trino.metastore.Partition; import io.trino.metastore.StorageFormat; @@ -101,14 +100,13 @@ public HudiBackgroundSplitLoader( Lazy> lazyPartitionMap, boolean enableMetadataTable, Lazy lazyTableMetadata, - CachingHostAddressProvider cachingHostAddressProvider, Consumer errorListener) { this.tableHandle = requireNonNull(tableHandle, "tableHandle is null"); this.hudiDirectoryLister = requireNonNull(hudiDirectoryLister, "hudiDirectoryLister is null"); this.asyncQueue = requireNonNull(asyncQueue, "asyncQueue is null"); this.splitGeneratorNumThreads = getSplitGeneratorParallelism(session); - this.hudiSplitFactory = new HudiSplitFactory(tableHandle, hudiSplitWeightProvider, getTargetSplitSize(session), cachingHostAddressProvider); + this.hudiSplitFactory = new HudiSplitFactory(tableHandle, hudiSplitWeightProvider, getTargetSplitSize(session)); this.lazyPartitionMap = requireNonNull(lazyPartitionMap, "partitions is null"); this.enableMetadataTable = enableMetadataTable; this.executor = requireNonNull(executor, "executor is null"); @@ -216,9 +214,9 @@ private Deque getPartitionInfos(boolean useIndex) List allPartitions = new ArrayList<>(metadataPartitions.keySet()); - List effectivePartitions = Optional.ofNullable(useIndex && partitionIndexSupportOpt.isPresent() - ? partitionIndexSupportOpt.get().prunePartitions(allPartitions).orElse(null) - : null).orElse(allPartitions); + List effectivePartitions = useIndex && partitionIndexSupportOpt.isPresent() + ? prunePartitionsSafely(allPartitions) + : allPartitions; Map finalMetadataPartitions = metadataPartitions; List hiveHudiPartitionInfos = effectivePartitions.stream() @@ -229,6 +227,24 @@ private Deque getPartitionInfos(boolean useIndex) return new ConcurrentLinkedDeque<>(hiveHudiPartitionInfos); } + /** + * Applies partition-stats index pruning, treating any failure as "no pruning". The pruning read + * goes through the metadata table and can fail for reasons unrelated to the query itself; index + * pruning is an optimization and must never fail the query, mirroring the metastore fallback of + * the metadata-table partition listing above. + */ + private List prunePartitionsSafely(List allPartitions) + { + try { + return partitionIndexSupportOpt.get().prunePartitions(allPartitions).orElse(allPartitions); + } + catch (Exception e) { + log.error(e, "Failed to prune partitions via partition stats index on table %s.%s, proceeding without partition pruning", + tableHandle.getSchemaName(), tableHandle.getTableName()); + return allPartitions; + } + } + private HiveHudiPartitionInfo buildHiveHudiPartitionInfo(HudiTableHandle tableHandle, String partitionName, Partition partition) { return new HiveHudiPartitionInfo( diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java similarity index 83% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java index 09dbf5bd5020c..ece6472473c0d 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiSplitFactory.java @@ -15,7 +15,6 @@ import com.google.common.collect.ImmutableList; import io.airlift.units.DataSize; -import io.trino.filesystem.cache.CachingHostAddressProvider; import io.trino.plugin.hive.HivePartitionKey; import io.trino.plugin.hudi.HudiSplit; import io.trino.plugin.hudi.HudiTableHandle; @@ -43,23 +42,22 @@ public class HudiSplitFactory private final HudiTableHandle hudiTableHandle; private final HudiSplitWeightProvider hudiSplitWeightProvider; private final DataSize targetSplitSize; - private final CachingHostAddressProvider cachingHostAddressProvider; public HudiSplitFactory( HudiTableHandle hudiTableHandle, HudiSplitWeightProvider hudiSplitWeightProvider, - DataSize targetSplitSize, - CachingHostAddressProvider cachingHostAddressProvider) + DataSize targetSplitSize) { this.hudiTableHandle = requireNonNull(hudiTableHandle, "hudiTableHandle is null"); this.hudiSplitWeightProvider = requireNonNull(hudiSplitWeightProvider, "hudiSplitWeightProvider is null"); this.targetSplitSize = requireNonNull(targetSplitSize, "targetSplitSize is null"); - this.cachingHostAddressProvider = requireNonNull(cachingHostAddressProvider, "cachingHostAddressProvider is null"); + // A non-positive target would make split generation loop forever, so reject it here rather than mid-scan + checkArgument(targetSplitSize.toBytes() > 0, "targetSplitSize must be positive: %s", targetSplitSize); } public List createSplits(List partitionKeys, FileSlice fileSlice, String commitTime) { - return createHudiSplits(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider, targetSplitSize, cachingHostAddressProvider); + return createHudiSplits(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider, targetSplitSize); } /** @@ -69,14 +67,13 @@ public List createSplits(List partitionKeys, FileSl *

    * For regular MOR tables, a single split is created for the combination of the base file and its log files. */ - public static List createHudiSplits( + private static List createHudiSplits( HudiTableHandle hudiTableHandle, List partitionKeys, FileSlice fileSlice, String commitTime, HudiSplitWeightProvider hudiSplitWeightProvider, - DataSize targetSplitSize, - CachingHostAddressProvider cachingHostAddressProvider) + DataSize targetSplitSize) { if (fileSlice.isEmpty()) { throw new TrinoException(HUDI_FILESYSTEM_ERROR, format("Not a valid file slice: %s", fileSlice)); @@ -85,9 +82,9 @@ public static List createHudiSplits( if (isCopyOnWriteOrReadOptimized(hudiTableHandle, fileSlice)) { // Handle MERGE_ON_READ tables to be read in read_optimized mode // IMPORTANT: These tables will have a COPY_ON_WRITE table type due to how `HudiTableTypeUtils#fromInputFormat` - return createSplitsForBaseFile(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider, targetSplitSize, cachingHostAddressProvider); + return createSplitsForBaseFile(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider, targetSplitSize); } - return createSplitForMergeOnRead(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider, cachingHostAddressProvider); + return createSplitForMergeOnRead(hudiTableHandle, partitionKeys, fileSlice, commitTime, hudiSplitWeightProvider); } /** @@ -108,15 +105,15 @@ private static List createSplitsForBaseFile( FileSlice fileSlice, String commitTime, HudiSplitWeightProvider hudiSplitWeightProvider, - DataSize targetSplitSize, - CachingHostAddressProvider cachingHostAddressProvider) + DataSize targetSplitSize) { checkArgument(fileSlice.getBaseFile().isPresent(), "Hudi base file must exist if there are no log files in the file slice"); HoodieBaseFile baseFile = fileSlice.getBaseFile().get(); long fileSize = baseFile.getFileSize(); - List addresses = cachingHostAddressProvider.getHosts(baseFile.getPath(), ImmutableList.of()); + // Object-storage reads have no host affinity. + List addresses = ImmutableList.of(); // If the file is empty, create a single split to represent it if (fileSize == 0) { @@ -132,7 +129,9 @@ private static List createSplitsForBaseFile( } ImmutableList.Builder splits = ImmutableList.builder(); - long targetSplitSizeInBytes = Math.max(targetSplitSize.toBytes(), baseFile.getPathInfo().getBlockSize()); + // Slicing is governed solely by the target split size; the block size reported by + // storage is not meaningful on object stores and must not influence split sizing. + long targetSplitSizeInBytes = targetSplitSize.toBytes(); long bytesRemaining = fileSize; while (((double) bytesRemaining) / targetSplitSizeInBytes > SPLIT_SLOP) { @@ -168,14 +167,11 @@ private static List createSplitForMergeOnRead( List partitionKeys, FileSlice fileSlice, String commitTime, - HudiSplitWeightProvider hudiSplitWeightProvider, - CachingHostAddressProvider cachingHostAddressProvider) + HudiSplitWeightProvider hudiSplitWeightProvider) { // NOTE: Some file slices may not have base files Option baseFileOption = fileSlice.getBaseFile(); - List addresses = baseFileOption - .map(baseFile -> cachingHostAddressProvider.getHosts(baseFile.getPath(), ImmutableList.of())) - .orElse(ImmutableList.of()); + List addresses = ImmutableList.of(); HudiSplit split = new HudiSplit( baseFileOption.map(HudiBaseFile::of).orElse(null), diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiSplitWeightProvider.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiSplitWeightProvider.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/HudiSplitWeightProvider.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/split/HudiSplitWeightProvider.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/SizeBasedSplitWeightProvider.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/split/SizeBasedSplitWeightProvider.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/split/SizeBasedSplitWeightProvider.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/split/SizeBasedSplitWeightProvider.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/ForHudiTableStatistics.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/ForHudiTableStatistics.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/ForHudiTableStatistics.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/stats/ForHudiTableStatistics.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/HudiTableStatistics.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/HudiTableStatistics.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/HudiTableStatistics.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/stats/HudiTableStatistics.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java similarity index 50% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java index e87122de07953..20921ea9be9af 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableMetadataReader.java @@ -16,21 +16,14 @@ import org.apache.hudi.avro.model.HoodieMetadataColumnStats; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.engine.HoodieEngineContext; -import org.apache.hudi.common.model.HoodieColumnRangeMetadata; -import org.apache.hudi.common.model.HoodieRecord; -import org.apache.hudi.common.util.HoodieTimer; import org.apache.hudi.common.util.collection.Pair; -import org.apache.hudi.common.util.hash.ColumnIndexID; -import org.apache.hudi.common.util.hash.FileIndexID; -import org.apache.hudi.common.util.hash.PartitionIndexID; import org.apache.hudi.exception.HoodieMetadataException; import org.apache.hudi.metadata.HoodieBackedTableMetadata; -import org.apache.hudi.metadata.HoodieMetadataMetrics; -import org.apache.hudi.metadata.HoodieMetadataPayload; -import org.apache.hudi.metadata.HoodieTableMetadataUtil; -import org.apache.hudi.metadata.MetadataPartitionType; +import org.apache.hudi.stats.HoodieColumnRangeMetadata; +import org.apache.hudi.stats.ValueMetadata; import org.apache.hudi.storage.HoodieStorage; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -55,44 +48,13 @@ public class TableMetadataReader * @return a map from column name to their corresponding {@link HoodieColumnRangeMetadata} * @throws HoodieMetadataException if an error occurs while fetching the column statistics */ - Map getColumnStats(List> partitionNameFileNameList, List columnNames) + Map getColumnRanges(List> partitionNameFileNameList, List columnNames) throws HoodieMetadataException { - return computeFileToColumnStatsMap(computeColumnStatsLookupKeys(partitionNameFileNameList, columnNames)); - } - - /** - * @param partitionNameFileNameList a list of partition and file name pairs for which column stats need to be retrieved - * @param columnNames list of column names for which stats are needed - * @return a list of column stats keys to look up in the metadata table col_stats partition. - */ - private List computeColumnStatsLookupKeys( - final List> partitionNameFileNameList, - final List columnNames) - { - return columnNames.stream() - .flatMap(columnName -> partitionNameFileNameList.stream() - .map(partitionNameFileNamePair -> HoodieMetadataPayload.getColumnStatsIndexKey( - new PartitionIndexID(HoodieTableMetadataUtil.getColumnStatsIndexPartitionIdentifier(partitionNameFileNamePair.getLeft())), - new FileIndexID(partitionNameFileNamePair.getRight()), - new ColumnIndexID(columnName)))) - .toList(); - } - - /** - * @param columnStatsLookupKeys a map from column stats key to partition and file name pair - * @return a map from column name to merged HoodieMetadataColumnStats - */ - private Map computeFileToColumnStatsMap(List columnStatsLookupKeys) - { - HoodieTimer timer = HoodieTimer.start(); - Map> hoodieRecords = - getRecordsByKeys(columnStatsLookupKeys, MetadataPartitionType.COLUMN_STATS.getPartitionPath()); - metrics.ifPresent(m -> m.updateMetrics(HoodieMetadataMetrics.LOOKUP_COLUMN_STATS_METADATA_STR, timer.endTimer())); - return hoodieRecords.values().stream() - .collect(Collectors.groupingBy( - r -> r.getData().getColumnStatMetadata().get().getColumnName(), - Collectors.mapping(r -> r.getData().getColumnStatMetadata().get(), Collectors.toList()))) + Map, List> columnStatsMap = getColumnStats(partitionNameFileNameList, columnNames); + return columnStatsMap.values().stream() + .flatMap(Collection::stream) + .collect(Collectors.groupingBy(HoodieMetadataColumnStats::getColumnName, Collectors.toList())) .entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, @@ -108,7 +70,7 @@ private Map computeFileToColumnStatsMap(List< totalUncompressedSize += stats.getTotalUncompressedSize(); } return HoodieColumnRangeMetadata.create( - "", e.getKey(), null, null, nullCount, valueCount, totalSize, totalUncompressedSize); + "", e.getKey(), null, null, nullCount, valueCount, totalSize, totalUncompressedSize, ValueMetadata.NULL_METADATA); })); } } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java similarity index 97% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java index 55d424db40caa..00261f9f78471 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/stats/TableStatisticsReader.java @@ -24,11 +24,11 @@ import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.engine.HoodieLocalEngineContext; -import org.apache.hudi.common.model.HoodieColumnRangeMetadata; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.stats.HoodieColumnRangeMetadata; import java.util.List; import java.util.Map; @@ -113,6 +113,6 @@ private static Map getColumnStats( .stream().flatMap(entry -> entry.getValue() .map(baseFile -> Pair.of(entry.getKey(), baseFile.getFileName()))) .toList(); - return tableMetadata.getColumnStats(filePaths, columnNames); + return tableMetadata.getColumnRanges(filePaths, columnNames); } } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoInlineStorage.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoInlineStorage.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoInlineStorage.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoInlineStorage.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java similarity index 97% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java index 48c5409c10d83..1edff63e8a7b5 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/storage/HudiTrinoStorage.java @@ -71,7 +71,7 @@ public static StoragePathInfo convertToPathInfo(FileEntry fileEntry) fileEntry.length(), false, (short) 0, - 0, + fileEntry.length(), fileEntry.lastModified().toEpochMilli()); } @@ -170,7 +170,8 @@ public StoragePathInfo getPathInfo(StoragePath path) if (!inputFile.exists()) { throw new FileNotFoundException("Path " + path + " does not exist"); } - return new StoragePathInfo(path, inputFile.length(), false, (short) 0, 0, inputFile.lastModified().toEpochMilli()); + long length = inputFile.length(); + return new StoragePathInfo(path, length, false, (short) 0, length, inputFile.lastModified().toEpochMilli()); } @Override diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/TrinoStorageConfiguration.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/storage/TrinoStorageConfiguration.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/storage/TrinoStorageConfiguration.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/storage/TrinoStorageConfiguration.java diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java similarity index 76% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java index 2fc020020252e..eed684808df61 100644 --- a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java @@ -41,7 +41,6 @@ import io.trino.spi.type.Type; import io.trino.spi.type.VarbinaryType; import io.trino.spi.type.VarcharType; -import org.apache.avro.Conversions; import org.apache.avro.Schema; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; @@ -49,6 +48,7 @@ import org.apache.avro.util.Utf8; import java.math.BigDecimal; +import java.math.BigInteger; import java.nio.ByteBuffer; import java.time.DateTimeException; import java.time.Instant; @@ -60,15 +60,14 @@ import java.util.List; import java.util.Map; +import static com.google.common.base.Preconditions.checkState; import static com.google.common.base.Verify.verify; import static io.airlift.slice.Slices.utf8Slice; -import static io.trino.plugin.hudi.HudiUtil.constructSchema; import static io.trino.plugin.hudi.HudiUtil.getFieldFromSchema; import static io.trino.spi.StandardErrorCode.GENERIC_INTERNAL_ERROR; import static io.trino.spi.StandardErrorCode.NUMERIC_VALUE_OUT_OF_RANGE; import static io.trino.spi.type.BigintType.BIGINT; import static io.trino.spi.type.DateType.DATE; -import static io.trino.spi.type.Decimals.encodeShortScaledValue; import static io.trino.spi.type.Decimals.writeBigDecimal; import static io.trino.spi.type.Decimals.writeShortDecimal; import static io.trino.spi.type.IntegerType.INTEGER; @@ -106,56 +105,135 @@ public class HudiAvroSerializer 1, // 9 digits after the dot }; - private static final AvroDecimalConverter DECIMAL_CONVERTER = new AvroDecimalConverter(); - private final SynthesizedColumnHandler synthesizedColumnHandler; + private final PrefilledColumnValues prefilledColumnValues; private final List columnHandles; private final List columnTypes; + // Both are null for a page-building-only serializer (the two-arg constructor): buildRecordInPage + // reads field positions off each record's own schema, so no record schema is needed there -- and + // none could be built for hidden (synthesized) columns, which are answered from the split by + // PrefilledColumnValues rather than read from the file. serialize() requires the three-arg + // constructor, which maps page channel i to record position channelToFieldPosition[i]. private final Schema schema; + private final int[] channelToFieldPosition; + // Per channel, whether the mapped record field is an Avro string; see serialize(). Null for the + // page-building-only serializer, which never calls serialize(). + private final boolean[] channelIsStringField; + // Single-entry cache for buildRecordInPage: all records of a split share one schema instance, + // so an identity check makes the per-record, per-column field-name lookup a one-time cost. + // Prefilled (hidden/synthesized) columns are not fields of the record schema; they get -1. + private Schema positionsCacheSchema; + private int[] positionsCache; + + public HudiAvroSerializer(List columnHandles, PrefilledColumnValues prefilledColumnValues) + { + this.columnHandles = columnHandles; + this.columnTypes = columnHandles.stream().map(HiveColumnHandle::getType).toList(); + this.prefilledColumnValues = prefilledColumnValues; + this.schema = null; + this.channelToFieldPosition = null; + this.channelIsStringField = null; + } - public HudiAvroSerializer(List columnHandles, SynthesizedColumnHandler synthesizedColumnHandler) + /** + * Builds a serializer whose {@link #serialize} records carry {@code recordSchema} -- the exact + * schema hudi-common tracks for the records of this read (the file-group reader's required + * schema) -- instead of a schema reconstructed from the projection's Hive types. The + * reconstruction differs from the table's real schema (every Hive column becomes a nullable + * union, fields follow projection order), and payload-based merging round-trips the record + * through Avro BINARY with the tracked schema ({@code BaseAvroPayload}), where any structural + * difference misaligns the decode and yields garbage values. Page channels are matched to + * record fields BY NAME, so the projection may order columns differently from the schema; + * every projected column must be a field of {@code recordSchema}. + */ + public HudiAvroSerializer(List columnHandles, PrefilledColumnValues prefilledColumnValues, Schema recordSchema) { this.columnHandles = columnHandles; this.columnTypes = columnHandles.stream().map(HiveColumnHandle::getType).toList(); - // Fetches projected schema - this.schema = constructSchema(columnHandles.stream().filter(ch -> !ch.isHidden()).map(HiveColumnHandle::getName).toList(), - columnHandles.stream().filter(ch -> !ch.isHidden()).map(HiveColumnHandle::getHiveType).toList()); - this.synthesizedColumnHandler = synthesizedColumnHandler; + this.schema = recordSchema; + this.prefilledColumnValues = prefilledColumnValues; + int[] mapping = new int[columnHandles.size()]; + for (int i = 0; i < columnHandles.size(); i++) { + mapping[i] = getFieldFromSchema(columnHandles.get(i).getName(), recordSchema).pos(); + } + this.channelToFieldPosition = mapping; + boolean[] stringFields = new boolean[columnHandles.size()]; + for (int i = 0; i < columnHandles.size(); i++) { + stringFields[i] = isStringField(recordSchema.getFields().get(mapping[i])); + } + this.channelIsStringField = stringFields; + } + + /** + * Whether an Avro record field holds a string, looking through a nullable union. + */ + private static boolean isStringField(Schema.Field field) + { + Schema fieldSchema = field.schema(); + if (fieldSchema.getType() == Schema.Type.UNION) { + return fieldSchema.getTypes().stream().anyMatch(branch -> branch.getType() == Schema.Type.STRING); + } + return fieldSchema.getType() == Schema.Type.STRING; } public IndexedRecord serialize(Page sourcePage, int position) { + checkState(schema != null, "serialize() requires a serializer built with a record schema"); IndexedRecord record = new GenericData.Record(schema); for (int i = 0; i < columnTypes.size(); i++) { Object value = getValue(sourcePage, i, position); - record.put(i, value); + // Trino hands back a java.lang.String for VARCHAR/CHAR, but Avro's own decoder produces Utf8, + // so records read out of a classic Avro log block carry Utf8 for the same field. Records from + // the two sides meet in the file-group reader -- BufferedRecordMergerFactory#shouldKeepNewerRecord + // compares their ordering values directly -- and Utf8.compareTo casts its argument to Utf8, so a + // String ordering value from the base side throws ClassCastException against a Utf8 from the log + // side. Emit Avro's representation here so every record of a merge carries the same type. + if (channelIsStringField[i] && value instanceof String stringValue) { + value = new Utf8(stringValue); + } + record.put(channelToFieldPosition[i], value); } return record; } public Object getValue(Page sourcePage, int channel, int position) { - return columnTypes.get(channel).getObjectValue(null, sourcePage.getBlock(channel), position); + return columnTypes.get(channel).getObjectValue(sourcePage.getBlock(channel), position); } public void buildRecordInPage(PageBuilder pageBuilder, IndexedRecord record) { pageBuilder.declarePosition(); - int blockSeq = 0; - for (int channel = 0; channel < columnTypes.size(); channel++, blockSeq++) { - BlockBuilder output = pageBuilder.getBlockBuilder(blockSeq); - HiveColumnHandle columnHandle = columnHandles.get(channel); - if (synthesizedColumnHandler.isSynthesizedColumn(columnHandle)) { - synthesizedColumnHandler.getColumnStrategy(columnHandle).appendToBlock(output, columnTypes.get(channel)); + // Record may not be projected, get field positions from its own schema + int[] fieldPositions = fieldPositionsFor(record.getSchema()); + for (int channel = 0; channel < columnTypes.size(); channel++) { + BlockBuilder output = pageBuilder.getBlockBuilder(channel); + int fieldPosition = fieldPositions[channel]; + if (fieldPosition < 0) { + prefilledColumnValues.appendTo(columnHandles.get(channel), output); } else { - // Record may not be projected, get index from it - int fieldPosInSchema = getFieldFromSchema(columnHandle.getName(), record.getSchema()).pos(); - appendTo(columnTypes.get(channel), record.get(fieldPosInSchema), output); + appendTo(columnTypes.get(channel), record.get(fieldPosition), output); } } } + private int[] fieldPositionsFor(Schema recordSchema) + { + if (positionsCacheSchema != recordSchema) { + int[] positions = new int[columnHandles.size()]; + for (int channel = 0; channel < columnHandles.size(); channel++) { + HiveColumnHandle columnHandle = columnHandles.get(channel); + positions[channel] = prefilledColumnValues.isPrefilled(columnHandle) + ? -1 + : getFieldFromSchema(columnHandle.getName(), recordSchema).pos(); + } + positionsCache = positions; + positionsCacheSchema = recordSchema; + } + return positionsCache; + } + public static void appendTo(Type type, Object value, BlockBuilder output) { if (value == null) { @@ -211,8 +289,13 @@ else if (type instanceof DecimalType decimalType) { } else if (value instanceof GenericData.Fixed fixed) { verify(decimalType.isShort(), "The type should be short decimal"); - BigDecimal decimal = DECIMAL_CONVERTER.convert(decimalType.getPrecision(), decimalType.getScale(), fixed.bytes()); - type.writeLong(output, encodeShortScaledValue(decimal, decimalType.getScale())); + // Avro stores a decimal as its unscaled value in big-endian two's complement, which is + // exactly what Trino's short decimal holds. Going through Avro's DecimalConversion and + // Decimals.encodeShortScaledValue is a no-op round trip: DecimalConversion.fromBytes reads + // only the scale (it ignores precision, and its schema argument entirely) to build + // BigDecimal(unscaled, scale), and encodeShortScaledValue then calls setScale to that same + // scale, which returns the BigDecimal unchanged, before taking the unscaled value back out. + type.writeLong(output, new BigInteger(fixed.bytes()).longValueExact()); } else { throw new TrinoException(GENERIC_INTERNAL_ERROR, @@ -464,7 +547,9 @@ private static void writeRow(RowBlockBuilder output, RowType rowType, GenericRec output.buildEntry(fieldBuilders -> { for (int index = 0; index < fields.size(); index++) { RowType.Field field = fields.get(index); - appendTo(field.getType(), record.get(field.getName().orElse("field" + index)), fieldBuilders.get(index)); + int fieldIndex = index; + String fieldName = field.getName().orElseGet(() -> "field" + fieldIndex); + appendTo(field.getType(), record.get(fieldName), fieldBuilders.get(index)); } }); } @@ -491,15 +576,4 @@ private static void writeMap(MapBlockBuilder output, MapType mapType, Map } }); } - - static class AvroDecimalConverter - { - private static final Conversions.DecimalConversion AVRO_DECIMAL_CONVERSION = new Conversions.DecimalConversion(); - - BigDecimal convert(int precision, int scale, byte[] bytes) - { - Schema schema = new Schema.Parser().parse(format("{\"type\":\"bytes\",\"logicalType\":\"decimal\",\"precision\":%d,\"scale\":%d}", precision, scale)); - return AVRO_DECIMAL_CONVERSION.fromBytes(ByteBuffer.wrap(bytes), schema, schema.getLogicalType()); - } - } } diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/HudiTableTypeUtils.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiTableTypeUtils.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/HudiTableTypeUtils.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiTableTypeUtils.java diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java new file mode 100644 index 0000000000000..c55d46956c279 --- /dev/null +++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java @@ -0,0 +1,155 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.util; + +import com.google.common.collect.ImmutableMap; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HivePartitionKey; +import io.trino.plugin.hudi.HudiSplit; +import io.trino.plugin.hudi.file.HudiFile; +import io.trino.spi.block.Block; +import io.trino.spi.block.BlockBuilder; +import io.trino.spi.block.RunLengthEncodedBlock; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import static io.trino.metastore.Partitions.makePartName; +import static io.trino.plugin.hive.HiveColumnHandle.isFileModifiedTimeColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.isFileSizeColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.isPartitionColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.isPathColumnHandle; +import static io.trino.plugin.hive.util.HiveUtil.getPrefilledColumnValue; +import static io.trino.spi.type.TypeUtils.writeNativeValue; + +/** + * Per-split constant values for the output columns that are not stored in the data file: Hive-style + * partition columns and Trino's hidden metadata columns ({@code $path}, {@code $file_size}, + * {@code $file_modified_time}, {@code $partition}). Value computation delegates to + * {@link io.trino.plugin.hive.util.HiveUtil#getPrefilledColumnValue}, the same implementation the Hive + * connector uses for these columns (including the {@code "\N"} hive-null partition-value convention). + * Note {@code $file_modified_time} is packed with the JVM default zone, as in the Hive connector; the + * replaced Hudi-specific code pinned UTC (same instant, different rendered zone). + */ +public class PrefilledColumnValues +{ + // Absent-marker for the memo, so a not-yet-resolved column is distinguishable from one resolved to null + private static final Object UNRESOLVED = new Object(); + + private final Map partitionKeysByName; + private final String partitionName; + private final String filePath; + private final long fileSize; + private final long fileModifiedTime; + // Resolved native value per column name, populated lazily. One instance belongs to one split and a + // split is read by a single driver thread, so a plain HashMap is enough; it also has to hold nulls, + // which a ConcurrentHashMap could not. + private final Map resolvedValues = new HashMap<>(); + + public static PrefilledColumnValues create(HudiSplit hudiSplit) + { + return new PrefilledColumnValues(hudiSplit); + } + + private PrefilledColumnValues(HudiSplit hudiSplit) + { + List partitionKeys = hudiSplit.getPartitionKeys(); + // ImmutableMap preserves insertion order, so $partition renders the keys in the split's + // partition-column order. + ImmutableMap.Builder byName = ImmutableMap.builder(); + partitionKeys.forEach(partitionKey -> byName.put(partitionKey.name(), partitionKey)); + this.partitionKeysByName = byName.buildOrThrow(); + this.partitionName = makePartName( + partitionKeys.stream().map(HivePartitionKey::name).toList(), + partitionKeys.stream().map(HivePartitionKey::value).toList()); + // Parquet files will be prioritised over log files + HudiFile hudiFile = hudiSplit.getBaseFile().isPresent() + ? hudiSplit.getBaseFile().get() + : hudiSplit.getLogFiles().getFirst(); + this.filePath = hudiFile.getPath(); + this.fileSize = hudiFile.getFileSize(); + this.fileModifiedTime = hudiFile.getModificationTime(); + } + + /** + * Returns whether this split can provide a value for the column, i.e. it is a partition column of + * the split or a hidden metadata column Hudi populates. + */ + public boolean isPrefilled(HiveColumnHandle columnHandle) + { + return partitionKeysByName.containsKey(columnHandle.getName()) + || isPathColumnHandle(columnHandle) + || isFileSizeColumnHandle(columnHandle) + || isFileModifiedTimeColumnHandle(columnHandle) + || isPartitionColumnHandle(columnHandle); + } + + /** + * Appends the column's value for this split to the builder; appends null for a column this split + * cannot provide. + */ + public void appendTo(HiveColumnHandle columnHandle, BlockBuilder blockBuilder) + { + writeNativeValue(columnHandle.getType(), blockBuilder, nativeValueOf(columnHandle)); + } + + /** + * Builds a run-length-encoded {@link Block} repeating the column's constant value for + * {@code positionCount} positions (null-filled for a column this split cannot provide). + */ + public Block toRleBlock(HiveColumnHandle columnHandle, int positionCount) + { + return RunLengthEncodedBlock.create(columnHandle.getType(), nativeValueOf(columnHandle), positionCount); + } + + private Object nativeValueOf(HiveColumnHandle columnHandle) + { + // Every input to computeNativeValue() is a constant of the split, but appendTo is called once per + // prefilled column per record, and computing re-parses the partition string each time + // ($file_modified_time even formats a timestamp and parses it straight back). Memoize per column so + // each one is resolved once per split. Keyed on the name rather than the handle because + // HiveColumnHandle.hashCode hashes seven fields through a varargs array, whereas a String caches + // its hash. A sentinel rather than a null check, because null is a legitimate resolved value -- both + // for the hive-null convention and for the lenient fallback below -- and getOrDefault keeps the hit + // path, the one taken per record, to a single hash lookup. + String name = columnHandle.getName(); + Object value = resolvedValues.getOrDefault(name, UNRESOLVED); + if (value == UNRESOLVED) { + value = computeNativeValue(columnHandle); + resolvedValues.put(name, value); + } + return value; + } + + private Object computeNativeValue(HiveColumnHandle columnHandle) + { + if (!isPrefilled(columnHandle)) { + // Lenient null fill, e.g. for a hidden column Trino defines but Hudi does not populate. + return null; + } + HivePartitionKey partitionKey = partitionKeysByName.get(columnHandle.getName()); + return getPrefilledColumnValue( + columnHandle, + partitionKey, + filePath, + // Hudi tables are never hive-bucketed, so no $bucket value can be requested here + OptionalInt.empty(), + fileSize, + fileModifiedTime, + partitionName) + .getValue(); + } +} diff --git a/hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/TupleDomainUtils.java b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/TupleDomainUtils.java similarity index 100% rename from hudi-trino-plugin/src/main/java/io/trino/plugin/hudi/util/TupleDomainUtils.java rename to hudi-trino/src/main/java/io/trino/plugin/hudi/util/TupleDomainUtils.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/BaseHudiConnectorSmokeTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/BaseHudiConnectorSmokeTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/BaseHudiConnectorSmokeTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/BaseHudiConnectorSmokeTest.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java similarity index 97% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java index 0114eb8400a2a..e11efbf3a569a 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/HudiQueryRunner.java @@ -35,9 +35,9 @@ import java.util.Optional; import static io.trino.testing.TestingSession.testSessionBuilder; -import static io.trino.testing.containers.Minio.MINIO_ACCESS_KEY; +import static io.trino.testing.containers.Minio.MINIO_ROOT_USER; import static io.trino.testing.containers.Minio.MINIO_REGION; -import static io.trino.testing.containers.Minio.MINIO_SECRET_KEY; +import static io.trino.testing.containers.Minio.MINIO_ROOT_PASSWORD; import static java.util.Objects.requireNonNull; public final class HudiQueryRunner @@ -60,8 +60,8 @@ public static Builder builder(Hive3MinioDataLake hiveMinioDataLake) { return new Builder("s3://" + hiveMinioDataLake.getBucketName() + "/") .addConnectorProperty("fs.native-s3.enabled", "true") - .addConnectorProperty("s3.aws-access-key", MINIO_ACCESS_KEY) - .addConnectorProperty("s3.aws-secret-key", MINIO_SECRET_KEY) + .addConnectorProperty("s3.aws-access-key", MINIO_ROOT_USER) + .addConnectorProperty("s3.aws-secret-key", MINIO_ROOT_PASSWORD) .addConnectorProperty("s3.region", MINIO_REGION) .addConnectorProperty("s3.endpoint", hiveMinioDataLake.getMinio().getMinioAddress()) .addConnectorProperty("s3.path-style-access", "true"); diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/HudiUtilTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/HudiUtilTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/HudiUtilTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/HudiUtilTest.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/SessionBuilder.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/SessionBuilder.java similarity index 86% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/SessionBuilder.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/SessionBuilder.java index aa26d2d35f8e9..8ddc1ab4d4691 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/SessionBuilder.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/SessionBuilder.java @@ -15,6 +15,9 @@ import io.trino.Session; +import java.util.Arrays; +import java.util.stream.Collectors; + import static io.trino.SystemSessionProperties.ENABLE_DYNAMIC_FILTERING; import static io.trino.SystemSessionProperties.JOIN_DISTRIBUTION_TYPE; import static io.trino.plugin.hudi.HudiSessionProperties.COLUMN_STATS_INDEX_ENABLED; @@ -24,7 +27,9 @@ import static io.trino.plugin.hudi.HudiSessionProperties.PARTITION_STATS_INDEX_ENABLED; import static io.trino.plugin.hudi.HudiSessionProperties.QUERY_PARTITION_FILTER_REQUIRED; import static io.trino.plugin.hudi.HudiSessionProperties.RECORD_INDEX_WAIT_TIMEOUT; +import static io.trino.plugin.hudi.HudiSessionProperties.RECORD_MERGER_IMPLS; import static io.trino.plugin.hudi.HudiSessionProperties.RECORD_LEVEL_INDEX_ENABLED; +import static io.trino.plugin.hudi.HudiSessionProperties.RESOLVE_COLUMN_NAME_CASING_ENABLED; import static io.trino.plugin.hudi.HudiSessionProperties.SECONDARY_INDEX_ENABLED; import static io.trino.plugin.hudi.HudiSessionProperties.SECONDARY_INDEX_WAIT_TIMEOUT; import static io.trino.plugin.hudi.HudiSessionProperties.TABLE_STATISTICS_ENABLED; @@ -140,4 +145,20 @@ public SessionBuilder withRecordIndexTimeout(String durationProp) { return setCatalogProperty(RECORD_INDEX_WAIT_TIMEOUT, durationProp); } + + /** + * Sets {@code record_merger_impls}. The property is an array type, so the value is rendered as a JSON list. + */ + public SessionBuilder withRecordMergerImpls(String... mergerClassNames) + { + String jsonList = Arrays.stream(mergerClassNames) + .map(name -> '"' + name + '"') + .collect(Collectors.joining(",", "[", "]")); + return setCatalogProperty(RECORD_MERGER_IMPLS, jsonList); + } + + public SessionBuilder withResolveColumnNameCasingEnabled(boolean enabled) + { + return setCatalogProperty(RESOLVE_COLUMN_NAME_CASING_ENABLED, String.valueOf(enabled)); + } } diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java similarity index 56% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java index 4395a9c1ddfff..22f569dcf50de 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCacheFileOperations.java @@ -17,6 +17,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMultiset; import com.google.common.collect.Multiset; +import io.airlift.units.Duration; import io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer; import io.trino.plugin.hudi.util.FileOperationUtils.FileOperation; import io.trino.testing.AbstractTestQueryFramework; @@ -36,15 +37,14 @@ import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; import static io.trino.filesystem.tracing.CacheFileSystemTraceUtils.getCacheOperationSpans; import static io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_MULTI_FG_PT_V8_MOR; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.DATA; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.INDEX_DEFINITION; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.LOG; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE_PROPERTIES; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TABLE_PROPERTIES; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TIMELINE; import static io.trino.testing.MultisetAssertions.assertMultisetsEqual; +import static io.trino.testing.assertions.Assert.assertEventually; import static java.util.stream.Collectors.toCollection; +import static org.assertj.core.api.Assertions.assertThat; @ResourceLock("HUDI_CACHE_SYSTEM") @Execution(ExecutionMode.SAME_THREAD) @@ -66,6 +66,12 @@ protected DistributedQueryRunner createQueryRunner() .put("fs.cache.directories", cacheDirectory.toAbsolutePath().toString()) .put("fs.cache.max-sizes", "100MB") .put("hudi.metadata.cache.enabled", "false") + // Disable the async table-statistics refresh: on the first query it reads the index + // definitions and table-property files (and the metadata table) on a background + // executor. Those non-metadata-table reads land in the asserted set and their timing + // is non-deterministic, so we turn the refresh off and assert only the synchronous + // planning-path reads. + .put("hudi.table-statistics-enabled", "false") .buildOrThrow(); return HudiQueryRunner.builder() @@ -77,20 +83,11 @@ protected DistributedQueryRunner createQueryRunner() @Test public void testSelectWithFilter() - throws InterruptedException { @Language("SQL") String query = "SELECT * FROM " + HUDI_MULTI_FG_PT_V8_MOR + " WHERE country='SG'"; assertFileSystemAccesses( query, ImmutableMultiset.builder() - .addCopies(new FileOperation("Alluxio.readCached", DATA), 2) - .addCopies(new FileOperation("Alluxio.readCached", METADATA_TABLE), 27) - .addCopies(new FileOperation("Alluxio.readCached", TIMELINE), 4) - .addCopies(new FileOperation("Alluxio.readCached", LOG), 15) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 4) - .addCopies(new FileOperation("InputFile.length", METADATA_TABLE), 10) - .addCopies(new FileOperation("InputFile.length", TIMELINE), 2) - .addCopies(new FileOperation("InputFile.length", LOG), 1) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) .add(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) @@ -99,14 +96,6 @@ public void testSelectWithFilter() assertFileSystemAccesses( query, ImmutableMultiset.builder() - .addCopies(new FileOperation("Alluxio.readCached", DATA), 2) - .addCopies(new FileOperation("Alluxio.readCached", METADATA_TABLE), 27) - .addCopies(new FileOperation("Alluxio.readCached", TIMELINE), 4) - .addCopies(new FileOperation("Alluxio.readCached", LOG), 15) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 4) - .addCopies(new FileOperation("InputFile.length", METADATA_TABLE), 10) - .addCopies(new FileOperation("InputFile.length", TIMELINE), 2) - .addCopies(new FileOperation("InputFile.length", LOG), 1) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) .add(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) @@ -115,7 +104,6 @@ public void testSelectWithFilter() @Test public void testJoin() - throws InterruptedException { @Language("SQL") String query = "SELECT t1.id, t1.name, t1.price, t1.ts FROM " + HUDI_MULTI_FG_PT_V8_MOR + " t1 " + @@ -124,67 +112,58 @@ public void testJoin() assertFileSystemAccesses(query, ImmutableMultiset.builder() - .addCopies(new FileOperation("Alluxio.readCached", DATA), 6) - .addCopies(new FileOperation("Alluxio.readCached", METADATA_TABLE), 288) - .addCopies(new FileOperation("Alluxio.readCached", TIMELINE), 8) - .addCopies(new FileOperation("Alluxio.readCached", LOG), 30) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 39) - .addCopies(new FileOperation("InputFile.length", METADATA_TABLE), 93) - .addCopies(new FileOperation("InputFile.length", TIMELINE), 4) - .addCopies(new FileOperation("InputFile.length", LOG), 2) - .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 5) - .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 3) - .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 5) + .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) + .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) + .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) .build()); assertFileSystemAccesses(query, ImmutableMultiset.builder() - .addCopies(new FileOperation("Alluxio.readCached", DATA), 6) - .addCopies(new FileOperation("Alluxio.readCached", METADATA_TABLE), 215) - .addCopies(new FileOperation("Alluxio.readCached", TIMELINE), 8) - .addCopies(new FileOperation("Alluxio.readCached", LOG), 30) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 29) - .addCopies(new FileOperation("InputFile.length", METADATA_TABLE), 69) - .addCopies(new FileOperation("InputFile.length", TIMELINE), 4) - .addCopies(new FileOperation("InputFile.length", LOG), 2) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) .build()); } - private void assertFileSystemAccesses(@Language("SQL") String query, Multiset expectedCacheAccesses) - throws InterruptedException + @Test + public void testReadsServedFromAlluxioCache() { + // The tests above intentionally do not assert exact Alluxio cache hit/miss counts: the cache + // write is asynchronous, so the per-query counts flake (a write from one query can still be in + // flight when the next query runs). This test instead gives count-independent coverage that the + // Alluxio cache is actually engaged: once the cache is warmed, at least one read is served from + // it (an "Alluxio.readCached" span). assertEventually re-runs the query until the asynchronous + // cache write has landed and a genuine hit is observed, and fails loudly at the deadline if the + // cache never serves a read. + @Language("SQL") String query = "SELECT * FROM " + HUDI_MULTI_FG_PT_V8_MOR; DistributedQueryRunner queryRunner = getDistributedQueryRunner(); + + // Warm the cache; the page write into Alluxio happens on a background thread. queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); - // Async table-stats computation can outlive the synchronous query and emit spans into - // the exporter after execute returns. A fixed Thread.sleep races with this — when - // stats from query N is still running while query N+1's measurement happens, spans - // leak across the boundary and counts get scrambled (the symmetric off-by-N failure - // across paired tests). Poll until the span set is stable for two consecutive reads. - Multiset actual = waitForStableSpans(queryRunner); - assertMultisetsEqual(actual, expectedCacheAccesses); + + assertEventually( + Duration.valueOf("30s"), + Duration.valueOf("500ms"), + () -> { + queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); + assertThat(countCachedReads(queryRunner)) + .as("Alluxio.readCached spans (cache hits)") + .isGreaterThanOrEqualTo(1); + }); } - /** - * Returns the file-operation span set once two consecutive reads (200ms apart) agree. - * Bounded by a 30-second ceiling so a runaway test fails loudly instead of hanging. - */ - private static Multiset waitForStableSpans(QueryRunner queryRunner) - throws InterruptedException + private static long countCachedReads(QueryRunner queryRunner) { - long deadlineMillis = System.currentTimeMillis() + 30_000L; - Multiset previous = null; - while (System.currentTimeMillis() < deadlineMillis) { - Thread.sleep(200L); - Multiset current = getFileOperations(queryRunner); - if (previous != null && current.equals(previous)) { - return current; - } - previous = current; - } - return previous != null ? previous : getFileOperations(queryRunner); + return getCacheOperationSpans(queryRunner).stream() + .filter(span -> span.getName().equals("Alluxio.readCached")) + .count(); + } + + private void assertFileSystemAccesses(@Language("SQL") String query, Multiset expectedCacheAccesses) + { + DistributedQueryRunner queryRunner = getDistributedQueryRunner(); + queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); + assertMultisetsEqual(getFileOperations(queryRunner), expectedCacheAccesses); } public static Multiset getFileOperations(QueryRunner queryRunner) @@ -193,6 +172,14 @@ public static Multiset getFileOperations(QueryRunner queryRunner) .stream() .filter(span -> !span.getName().startsWith("InputFile.exists")) .map(FileOperation::create) + // Metadata-table reads are issued from Hudi background pools (split loading, partition + // listing, table-statistics refresh) whose spans can outlive the synchronous query and + // land in the next query's measurement window, so their per-query counts are not + // deterministic. Alluxio cache hits/misses (Alluxio.*) depend on whether an earlier + // asynchronous cache write had already completed, so their counts are not deterministic + // either. Both are excluded; only synchronous foreground reads are asserted. + .filter(operation -> operation.fileType() != METADATA_TABLE) + .filter(operation -> !operation.operationType().startsWith("Alluxio.")) .collect(toCollection(HashMultiset::create)); } } diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCachingSmokeTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCachingSmokeTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCachingSmokeTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiAlluxioCachingSmokeTest.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java similarity index 87% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java index 545693f1ea076..29aeded55ffd0 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConfig.java @@ -17,6 +17,7 @@ import com.google.common.collect.ImmutableMap; import io.airlift.units.DataSize; import io.airlift.units.Duration; +import io.airlift.units.MinDataSize; import org.junit.jupiter.api.Test; import java.util.Map; @@ -24,6 +25,7 @@ import static io.airlift.configuration.testing.ConfigAssertions.assertFullMapping; import static io.airlift.configuration.testing.ConfigAssertions.assertRecordedDefaults; import static io.airlift.configuration.testing.ConfigAssertions.recordDefaults; +import static io.airlift.testing.ValidationAssertions.assertFailsValidation; import static io.airlift.units.DataSize.Unit.MEGABYTE; public class TestHudiConfig @@ -33,6 +35,7 @@ public void testDefaults() { assertRecordedDefaults(recordDefaults(HudiConfig.class) .setColumnsToHide(ImmutableList.of()) + .setRecordMergerImpls(ImmutableList.of()) .setTableStatisticsEnabled(true) .setMetadataEnabled(true) .setUseParquetColumnNames(true) @@ -59,7 +62,7 @@ public void testDefaults() .setSecondaryIndexWaitTimeout(Duration.valueOf("2s")) .setMetadataPartitionListingEnabled(true) .setMetadataCacheEnabled(true) - .setResolveColumnNameCasingEnabled(true)); + .setResolveColumnNameCasingEnabled(false)); } @Test @@ -67,6 +70,7 @@ public void testExplicitPropertyMappings() { Map properties = ImmutableMap.builder() .put("hudi.columns-to-hide", "_hoodie_record_key") + .put("hudi.record-merger-impls", "com.example.MergerOne,com.example.MergerTwo") .put("hudi.table-statistics-enabled", "false") .put("hudi.metadata-enabled", "false") .put("hudi.parquet.use-column-names", "false") @@ -93,11 +97,12 @@ public void testExplicitPropertyMappings() .put("hudi.index.secondary-index.wait-timeout", "1s") .put("hudi.metadata.cache.enabled", "false") .put("hudi.metadata.partition-listing.enabled", "false") - .put("hudi.table.resolve-column-name-casing.enabled", "false") + .put("hudi.table.resolve-column-name-casing.enabled", "true") .buildOrThrow(); HudiConfig expected = new HudiConfig() .setColumnsToHide(ImmutableList.of("_hoodie_record_key")) + .setRecordMergerImpls(ImmutableList.of("com.example.MergerOne", "com.example.MergerTwo")) .setTableStatisticsEnabled(false) .setMetadataEnabled(false) .setUseParquetColumnNames(false) @@ -124,8 +129,19 @@ public void testExplicitPropertyMappings() .setSecondaryIndexWaitTimeout(Duration.valueOf("1s")) .setMetadataPartitionListingEnabled(false) .setMetadataCacheEnabled(false) - .setResolveColumnNameCasingEnabled(false); + .setResolveColumnNameCasingEnabled(true); assertFullMapping(properties, expected); } + + @Test + public void testTargetSplitSizeValidation() + { + // A zero target split size would make split generation loop forever, so reject it at config time + assertFailsValidation( + new HudiConfig().setTargetSplitSize(DataSize.ofBytes(0)), + "targetSplitSize", + "must be greater than or equal to 1B", + MinDataSize.class); + } } diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorFactory.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorFactory.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorFactory.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorFactory.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java new file mode 100644 index 0000000000000..fda63c8b47140 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java @@ -0,0 +1,70 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer; +import io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer; +import io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer.LATE_COLUMN; +import static io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer.SHADOWED_COLUMN; +import static io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer.THRESHOLD; +import static io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer.expectedRowsAboveThreshold; + +public class TestHudiConnectorParquetColumnNamesTest + extends TestHudiSmokeTest +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .addConnectorProperty("hudi.parquet.use-column-names", "false") + // The resource tables all register the Hudi meta columns in the metastore, so their metastore + // ordinals already equal their physical ones and nothing here resolves a stale ordinal. The + // second fixture is the one whose metastore omits them. + .setDataLoader(new CompositeHudiTablesInitializer( + new ResourceHudiTablesInitializer(), + new OmittedMetaColumnsHudiTablesInitializer())) + .build(); + } + + /** + * apache/hudi#19387: with columns resolved positionally, a predicate handle carrying a metastore ordinal has to + * be rebuilt on the file's physical ordinal before it is pushed into the parquet reader. Left unremapped, the + * domain lands on whichever column physically sits at that ordinal -- here {@code shadowed_value}, whose values + * are far below the threshold -- and the only row group is pruned, so the query returns nothing at all. + *

    + * {@code shadowed_value} has to stay in the SELECT list: {@code descriptorsByPath} is derived from the + * projection, so a domain resolving to a column the query does not read finds no descriptor and is dropped + * instead of being misapplied. Narrowing this projection turns the test green against the unfixed code. + *

    + * Only the {@code hudi.parquet.use-column-names=false} suite runs this; the name-based parent resolves the + * predicate by name and was never affected. + */ + @Test + public void testPredicateOnColumnWithStaleMetastoreOrdinal() + { + assertQuery( + "SELECT key, %s, %s FROM %s WHERE %s > %s ORDER BY key".formatted( + SHADOWED_COLUMN, + LATE_COLUMN, + OmittedMetaColumnsHudiTablesInitializer.TABLE_NAME, + LATE_COLUMN, + THRESHOLD), + expectedRowsAboveThreshold()); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java similarity index 89% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java index 84a4cbd604402..acb85a2fe25fb 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorTest.java @@ -49,6 +49,11 @@ protected boolean hasBehavior(TestingConnectorBehavior connectorBehavior) SUPPORTS_DELETE, SUPPORTS_DEREFERENCE_PUSHDOWN, SUPPORTS_INSERT, + // HudiMetadata.applyLimit returns limitGuaranteed=false (multi-split connector + // cannot bound total rows across workers). BaseConnectorTest.testLimitPushdown + // requires Output->TableScan with no Limit node, which needs guaranteed=true. + // Stays off, matching Iceberg / Delta Lake / Hive. + SUPPORTS_LIMIT_PUSHDOWN, SUPPORTS_MERGE, SUPPORTS_RENAME_COLUMN, SUPPORTS_RENAME_TABLE, diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMerger.java new file mode 100644 index 0000000000000..76b4db658941c --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMerger.java @@ -0,0 +1,105 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.collect.ImmutableMap; +import io.trino.Session; +import io.trino.plugin.hudi.testing.CustomMergerHudiTablesInitializer; +import io.trino.plugin.hudi.testing.KeyBasedTestRecordMerger; +import io.trino.plugin.hudi.testing.NonProjectionCompatibleTestRecordMerger; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the Hudi Trino connector resolves and applies a user-supplied custom record merger + * (configured via {@code hudi.record-merger-impls}) for Merge-On-Read tables whose record merge mode is + * {@code CUSTOM}. + *

    + * The test table is generated by {@link CustomMergerHudiTablesInitializer}: two record keys are inserted and + * then upserted so each file group has a base file plus a log file. {@link KeyBasedTestRecordMerger} keeps the + * newer record for keys ending in an odd digit and the older record otherwise, which makes the merged result + * distinguishable from both the read-optimized (base-only) view and the built-in newest-wins behavior: + *

      + *
    • {@code k1} (ends in odd digit): keeps the update -> value 99, name k1_updated
    • + *
    • {@code k2} (ends in even digit): keeps the base -> value 100, name k2_base
    • + *
    + * So on the real-time table {@code sum(value)} is 199 (99 + 100), whereas the base-only view yields 110 + * (10 + 100) and the built-in newest-wins would yield 104 (99 + 5). + */ +public class TestHudiCustomMerger + extends AbstractTestQueryFramework +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .setDataLoader(new CustomMergerHudiTablesInitializer()) + .addConnectorProperties(ImmutableMap.of( + "hudi.record-merger-impls", KeyBasedTestRecordMerger.class.getName())) + .build(); + } + + @Test + public void testReadOptimizedTableReturnsBaseFileValues() + { + // The read-optimized table reads base files only, so it reflects the initial insert. + assertQuery( + "SELECT key, name, value FROM " + CustomMergerHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + assertThat(computeScalar("SELECT sum(value) FROM " + CustomMergerHudiTablesInitializer.TABLE_NAME)) + .isEqualTo(110L); + } + + @Test + public void testRealtimeTableAppliesCustomMerger() + { + // The real-time table merges base + log files. With the key-based custom merger: + // - k1 (odd) keeps the update (value 99, name k1_updated) -> proves merging happened (base was 10) + // - k2 (even) keeps the base (value 100, name k2_base) -> proves it is the custom rule, not newest-wins + assertQuery( + "SELECT key, name, value FROM " + CustomMergerHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(99 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } + + @Test + public void testRealtimeTableSumIsDistinctFromBaseAndNewestWins() + { + // 199 uniquely identifies the custom merger: base-only would be 110, built-in newest-wins would be 104. + assertThat(computeScalar("SELECT sum(value) FROM " + CustomMergerHudiTablesInitializer.RT_TABLE_NAME)) + .isEqualTo(199L); + } + + @Test + public void testNonProjectionCompatibleMergerMergesWithFullTableSchema() + { + // A merger that is not projection compatible makes the file-group reader ask for the FULL table + // schema, so base and log reads must resolve data columns outside the query projection. + // sum(value) projects neither key nor name, yet 199 proves the key-based merge still ran: + // base-only would be 110 and the built-in newest-wins 104. + Session session = SessionBuilder.from(getSession()) + .withRecordMergerImpls(NonProjectionCompatibleTestRecordMerger.class.getName()) + .build(); + assertThat(computeScalar(session, "SELECT sum(value) FROM " + CustomMergerHudiTablesInitializer.RT_TABLE_NAME)) + .isEqualTo(199L); + // Row-level check: the same merge decisions hold for every column. + assertQuery( + session, + "SELECT key, name, value FROM " + CustomMergerHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(99 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMergerEndToEnd.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMergerEndToEnd.java new file mode 100644 index 0000000000000..fbd4b186556ec --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiCustomMergerEndToEnd.java @@ -0,0 +1,128 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.collect.ImmutableMap; +import io.trino.plugin.hudi.testing.IncrementalCustomMergerHudiTablesInitializer; +import io.trino.plugin.hudi.testing.MaxRankRecordMerger; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.MaterializedRow; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static io.trino.plugin.hudi.testing.IncrementalCustomMergerHudiTablesInitializer.RT_TABLE_NAME; +import static io.trino.plugin.hudi.testing.IncrementalCustomMergerHudiTablesInitializer.TABLE_NAME; +import static io.trino.plugin.hudi.testing.IncrementalCustomMergerHudiTablesInitializer.TOTAL_COMMITS; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the custom record merger ({@link MaxRankRecordMerger}, configured via + * {@code hudi.record-merger-impls}) on a 30-column Merge-On-Read table with {@link + * IncrementalCustomMergerHudiTablesInitializer#NUM_RECORDS} records. + *

    + * The table is written one commit at a time (a bulk insert followed by {@link + * IncrementalCustomMergerHudiTablesInitializer#TOTAL_COMMITS} - 1 upserts). After every commit the real-time + * table is read back through Trino and every column of every record is compared against the closed-form expected + * merge result. The expectation is computed independently of the connector by folding the same max-rank policy + * over the committed values, so a regression in merger resolution, merge ordering, or column handling fails the + * assertion. + */ +public class TestHudiCustomMergerEndToEnd + extends AbstractTestQueryFramework +{ + private IncrementalCustomMergerHudiTablesInitializer writer; + + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + writer = new IncrementalCustomMergerHudiTablesInitializer(); + return HudiQueryRunner.builder() + .setDataLoader(writer) + .addConnectorProperties(ImmutableMap.of( + "hudi.record-merger-impls", MaxRankRecordMerger.class.getName())) + .build(); + } + + @AfterAll + public void tearDown() + throws Exception + { + if (writer != null) { + writer.close(); + writer = null; + } + } + + @Test + public void testCustomMergerValidatedAfterEveryCommit() + { + // The first commit was written and synced by initializeTables; validate it, then drive the remaining commits. + validateRows(RT_TABLE_NAME, writer.expectedRows()); + for (int commit = 2; commit <= TOTAL_COMMITS; commit++) { + writer.writeAndSyncNextCommit(); + validateRows(RT_TABLE_NAME, writer.expectedRows()); + } + // Sanity check that the data actually distinguishes the custom merge from built-in newest-wins: + // for many keys the winning record is not the most recently committed one. + assertThat(writer.divergentKeyCount()).isGreaterThan(0); + + // Regression check for projection pushdown: a query that does NOT project the merge column + // (merge_rank) must still merge correctly, because MaxRankRecordMerger declares merge_rank as a + // mandatory merge field, so the reader includes it in the read schema even though it is not selected. + // Without that, the merger would read a pruned (null) merge_rank and fail / mis-merge. + int s0Index = writer.dataColumnNames().indexOf("s0"); + List projectedRows = computeActual( + "SELECT key, s0 FROM " + RT_TABLE_NAME + " ORDER BY key").getMaterializedRows(); + Map expected = writer.expectedRows(); + assertThat(projectedRows).hasSize(expected.size()); + for (MaterializedRow row : projectedRows) { + Object[] expectedRow = expected.get((String) row.getField(0)); + assertThat(expectedRow).as("unexpected key %s", row.getField(0)).isNotNull(); + assertThat(row.getField(1)).as("key %s, column s0", row.getField(0)).isEqualTo(expectedRow[s0Index]); + } + } + + @Test + public void testReadOptimizedReflectsBaseFilesOnly() + { + // The read-optimized table reads base files only, so it always reflects the first commit regardless of + // how many delta commits have since been written. + validateRows(TABLE_NAME, writer.baseRows()); + } + + private void validateRows(String table, Map expected) + { + List columns = writer.dataColumnNames(); + List rows = computeActual( + "SELECT " + String.join(", ", columns) + " FROM " + table + " ORDER BY key") + .getMaterializedRows(); + + assertThat(rows).hasSize(expected.size()); + for (MaterializedRow row : rows) { + String key = (String) row.getField(0); + Object[] expectedRow = expected.get(key); + assertThat(expectedRow).as("unexpected key %s in table %s", key, table).isNotNull(); + for (int i = 0; i < expectedRow.length; i++) { + assertThat(row.getField(i)) + .as("table %s, key %s, column %s", table, key, columns.get(i)) + .isEqualTo(expectedRow[i]); + } + } + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java similarity index 62% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java index ed362391b017a..b5ada57414115 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMemoryCacheFileOperations.java @@ -35,11 +35,9 @@ import static io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_MULTI_FG_PT_V8_MOR; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.DATA; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.INDEX_DEFINITION; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.LOG; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE_PROPERTIES; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TABLE_PROPERTIES; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TIMELINE; import static io.trino.testing.MultisetAssertions.assertMultisetsEqual; import static java.util.stream.Collectors.toCollection; @@ -56,6 +54,12 @@ protected DistributedQueryRunner createQueryRunner() .put("hudi.metadata-enabled", "true") .put("hudi.metadata.cache.enabled", "true") .put("fs.cache.enabled", "false") + // Disable the async table-statistics refresh: on the first query it reads the index + // definitions and table-property files (and the metadata table) on a background + // executor. Those non-metadata-table reads land in the asserted set and their timing + // is non-deterministic, so we turn the refresh off and assert only the synchronous + // planning-path reads. + .put("hudi.table-statistics-enabled", "false") .buildOrThrow(); return HudiQueryRunner.builder() @@ -67,18 +71,12 @@ protected DistributedQueryRunner createQueryRunner() @Test public void testSelectWithFilter() - throws InterruptedException { @Language("SQL") String query = "SELECT * FROM " + HUDI_MULTI_FG_PT_V8_MOR + " WHERE country='SG'"; assertFileSystemAccesses( query, ImmutableMultiset.builder() .addCopies(new FileOperation("FileSystemCache.cacheInput", DATA), 2) - .addCopies(new FileOperation("FileSystemCache.cacheLength", METADATA_TABLE), 4) - .addCopies(new FileOperation("FileSystemCache.cacheStream", METADATA_TABLE), 6) - .addCopies(new FileOperation("FileSystemCache.cacheStream", TIMELINE), 2) - .addCopies(new FileOperation("FileSystemCache.cacheStream", LOG), 1) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 4) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) .add(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) @@ -88,11 +86,6 @@ public void testSelectWithFilter() query, ImmutableMultiset.builder() .addCopies(new FileOperation("FileSystemCache.cacheInput", DATA), 2) - .addCopies(new FileOperation("FileSystemCache.cacheLength", METADATA_TABLE), 4) - .addCopies(new FileOperation("FileSystemCache.cacheStream", METADATA_TABLE), 6) - .addCopies(new FileOperation("FileSystemCache.cacheStream", TIMELINE), 2) - .addCopies(new FileOperation("FileSystemCache.cacheStream", LOG), 1) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 4) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) .add(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) @@ -101,7 +94,6 @@ public void testSelectWithFilter() @Test public void testJoin() - throws InterruptedException { @Language("SQL") String query = "SELECT t1.id, t1.name, t1.price, t1.ts FROM " + HUDI_MULTI_FG_PT_V8_MOR + " t1 " + @@ -111,24 +103,14 @@ public void testJoin() assertFileSystemAccesses(query, ImmutableMultiset.builder() .addCopies(new FileOperation("FileSystemCache.cacheInput", DATA), 6) - .addCopies(new FileOperation("FileSystemCache.cacheLength", METADATA_TABLE), 39) - .addCopies(new FileOperation("FileSystemCache.cacheStream", METADATA_TABLE), 54) - .addCopies(new FileOperation("FileSystemCache.cacheStream", TIMELINE), 4) - .addCopies(new FileOperation("FileSystemCache.cacheStream", LOG), 2) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 39) - .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 5) - .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 3) - .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 5) + .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) + .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) + .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) .build()); assertFileSystemAccesses(query, ImmutableMultiset.builder() .addCopies(new FileOperation("FileSystemCache.cacheInput", DATA), 6) - .addCopies(new FileOperation("FileSystemCache.cacheLength", METADATA_TABLE), 29) - .addCopies(new FileOperation("FileSystemCache.cacheStream", METADATA_TABLE), 40) - .addCopies(new FileOperation("FileSystemCache.cacheStream", TIMELINE), 4) - .addCopies(new FileOperation("FileSystemCache.cacheStream", LOG), 2) - .addCopies(new FileOperation("InputFile.lastModified", METADATA_TABLE), 29) .addCopies(new FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) .addCopies(new FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) .addCopies(new FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) @@ -136,37 +118,10 @@ public void testJoin() } private void assertFileSystemAccesses(@Language("SQL") String query, Multiset expectedCacheAccesses) - throws InterruptedException { DistributedQueryRunner queryRunner = getDistributedQueryRunner(); queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); - // Async table-stats computation can outlive the synchronous query and emit spans into - // the exporter after execute returns. A fixed Thread.sleep races with this — when - // stats from query N is still running while query N+1's measurement happens, spans - // leak across the boundary and counts get scrambled (the symmetric off-by-N failure - // across paired tests). Poll until the span set is stable for two consecutive reads. - Multiset actual = waitForStableSpans(queryRunner); - assertMultisetsEqual(actual, expectedCacheAccesses); - } - - /** - * Returns the file-operation span set once two consecutive reads (200ms apart) agree. - * Bounded by a 30-second ceiling so a runaway test fails loudly instead of hanging. - */ - private static Multiset waitForStableSpans(QueryRunner queryRunner) - throws InterruptedException - { - long deadlineMillis = System.currentTimeMillis() + 30_000L; - Multiset previous = null; - while (System.currentTimeMillis() < deadlineMillis) { - Thread.sleep(200L); - Multiset current = getFileOperations(queryRunner); - if (previous != null && current.equals(previous)) { - return current; - } - previous = current; - } - return previous != null ? previous : getFileOperations(queryRunner); + assertMultisetsEqual(getFileOperations(queryRunner), expectedCacheAccesses); } private static Multiset getFileOperations(QueryRunner queryRunner) @@ -177,6 +132,11 @@ private static Multiset getFileOperations(QueryRunner queryRunner .filter(span -> !span.getName().startsWith("InputFile.exists")) .filter(span -> !isTrinoSchemaOrPermissions(getFileLocation(span))) .map(FileOperation::create) + // Metadata-table reads are issued from Hudi background pools (split loading, partition + // listing, table-statistics refresh) whose spans can outlive the synchronous query and + // land in the next query's measurement window. Their per-query counts are therefore + // non-deterministic, so they are excluded; only synchronous foreground reads are asserted. + .filter(operation -> operation.fileType() != METADATA_TABLE) .collect(toCollection(HashMultiset::create)); } } diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java new file mode 100644 index 0000000000000..2abc2c680b08e --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java @@ -0,0 +1,242 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.metastore.HiveType; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hudi.testing.MaxRankRecordMerger; +import org.apache.avro.SchemaBuilder; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR; +import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn; +import static io.trino.plugin.hudi.HudiUtil.appendMissingMergeRequiredColumns; +import static io.trino.plugin.hudi.HudiUtil.mergeRequiredColumnNames; +import static io.trino.spi.type.BigintType.BIGINT; +import static io.trino.spi.type.VarcharType.VARCHAR; +import static org.apache.hudi.common.config.HoodieReaderConfig.RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY; +import static org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER; +import static org.apache.hudi.common.model.HoodieRecord.HOODIE_IS_DELETED_FIELD; +import static org.apache.hudi.common.model.HoodieRecord.OPERATION_METADATA_FIELD; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests {@link HudiUtil#mergeRequiredColumnNames}, which mirrors the non-CUSTOM branch of the file-group + * reader's {@code FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging} so the base-file read + * projection carries every column the merge may consult (ordering fields, delete markers, the operation + * field, record-key data columns) even when the query does not project them, plus the merge-path recovery + * of names the metastore could not resolve ({@link HudiUtil#appendMissingMergeRequiredColumns}). + */ +class TestHudiMergeRequiredColumns +{ + @Test + public void testOrderingFieldsFollowMergeMode() + { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.ORDERING_FIELDS, "ts,seq"); + + assertThat(mergeRequiredColumnNames(tableConfig, RecordMergeMode.EVENT_TIME_ORDERING)) + .contains("ts", "seq"); + // Commit-time merging ignores ordering fields + assertThat(mergeRequiredColumnNames(tableConfig, RecordMergeMode.COMMIT_TIME_ORDERING)) + .doesNotContain("ts", "seq"); + assertThat(mergeRequiredColumnNames(tableConfig, null)) + .doesNotContain("ts", "seq"); + } + + @Test + public void testDeleteAndOperationColumnsAlwaysRequested() + { + // Requested unconditionally: getMergeRequiredColumnHandles keeps only metastore data columns, and + // the merge path's appendMissingMergeRequiredColumns recovers schema-carried names, so a table + // with these fields in neither reads nothing extra + assertThat(mergeRequiredColumnNames(new HoodieTableConfig(), RecordMergeMode.COMMIT_TIME_ORDERING)) + .contains(HOODIE_IS_DELETED_FIELD, OPERATION_METADATA_FIELD); + } + + @Test + public void testCustomDeleteKeyRequiresMarkerToo() + { + HoodieTableConfig withKeyAndMarker = new HoodieTableConfig(); + withKeyAndMarker.setValue(DELETE_KEY, "op"); + withKeyAndMarker.setValue(DELETE_MARKER, "D"); + assertThat(mergeRequiredColumnNames(withKeyAndMarker, RecordMergeMode.EVENT_TIME_ORDERING)) + .contains("op"); + + // DeleteContext only honors the delete key when the marker value is also set + HoodieTableConfig keyOnly = new HoodieTableConfig(); + keyOnly.setValue(DELETE_KEY, "op"); + assertThat(mergeRequiredColumnNames(keyOnly, RecordMergeMode.EVENT_TIME_ORDERING)) + .doesNotContain("op"); + } + + @Test + public void testPrefixedDeleteKeyIsRequested() + { + // v9+ table creation persists the delete key/marker under the hoodie.record.merge.property. + // prefix (e.g. for AWSDmsAvroPayload tables); the file-group reader strips the prefix via + // getTableMergeProperties() before DeleteContext reads the plain keys, and the connector's + // prediction must see the same values or the base-read projection guard fires on narrow queries + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX + DELETE_KEY, "Op"); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX + DELETE_MARKER, "D"); + + assertThat(mergeRequiredColumnNames(tableConfig, RecordMergeMode.COMMIT_TIME_ORDERING)) + .contains("Op"); + } + + @Test + public void testRecordKeyFieldsRequestedWithoutPopulatedMetaFields() + { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.RECORDKEY_FIELDS, "id1,id2"); + + // With populated meta fields (the default) the merge keys on _hoodie_record_key, which the + // connector always prepends into the projection + assertThat(mergeRequiredColumnNames(tableConfig, RecordMergeMode.EVENT_TIME_ORDERING)) + .doesNotContain("id1", "id2"); + + tableConfig.setValue(HoodieTableConfig.POPULATE_META_FIELDS, "false"); + assertThat(mergeRequiredColumnNames(tableConfig, RecordMergeMode.EVENT_TIME_ORDERING)) + .contains("id1", "id2"); + } + + @Test + public void testMergeRequiredColumnMissingFromProjectionResolvesFromTableSchema() + { + // The metastore never resolved _hoodie_operation (hive sync with omit_metadata_fields=true), so the + // projection arrives on the merge path without it; it must be recovered from the resolved table + // schema -- the gate the file-group reader itself applies -- along with the ordering field, while + // _hoodie_is_deleted, in neither the projection nor the schema, stays dropped + HoodieSchema dataSchema = HoodieSchema.fromAvroSchema(SchemaBuilder.record("rec").fields() + .requiredString("id") + .requiredLong("ts") + .requiredString(OPERATION_METADATA_FIELD) + .endRecord()); + List projection = List.of( + createBaseColumn("id", 0, HiveType.HIVE_STRING, VARCHAR, REGULAR, Optional.empty())); + + List extended = appendMissingMergeRequiredColumns(dataSchema, projection, eventTimeOrderedOn("ts"), new TypedProperties()); + + assertThat(extended).extracting(HiveColumnHandle::getName) + .containsExactly("id", "ts", OPERATION_METADATA_FIELD); + // The recovered handles are typed from their Avro fields + assertThat(extended.get(1).getType()).isEqualTo(BIGINT); + assertThat(extended.getLast().getType()).isEqualTo(VARCHAR); + } + + @Test + public void testCustomMergerMandatoryFieldMissingFromProjectionResolvesFromTableSchema() + { + // MaxRankRecordMerger declares merge_rank mandatory; a metastore that does not carry the column + // leaves it out of the projection, and the merge path must recover it by asking the same resolved + // merger the file-group reader will use + assertThat(appendMissingMergeRequiredColumns( + rankTableSchema(), idOnlyProjection(), customMergeOrderedOn("ts", MaxRankRecordMerger.MERGE_STRATEGY_ID), maxRankMergerProps())) + .extracting(HiveColumnHandle::getName) + .containsExactly("id", "ts", MaxRankRecordMerger.RANK_COLUMN); + } + + @Test + public void testCustomMergeModeWithoutStrategyIdSkipsMergerResolution() + { + // A CUSTOM table without a persisted strategy id cannot resolve a merger; the append must fall + // back to the table-config-derived names without dereferencing the null id -- the read then fails + // actionably in getRecordMerger, not with an NPE here + assertThat(appendMissingMergeRequiredColumns( + rankTableSchema(), idOnlyProjection(), customMergeOrderedOn("ts", null), maxRankMergerProps())) + .extracting(HiveColumnHandle::getName) + .containsExactly("id", "ts"); + } + + @Test + public void testCustomMergeModeWithUnresolvableMergerSkipsMandatoryFields() + { + // A strategy id no configured merger implementation declares resolves nothing; the append keeps + // the table-config-derived names and must not dereference the empty resolution. (The all-zeros + // uuid would NOT do here: it is PAYLOAD_BASED_MERGE_STRATEGY_UUID, which always resolves.) + assertThat(appendMissingMergeRequiredColumns( + rankTableSchema(), idOnlyProjection(), customMergeOrderedOn("ts", "e2a5b7c9-1d3f-4a68-9c0b-5e7d9f1a3b6c"), maxRankMergerProps())) + .extracting(HiveColumnHandle::getName) + .containsExactly("id", "ts"); + } + + @Test + public void testMergeRequiredColumnAlreadyProjectedIsNotDuplicated() + { + HoodieSchema dataSchema = HoodieSchema.fromAvroSchema(SchemaBuilder.record("rec").fields() + .requiredString("id") + .requiredLong("ts") + .endRecord()); + // The projection carries the ordering field under a different case; the append must match + // case-insensitively and leave the projection handle untouched + List projection = List.of( + createBaseColumn("TS", 1, HiveType.HIVE_LONG, BIGINT, REGULAR, Optional.empty())); + + assertThat(appendMissingMergeRequiredColumns(dataSchema, projection, eventTimeOrderedOn("ts"), new TypedProperties())) + .extracting(HiveColumnHandle::getName) + .containsExactly("TS"); + } + + private static HoodieSchema rankTableSchema() + { + return HoodieSchema.fromAvroSchema(SchemaBuilder.record("rec").fields() + .requiredString("id") + .requiredLong("ts") + .requiredLong(MaxRankRecordMerger.RANK_COLUMN) + .endRecord()); + } + + private static List idOnlyProjection() + { + return List.of(createBaseColumn("id", 0, HiveType.HIVE_STRING, VARCHAR, REGULAR, Optional.empty())); + } + + private static HoodieTableConfig customMergeOrderedOn(String orderingField, String mergeStrategyId) + { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "9"); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_MODE, RecordMergeMode.CUSTOM.name()); + if (mergeStrategyId != null) { + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_STRATEGY_ID, mergeStrategyId); + } + tableConfig.setValue(HoodieTableConfig.ORDERING_FIELDS, orderingField); + return tableConfig; + } + + private static TypedProperties maxRankMergerProps() + { + TypedProperties readerProps = new TypedProperties(); + readerProps.setProperty(RECORD_MERGE_IMPL_CLASSES_WRITE_CONFIG_KEY, MaxRankRecordMerger.class.getName()); + return readerProps; + } + + private static HoodieTableConfig eventTimeOrderedOn(String orderingField) + { + // Pin the version so the names under test are not at the mercy of the pre-v9 merge-config inference + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "9"); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_MODE, RecordMergeMode.EVENT_TIME_ORDERING.name()); + tableConfig.setValue(HoodieTableConfig.ORDERING_FIELDS, orderingField); + return tableConfig; + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiMinioConnectorSmokeTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMinioConnectorSmokeTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiMinioConnectorSmokeTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMinioConnectorSmokeTest.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java new file mode 100644 index 0000000000000..4d6a64d55a42a --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java @@ -0,0 +1,173 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.plugin.hudi.testing.CommitTimeOrderingHudiTablesInitializer; +import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer; +import io.trino.plugin.hudi.testing.EventTimeDeletesHudiTablesInitializer; +import io.trino.plugin.hudi.testing.StringOrderingHudiTablesInitializer; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end MoR snapshot-read tests for the merge-mode dispatch in + * {@code HudiTrinoReaderContext.getRecordMerger} with deletes (issue apache/hudi#18898), on tables + * written by {@link EventTimeDeletesHudiTablesInitializer} and + * {@link CommitTimeOrderingHudiTablesInitializer}: + *

      + *
    • EVENT_TIME_ORDERING: updates and soft deletes apply only when their ordering value wins; + * obsolete (lower-ordering) updates and soft deletes must LOSE against the base row.
    • + *
    • Hard deletes (native delete log files, read back through the connector's own + * {@code getFileRecordIterator}) always win.
    • + *
    • COMMIT_TIME_ORDERING: the latest write wins even with a LOWER ordering value -- the exact + * mirror of the event-time obsolete-update case, discriminating the two merger dispatches.
    • + *
    + */ +public class TestHudiMorMergeModeSemantics + extends AbstractTestQueryFramework +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .setDataLoader(new CompositeHudiTablesInitializer( + new EventTimeDeletesHudiTablesInitializer(), + new CommitTimeOrderingHudiTablesInitializer(), + new StringOrderingHudiTablesInitializer())) + .build(); + } + + @Test + public void testReadOptimizedShowsAllBaseRows() + { + // Deletes and updates live in log files only; the read-optimized tables reflect the base commit + assertQuery( + "SELECT key, name, value FROM " + EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2', 'k2_base', 20), ('k3', 'k3_base', 30)," + + " ('k4', 'k4_base', 40), ('k5', 'k5_base', 50), ('k6', 'k6_base', 60)"); + assertQuery( + "SELECT key, name, value FROM " + CommitTimeOrderingHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2', 'k2_base', 20), ('k3', 'k3_base', 30)"); + } + + @Test + public void testEventTimeMergeOnStringOrderingField() + { + // Regression test for the String/Utf8 ordering-value mismatch. The base row's ordering value comes + // from HudiAvroSerializer (a java.lang.String), the log row's from an inline-deserialized Avro log + // block (a Utf8); BufferedRecordMergerFactory#shouldKeepNewerRecord compares them directly and + // Utf8.compareTo casts, so before the fix this query failed outright with ClassCastException rather + // than returning wrong rows. k1's update carries a later ts and wins; k2's carries an earlier ts and + // loses, which also pins that the comparison still resolves the right way round. + assertQuery( + "SELECT key, name, value, ts FROM " + StringOrderingHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT), '2018-08-31 11:00:00')," + + " ('k2', 'k2_base', CAST(20 AS BIGINT), '2018-08-31 10:00:00')"); + } + + @Test + public void testEventTimeMergeWithDeletes() + { + // k1: higher-ts update wins; k2: hard-deleted; k3: soft-deleted (higher ts); + // k4: OBSOLETE soft delete (lower ts) -> base row survives; + // k5: untouched; k6: OBSOLETE update (lower ts) -> base row survives + assertQuery( + "SELECT key, name, value FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT)), ('k4', 'k4_base', 40)," + + " ('k5', 'k5_base', 50), ('k6', 'k6_base', 60)"); + } + + @Test + public void testHardDeleteRemovesRowOnSnapshotRead() + { + // The hard delete is a native delete log file, resolved through the connector's + // getFileRecordIterator with the synthetic delete-log schema (record key + ordering field) + assertThat(computeScalar("SELECT count(*) FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k2'")) + .isEqualTo(0L); + assertThat(computeScalar("SELECT count(*) FROM " + EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k2'")) + .isEqualTo(1L); + } + + @Test + public void testSoftDeleteRemovesRowOnSnapshotRead() + { + // _hoodie_is_deleted=true log record with a winning (higher) ordering value + assertThat(computeScalar("SELECT count(*) FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k3'")) + .isEqualTo(0L); + assertThat(computeScalar("SELECT count(*) FROM " + EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k3'")) + .isEqualTo(1L); + } + + @Test + public void testObsoleteSoftDeleteLosesUnderEventTimeOrdering() + { + // The k4 soft delete carries ts=50 < base ts=100: event-time merging must keep the base row + assertQuery( + "SELECT key, name, value FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k4'", + "VALUES ('k4', 'k4_base', CAST(40 AS BIGINT))"); + } + + @Test + public void testCommitTimeOrderingKeepsLatestWrite() + { + // k1's update carries ts=50 < base ts=100. Under COMMIT_TIME_ORDERING the LATEST WRITE wins + // regardless of the ordering value -- the mirror of the event-time k6 case, where the same + // shape keeps the BASE row. Together they discriminate the two merger dispatches. + assertQuery( + "SELECT key, name, value FROM " + CommitTimeOrderingHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT)), ('k3', 'k3_base', 30)"); + } + + @Test + public void testCountAfterDeletes() + { + assertThat(computeScalar("SELECT count(*) FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(4L); + assertThat(computeScalar("SELECT count(*) FROM " + CommitTimeOrderingHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(2L); + } + + @Test + public void testNarrowProjectionMergesCorrectly() + { + // Neither the ordering field nor _hoodie_is_deleted is projected; the connector must still read + // them on both the base and log sides for the merge to resolve updates and deletes correctly + assertQuery( + "SELECT key, value FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', CAST(11 AS BIGINT)), ('k4', 40), ('k5', 50), ('k6', 60)"); + } + + @Test + public void testPredicateIsNotPushedIntoTheBaseReadOfAMergedSplit() + { + // HudiPageSourceProvider enables parquet predicate pushdown for base-file-only splits ONLY; the merge + // path builds its base page source with it off. That invariant is load-bearing and nothing else pins it: + // pruning happens on the BASE row group's statistics, before the log records the merge needs are seen. + // + // 65 is above every base value (10..60) and below the obsolete k6 update (66), which loses on event time + // and must not surface. With pushdown enabled on this path the whole row group is pruned, the base side + // comes back empty, and every log record is then emitted as an insert -- so k6 appears with 66 and the + // merge is silently skipped. Note the naive shape does NOT discriminate: for a row whose log record wins + // and carries the full record, dropping the base row still yields the right answer. + assertQueryReturnsEmptyResult( + "SELECT key, value FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE value > 65"); + // Anchor: the same predicate one step lower does return the rows it should, so the query above is empty + // because of the merge, not because nothing ever matches. + assertQuery( + "SELECT key, value FROM " + EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE value > 45 ORDER BY key", + "VALUES ('k5', CAST(50 AS BIGINT)), ('k6', 60)"); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java new file mode 100644 index 0000000000000..2183d80fe6867 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java @@ -0,0 +1,129 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer; +import io.trino.plugin.hudi.testing.DmsPayloadHudiTablesInitializer; +import io.trino.plugin.hudi.testing.OverwriteNonDefaultsPayloadHudiTablesInitializer; +import io.trino.plugin.hudi.testing.SummingPayloadHudiTablesInitializer; +import io.trino.plugin.hudi.testing.SummingTestPayload; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end MoR snapshot-read tests for PAYLOAD-driven merge semantics (issue apache/hudi#18898), on + * tables written by {@link DmsPayloadHudiTablesInitializer}, + * {@link OverwriteNonDefaultsPayloadHudiTablesInitializer} and {@link SummingPayloadHudiTablesInitializer}. + * No {@code hudi.record-merger-impls} connector property is set anywhere -- every behavior below must + * resolve purely from the table config: + *
      + *
    • AWSDms: a log record with {@code Op='D'} deletes the row at merge time via the translated + * delete-key/marker table properties, while a log record with the non-marker {@code Op='U'} must + * apply as an update (the narrow-projection case pins the fix that reads those properties with + * their {@code hoodie.record.merge.property.} prefix).
    • + *
    • OverwriteNonDefaults: IGNORE_DEFAULTS partial merging keeps the stored value for update columns + * equal to the schema default (null).
    • + *
    • {@link SummingTestPayload}: a user-defined payload rides the payload-based CUSTOM merge + * strategy; the merged value is the SUM of stored and incoming values, which proves the payload's + * {@code combineAndGetUpdateValue} executed (overwrite would yield the incoming value), and a hard + * delete routed through the same arm must remove its row.
    • + *
    + */ +public class TestHudiMorPayloadSemantics + extends AbstractTestQueryFramework +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .setDataLoader(new CompositeHudiTablesInitializer( + new DmsPayloadHudiTablesInitializer(), + new OverwriteNonDefaultsPayloadHudiTablesInitializer(), + new SummingPayloadHudiTablesInitializer())) + .build(); + } + + @Test + public void testDmsDeleteMarkerRemovesRowOnSnapshotRead() + { + // Read-optimized: both rows (the log records are not merged) + assertQuery( + "SELECT key, name, value, Op FROM " + DmsPayloadHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT), 'I'), ('k2', 'k2_base', 20, 'I')"); + // Snapshot: k2 is deleted by the Op='D' log record via the delete-key/marker table properties, + // while k1's NON-marker Op='U' log record must apply as an update -- a marker comparison that + // fires on any non-null Op would wrongly delete k1 too + assertQuery( + "SELECT key, name, value, Op FROM " + DmsPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT), 'U')"); + } + + @Test + public void testDmsNarrowProjectionMergesCorrectly() + { + // The Op column is NOT projected, so the connector must predict it as a merge-required column + // from the PREFIXED table properties (hoodie.record.merge.property.hoodie.payload.delete.field) + // for the base read -- the regression this suite pins for HudiUtil.mergeRequiredColumnNames + assertQuery( + "SELECT key, value FROM " + DmsPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', CAST(11 AS BIGINT))"); + assertThat(computeScalar("SELECT count(*) FROM " + DmsPayloadHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(1L); + } + + @Test + public void testOverwriteNonDefaultsKeepsStoredValueForDefaultColumns() + { + // Read-optimized: base values + assertQuery( + "SELECT key, a, b FROM " + OverwriteNonDefaultsPayloadHudiTablesInitializer.TABLE_NAME, + "VALUES ('k1', 'base_a', 'base_b')"); + // Snapshot: the update carried a='new_a' and b=null (the schema default); IGNORE_DEFAULTS + // partial merging takes the update's a but keeps the STORED b + assertQuery( + "SELECT key, a, b FROM " + OverwriteNonDefaultsPayloadHudiTablesInitializer.RT_TABLE_NAME, + "VALUES ('k1', 'new_a', 'base_b')"); + } + + @Test + public void testSummingPayloadRunsCombineAndGetUpdateValueOnRead() + { + // Read-optimized: the base values + assertQuery( + "SELECT key, value FROM " + SummingPayloadHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', CAST(10 AS BIGINT)), ('k2', 20)"); + // Snapshot: 10 + 99 = 109 -- only the payload's combineAndGetUpdateValue can produce this + // (newest-wins would yield 99, base-only 10), proving the CUSTOM payload-strategy branch ran; + // k2 is hard-deleted + assertQuery( + "SELECT key, value FROM " + SummingPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', CAST(109 AS BIGINT))"); + } + + @Test + public void testSummingPayloadHardDeleteRemovesRowOnSnapshotRead() + { + // The hard delete is a native delete log record routed to the payload-based CUSTOM merge arm, + // where it wins on HoodieAvroRecordMerger's isCommitTimeOrderingDelete short-circuit (the + // delete carries the sentinel ordering value) -- the delete path of the user-merger dispatch, + // which both ordering arms already cover + assertThat(computeScalar("SELECT count(*) FROM " + SummingPayloadHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k2'")) + .isEqualTo(0L); + assertThat(computeScalar("SELECT count(*) FROM " + SummingPayloadHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k2'")) + .isEqualTo(1L); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java similarity index 61% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java index 9d6a6a8a52002..e835e9249b0a5 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNoCacheFileOperations.java @@ -35,11 +35,9 @@ import static io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_MULTI_FG_PT_V8_MOR; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.DATA; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.INDEX_DEFINITION; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.LOG; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.METADATA_TABLE_PROPERTIES; import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TABLE_PROPERTIES; -import static io.trino.plugin.hudi.util.FileOperationUtils.FileType.TIMELINE; import static io.trino.testing.MultisetAssertions.assertMultisetsEqual; import static java.util.stream.Collectors.toCollection; @@ -56,6 +54,12 @@ protected DistributedQueryRunner createQueryRunner() .put("hudi.metadata-enabled", "true") .put("hudi.metadata.cache.enabled", "false") .put("fs.cache.enabled", "false") + // Disable the async table-statistics refresh: on the first query it reads the index + // definitions and table-property files (and the metadata table) on a background + // executor. Those non-metadata-table reads land in the asserted set and their timing + // is non-deterministic, so we turn the refresh off and assert only the synchronous + // planning-path reads. + .put("hudi.table-statistics-enabled", "false") .buildOrThrow(); return HudiQueryRunner.builder() @@ -67,19 +71,13 @@ protected DistributedQueryRunner createQueryRunner() @Test public void testSelectWithFilter() - throws InterruptedException { @Language("SQL") String query = "SELECT * FROM " + HUDI_MULTI_FG_PT_V8_MOR + " WHERE country='SG'"; assertFileSystemAccesses( query, ImmutableMultiset.builder() .addCopies(new FileOperationUtils.FileOperation("Input.readTail", DATA), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.lastModified", METADATA_TABLE), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.length", METADATA_TABLE), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE), 6) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TIMELINE), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", LOG), 1) .add(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) .build()); @@ -88,12 +86,7 @@ public void testSelectWithFilter() query, ImmutableMultiset.builder() .addCopies(new FileOperationUtils.FileOperation("Input.readTail", DATA), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.lastModified", METADATA_TABLE), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.length", METADATA_TABLE), 4) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", INDEX_DEFINITION), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE), 6) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TIMELINE), 2) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", LOG), 1) .add(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES)) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TABLE_PROPERTIES), 2) .build()); @@ -101,7 +94,6 @@ public void testSelectWithFilter() @Test public void testJoin() - throws InterruptedException { @Language("SQL") String query = "SELECT t1.id, t1.name, t1.price, t1.ts FROM " + HUDI_MULTI_FG_PT_V8_MOR + " t1 " + @@ -111,62 +103,25 @@ public void testJoin() assertFileSystemAccesses(query, ImmutableMultiset.builder() .addCopies(new FileOperationUtils.FileOperation("Input.readTail", DATA), 6) - .addCopies(new FileOperationUtils.FileOperation("InputFile.lastModified", METADATA_TABLE), 39) - .addCopies(new FileOperationUtils.FileOperation("InputFile.length", METADATA_TABLE), 39) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", INDEX_DEFINITION), 5) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE), 54) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 3) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TABLE_PROPERTIES), 5) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TIMELINE), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", LOG), 2) + .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) + .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) + .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) .build()); assertFileSystemAccesses(query, ImmutableMultiset.builder() .addCopies(new FileOperationUtils.FileOperation("Input.readTail", DATA), 6) - .addCopies(new FileOperationUtils.FileOperation("InputFile.lastModified", METADATA_TABLE), 29) - .addCopies(new FileOperationUtils.FileOperation("InputFile.length", METADATA_TABLE), 29) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", INDEX_DEFINITION), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE), 40) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", METADATA_TABLE_PROPERTIES), 2) .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TABLE_PROPERTIES), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", TIMELINE), 4) - .addCopies(new FileOperationUtils.FileOperation("InputFile.newStream", LOG), 2) .build()); } private void assertFileSystemAccesses(@Language("SQL") String query, Multiset expectedCacheAccesses) - throws InterruptedException { DistributedQueryRunner queryRunner = getDistributedQueryRunner(); queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); - // Async table-stats computation can outlive the synchronous query and emit spans into - // the exporter after execute returns. A fixed Thread.sleep races with this — when - // stats from query N is still running while query N+1's measurement happens, spans - // leak across the boundary and counts get scrambled (the symmetric off-by-N failure - // across paired tests). Poll until the span set is stable for two consecutive reads. - Multiset actual = waitForStableSpans(queryRunner); - assertMultisetsEqual(actual, expectedCacheAccesses); - } - - /** - * Returns the file-operation span set once two consecutive reads (200ms apart) agree. - * Bounded by a 30-second ceiling so a runaway test fails loudly instead of hanging. - */ - private static Multiset waitForStableSpans(QueryRunner queryRunner) - throws InterruptedException - { - long deadlineMillis = System.currentTimeMillis() + 30_000L; - Multiset previous = null; - while (System.currentTimeMillis() < deadlineMillis) { - Thread.sleep(200L); - Multiset current = getFileOperations(queryRunner); - if (previous != null && current.equals(previous)) { - return current; - } - previous = current; - } - return previous != null ? previous : getFileOperations(queryRunner); + assertMultisetsEqual(getFileOperations(queryRunner), expectedCacheAccesses); } private static Multiset getFileOperations(QueryRunner queryRunner) @@ -177,6 +132,11 @@ private static Multiset getFileOperations(Quer .filter(span -> !span.getName().startsWith("InputFile.exists")) .filter(span -> !isTrinoSchemaOrPermissions(getFileLocation(span))) .map(FileOperationUtils.FileOperation::create) + // Metadata-table reads are issued from Hudi background pools (split loading, partition + // listing, table-statistics refresh) whose spans can outlive the synchronous query and + // land in the next query's measurement window. Their per-query counts are therefore + // non-deterministic, so they are excluded; only synchronous foreground reads are asserted. + .filter(operation -> operation.fileType() != METADATA_TABLE) .collect(toCollection(HashMultiset::create)); } } diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNonProjectionCompatibleMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNonProjectionCompatibleMerger.java new file mode 100644 index 0000000000000..8c239105d721b --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNonProjectionCompatibleMerger.java @@ -0,0 +1,192 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.collect.ImmutableMap; +import io.trino.Session; +import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer; +import io.trino.plugin.hudi.testing.MaxRankRecordMerger; +import io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer; +import io.trino.plugin.hudi.testing.NonProjectionCompatibleRankMerger; +import io.trino.plugin.hudi.testing.OmittedOrderingFieldHudiTablesInitializer; +import io.trino.plugin.hudi.testing.OmittedRankFieldHudiTablesInitializer; +import io.trino.plugin.hudi.testing.PayloadOnlyMergerHudiTablesInitializer; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer.RT_TABLE_NAME; +import static io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer.TABLE_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Acceptance test for full-table-schema merge reads (apache/hudi#19249, issue comment on scope): + * {@link NonProjectionCompatibleRankMerger} does NOT override {@code isProjectionCompatible()} (default + * {@code false}) and does NOT declare {@code merge_rank} mandatory, so the file-group reader demands the + * FULL table schema as its required schema for base and log reads alike, and nothing prepends + * {@code merge_rank} into the connector's read projection. + *

    + * The queries below never project {@code merge_rank}. The data is laid out so each merge direction is + * proven independently: {@code k1}'s winning rank is on the LOG record (update wins, value 99) and + * {@code k2}'s winning rank is on the BASE record (base wins, value 100). A correct result therefore + * requires the un-projected rank column to be read on BOTH sides of the merge. {@code sum(value)} is a + * three-way discriminator: merged = 199, base-only = 110, built-in newest-wins = 103. + *

    + * The same full-schema read path is reached without any configured merger at all by a pre-1.0 table that + * persists only a {@link org.apache.hudi.common.model.HoodieRecordPayload} class + * ({@link PayloadOnlyMergerHudiTablesInitializer}), covered by the {@code payloadOnly} tests below. + *

    + * A third fixture ({@link OmittedOrderingFieldHudiTablesInitializer}) pins the projection-compatible side + * end to end: its metastore omits the ordering field its Avro schema carries, so correct event-time results + * prove the merge path recovers metastore-unknown merge columns from the resolved table schema. A fourth + * ({@link OmittedRankFieldHudiTablesInitializer}) does the same for a column only the CUSTOM merger itself + * declares mandatory ({@code merge_rank}), proving the merge path asks the resolved merger too. + */ +public class TestHudiNonProjectionCompatibleMerger + extends AbstractTestQueryFramework +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .setDataLoader(new CompositeHudiTablesInitializer( + new NonProjectionCompatibleMergerHudiTablesInitializer(), + new PayloadOnlyMergerHudiTablesInitializer(), + new OmittedOrderingFieldHudiTablesInitializer(), + new OmittedRankFieldHudiTablesInitializer())) + // Both custom mergers; each table resolves its own by strategy id. + .addConnectorProperties(ImmutableMap.of( + "hudi.record-merger-impls", + NonProjectionCompatibleRankMerger.class.getName() + "," + MaxRankRecordMerger.class.getName())) + .build(); + } + + @Test + public void testReadOptimizedTableReturnsBaseFileValues() + { + // The read-optimized table reads base files only, so it reflects the initial insert. + assertQuery( + "SELECT key, name, value FROM " + TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + assertThat(computeScalar("SELECT sum(value) FROM " + TABLE_NAME)) + .isEqualTo(110L); + } + + @Test + public void testNarrowProjectionMergesViaFullSchemaRead() + { + // Neither query projects merge_rank; the merger can only see it through the full-schema read. + // - k1 keeps the update (99): the winning rank (7 > 5) is on the LOG record + // - k2 keeps the base (100): the winning rank (9 > 1) is on the BASE record, proving the base + // read honors the file-group reader's full-schema required schema + assertQuery( + "SELECT key, value FROM " + RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', CAST(99 AS BIGINT)), ('k2', CAST(100 AS BIGINT))"); + // 199 uniquely identifies the rank-based merge: base-only would be 110, newest-wins would be 103. + assertThat(computeScalar("SELECT sum(value) FROM " + RT_TABLE_NAME)) + .isEqualTo(199L); + } + + @Test + public void testSelectStarMergesAllColumns() + { + assertQuery( + "SELECT key, name, value, merge_rank, ts FROM " + RT_TABLE_NAME + " ORDER BY key", + "VALUES" + + " ('k1', 'k1_updated', CAST(99 AS BIGINT), CAST(7 AS BIGINT), CAST(2 AS BIGINT))," + + " ('k2', 'k2_base', CAST(100 AS BIGINT), CAST(9 AS BIGINT), CAST(1 AS BIGINT))"); + } + + @Test + public void testPayloadOnlyReadOptimizedTableReturnsBaseFileValues() + { + assertQuery( + noRecordMergerImplsSession(), + "SELECT key, name, value FROM " + PayloadOnlyMergerHudiTablesInitializer.TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } + + @Test + public void testPayloadOnlyRealtimeTableAppliesPayloadMerge() + { + // Table version 6 with nothing but a payload class in hoodie.properties: the reader infers CUSTOM + // merge mode with the payload-based strategy, which resolves HoodieAvroRecordMerger and runs + // RankBasedTestPayload. Both merge directions are exercised, as for the custom merger above. + assertQuery( + noRecordMergerImplsSession(), + "SELECT key, name, value FROM " + PayloadOnlyMergerHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(99 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } + + @Test + public void testPayloadOnlyNarrowProjectionMergesViaFullSchemaRead() + { + // Projects neither key, name nor merge_rank, so 199 proves the full-schema read fed merge_rank to the + // payload on both sides: base-only would be 110, built-in newest-wins would be 103. + assertThat(computeScalar( + noRecordMergerImplsSession(), + "SELECT sum(value) FROM " + PayloadOnlyMergerHudiTablesInitializer.RT_TABLE_NAME)) + .isEqualTo(199L); + } + + @Test + public void testMetastoreOmittedOrderingFieldRealtimeTableMerges() + { + // This fixture's metastore does not carry the ordering field ts its Avro schema has (the hive-sync + // omission shape); event-time merging still needs ts on both sides, so these results only come out + // when the merge path recovers the column from the resolved table schema. + assertQuery( + "SELECT key, name, value FROM " + OmittedOrderingFieldHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(99 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } + + @Test + public void testMetastoreOmittedOrderingFieldNarrowProjectionSum() + { + // 199 discriminates event-time merging from base-only (110) and commit-time newest-wins (103). + assertThat(computeScalar("SELECT sum(value) FROM " + OmittedOrderingFieldHudiTablesInitializer.RT_TABLE_NAME)) + .isEqualTo(199L); + } + + @Test + public void testMetastoreOmittedMergerMandatoryFieldRealtimeTableMerges() + { + // This fixture's metastore does not carry merge_rank, the column MaxRankRecordMerger declares + // mandatory; only asking the resolved merger on the merge path recovers it, so these results only + // come out when merger-declared columns are recovered from the table schema too. + assertQuery( + "SELECT key, name, value FROM " + OmittedRankFieldHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key", + "VALUES ('k1', 'k1_updated', CAST(99 AS BIGINT)), ('k2', 'k2_base', CAST(100 AS BIGINT))"); + } + + @Test + public void testMetastoreOmittedMergerMandatoryFieldNarrowProjectionSum() + { + // 199 discriminates the keep-max merge from base-only (110) and newest-wins (103). + assertThat(computeScalar("SELECT sum(value) FROM " + OmittedRankFieldHudiTablesInitializer.RT_TABLE_NAME)) + .isEqualTo(199L); + } + + /** + * The payload-only table must resolve its merger purely from hoodie.properties, so the merger impls the + * connector is configured with for {@link NonProjectionCompatibleRankMerger} are cleared for its queries. + */ + private Session noRecordMergerImplsSession() + { + return SessionBuilder.from(getSession()) + .withRecordMergerImpls() + .build(); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java similarity index 91% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java index 8bb46f8fb3f5a..9a36e7d385240 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSource.java @@ -16,7 +16,7 @@ import io.trino.spi.connector.ConnectorPageSource; import org.junit.jupiter.api.Test; -import static io.trino.spi.testing.InterfaceTestUtils.assertAllMethodsOverridden; +import static io.trino.testing.InterfaceTestUtils.assertAllMethodsOverridden; public class TestHudiPageSource { diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java new file mode 100644 index 0000000000000..a224f2ffb6c2f --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java @@ -0,0 +1,807 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.filesystem.local.LocalInputFile; +import io.trino.metastore.HiveType; +import io.trino.parquet.ParquetReaderOptions; +import io.trino.plugin.base.metrics.FileFormatDataSourceStats; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HiveColumnProjectionInfo; +import io.trino.plugin.hive.parquet.ParquetReaderConfig; +import io.trino.plugin.hudi.file.HudiBaseFile; +import io.trino.spi.SplitWeight; +import io.trino.spi.connector.ColumnHandle; +import io.trino.spi.connector.ConnectorPageSource; +import io.trino.spi.connector.ConnectorSession; +import io.trino.spi.connector.DynamicFilter; +import io.trino.spi.predicate.Domain; +import io.trino.spi.predicate.Range; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.predicate.ValueSet; +import io.trino.spi.type.BigintType; +import io.trino.spi.type.RowType; +import io.trino.spi.type.Type; +import io.trino.testing.MaterializedResult; +import io.trino.testing.TestingConnectorSession; +import org.apache.parquet.conf.PlainParquetConfiguration; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.io.LocalOutputFile; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; +import org.joda.time.DateTimeZone; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn; +import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource; +import static io.trino.plugin.hudi.HudiPageSourceProvider.remapColumnIndicesToPhysical; +import static io.trino.plugin.hudi.HudiPageSourceProvider.remapPredicateColumnIndicesToPhysical; +import static io.trino.spi.type.DoubleType.DOUBLE; +import static io.trino.spi.type.IntegerType.INTEGER; +import static io.trino.spi.type.VarcharType.VARCHAR; +import static io.trino.testing.MaterializedResult.materializeSourceDataStream; +import static java.lang.Integer.parseInt; +import static org.apache.hudi.common.model.HoodieRecord.HOODIE_META_COLUMNS; +import static org.apache.parquet.schema.Type.Repetition.OPTIONAL; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers {@link HudiPageSourceProvider}'s column remapping from both sides: the index arithmetic of + * {@code remapColumnIndicesToPhysical} and {@code remapPredicateColumnIndicesToPhysical} on their own, and reads of + * a real base file through {@code createPageSource}, which are what prove a remapped predicate actually reaches the + * parquet reader and prunes the column it was written for. + *

    + * The base file the reading tests share has a physical column order the metastore does not: it carries the five + * {@code _hoodie_*} meta columns, which hive sync with + * {@code hoodie.datasource.hive_sync.omit_metadata_fields=true} leaves out, so every data column's metastore ordinal + * is five below its physical position. With {@code hudi.parquet.use-column-names=false} the parquet page source + * resolves columns positionally, so a predicate whose handle still carries the metastore ordinal lands on whichever + * column physically sits there and row groups get pruned on that column's statistics. The fixture makes that + * observable: {@link #PREDICATE_COLUMN} grows with the row index while every other data column stays in 0..9, so a + * domain meant for it but applied to any other column excludes every row group and the read returns nothing. + *

    + * Note that the shadowed column has to be part of the PROJECTION for the damage to appear: {@code + * descriptorsByPath} is derived from the projection, so a domain resolving to a column the query does not read + * finds no descriptor and is discarded instead. Do not "simplify" the projections below to the predicate column + * alone - that turns those tests green against the unfixed code. + */ +class TestHudiPageSourceProviderTest +{ + private static final int DATA_COLUMN_COUNT = 10; + /** The column the predicate is on: physically at 12, but numbered 7 by a metastore without the meta columns. */ + private static final String PREDICATE_COLUMN = "c7"; + /** The column physically sitting at {@code c7}'s stale ordinal, and therefore the one that shadows it. */ + private static final String SHADOWED_COLUMN = "c2"; + private static final int ROW_COUNT = 1000; + private static final long THRESHOLD = 900; + private static final int MATCHING_ROW_COUNT = (int) (ROW_COUNT - THRESHOLD - 1); + + @TempDir + static Path tempDir; + + private static Path baseFile; + + @BeforeAll + static void writeBaseFile() + throws IOException + { + MessageType schema = hudiFileSchema(DATA_COLUMN_COUNT); + baseFile = tempDir.resolve("base_file.parquet"); + SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(baseFile)) + .withType(schema) + .withConf(new PlainParquetConfiguration()) + .withRowGroupSize(1024L) + .withPageSize(512) + .build()) { + for (int row = 0; row < ROW_COUNT; row++) { + Group group = groupFactory.newGroup(); + for (String metaColumn : HOODIE_META_COLUMNS) { + group.append(metaColumn, metaColumn + "_" + row); + } + for (int column = 0; column < DATA_COLUMN_COUNT; column++) { + String columnName = "c" + column; + group.append(columnName, columnName.equals(PREDICATE_COLUMN) ? row : row % 10); + } + writer.write(group); + } + } + // The writer flushes a row group whenever the buffered size is over withRowGroupSize, checked every + // parquet.page.size.row.check.min records (100 by default), which is what actually splits this file. + // Assert the outcome rather than the knobs: with a single row group there would be nothing to prune, + // and every test below would pass without proving anything. + assertThat(rowGroupCount(baseFile)).as("row groups written").isGreaterThan(1); + } + + @Test + public void testRemapSimpleMatchCaseInsensitive() + { + // Physical Schema: [col_a (int), col_b (string)] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); + + // Requested Columns (same order, different case) + List requestedColumns = List.of( + createDummyHandle("COL_A", 0, HiveType.HIVE_INT, INTEGER), + createDummyHandle("COL_B", 1, HiveType.HIVE_STRING, VARCHAR)); + + // Perform remapping (case-insensitive) + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); + + assertThat(remapped).hasSize(2); + // First requested column "COL_A" should map to physical index 0 + assertHandle(remapped.get(0), "COL_A", 0, HiveType.HIVE_INT, INTEGER); + // Second requested column "COL_B" should map to physical index 1 + assertHandle(remapped.get(1), "COL_B", 1, HiveType.HIVE_STRING, VARCHAR); + } + + @Test + public void testRemapSimpleMatchCaseSensitive() + { + // Physical Schema: [col_a (int), Col_B (string)] - Note the case difference + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("Col_B")); + + // Requested Columns (matching case) + List requestedColumns = List.of( + createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER), + createDummyHandle("Col_B", 1, HiveType.HIVE_STRING, VARCHAR)); + + // Perform remapping (case-sensitive) + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, true); + + assertThat(remapped).hasSize(2); + assertHandle(remapped.get(0), "col_a", 0, HiveType.HIVE_INT, INTEGER); + assertHandle(remapped.get(1), "Col_B", 1, HiveType.HIVE_STRING, VARCHAR); + } + + @Test + public void testRemapCaseSensitiveMismatch() + { + // Physical Schema: [col_a (int), col_b (string)] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); + + // Requested Columns (different case) + List requestedColumns = List.of( + createDummyHandle("COL_A", 0, HiveType.HIVE_INT, INTEGER), // This will mismatch + createDummyHandle("col_b", 1, HiveType.HIVE_STRING, VARCHAR)); + + // Perform remapping (case-sensitive) - "COL_A" won't be found + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, true); + + assertThat(remapped).hasSize(2); + // An unmatched column maps one past the last physical field, so the parquet reader null-fills it + assertHandle(remapped.get(0), "COL_A", fileSchema.getFieldCount(), HiveType.HIVE_INT, INTEGER); + assertHandle(remapped.get(1), "col_b", 1, HiveType.HIVE_STRING, VARCHAR); + } + + @Test + public void testRemapDifferentOrder() + { + // Physical Schema: [id (int), name (string), timestamp (long)] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("id"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("name"), + Types.primitive(PrimitiveType.PrimitiveTypeName.INT64, OPTIONAL).named("timestamp")); + + // Requested Columns (different order) + List requestedColumns = List.of( + // Original index irrelevant + createDummyHandle("name", 99, HiveType.HIVE_STRING, VARCHAR), + createDummyHandle("timestamp", 5, HiveType.HIVE_LONG, BigintType.BIGINT), + createDummyHandle("id", 0, HiveType.HIVE_INT, INTEGER)); + + // Perform remapping (case-insensitive) + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); + + assertThat(remapped).hasSize(3); + // First requested "name" -> physical index 1 + assertHandle(remapped.get(0), "name", 1, HiveType.HIVE_STRING, VARCHAR); + // Second requested "timestamp" -> physical index 2 + assertHandle(remapped.get(1), "timestamp", 2, HiveType.HIVE_LONG, BigintType.BIGINT); + // Third requested "id" -> physical index 0 + assertHandle(remapped.get(2), "id", 0, HiveType.HIVE_INT, INTEGER); + } + + @Test + public void testRemapSubset() + { + // Physical Schema: [col_a, col_b, col_c, col_d] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BOOLEAN, OPTIONAL).named("col_c"), + Types.primitive(PrimitiveType.PrimitiveTypeName.DOUBLE, OPTIONAL).named("col_d")); + + // Requested Columns (subset and different order) + List requestedColumns = List.of( + createDummyHandle("col_d", 1, HiveType.HIVE_DOUBLE, DOUBLE), + createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER)); + + // Perform remapping (case-insensitive) + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); + + assertThat(remapped).hasSize(2); + // First requested "col_d" -> physical index 3 + assertHandle(remapped.get(0), "col_d", 3, HiveType.HIVE_DOUBLE, DOUBLE); + // Second requested "col_a" -> physical index 0 + assertHandle(remapped.get(1), "col_a", 0, HiveType.HIVE_INT, INTEGER); + } + + @Test + public void testRemapEmptyRequested() + { + // Physical Schema: [col_a, col_b] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a"), + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named("col_b")); + + // Requested Columns (empty list) + List requestedColumns = List.of(); + + // Perform remapping + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); + + assertThat(remapped).isEmpty(); + } + + @Test + public void testRemapColumnNotFound() + { + // Physical Schema: [col_a] + MessageType fileSchema = new MessageType("file_schema", + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("col_a")); + + // Requested Columns (includes a non-existent column) + List requestedColumns = List.of( + createDummyHandle("col_a", 0, HiveType.HIVE_INT, INTEGER), + // Not in schema, e.g. a base file written before the column was added + createDummyHandle("col_x", 1, HiveType.HIVE_STRING, VARCHAR)); + + // Perform remapping (case-insensitive) - "col_x" won't be found + List remapped = remapColumnIndicesToPhysical(fileSchema, requestedColumns, false); + + assertThat(remapped).hasSize(2); + assertHandle(remapped.get(0), "col_a", 0, HiveType.HIVE_INT, INTEGER); + // Out of range on purpose: ParquetPageSourceFactory reports such a column as absent and null-fills it + assertHandle(remapped.get(1), "col_x", fileSchema.getFieldCount(), HiveType.HIVE_STRING, VARCHAR); + } + + @Test + public void testRemapPredicateStaleMetastoreOrdinals() + { + // Physical Schema: the five Hudi meta columns, then [c0, c1, c2] + MessageType fileSchema = hudiFileSchema(3); + + // A metastore synced with omit_metadata_fields=true carries no meta columns, so "c2" is numbered 2 + // while it physically sits at 7, and "c0" is numbered 0 while it physically sits at 5. + HiveColumnHandle staleC2 = createDummyHandle("c2", 2, HiveType.HIVE_INT, INTEGER); + HiveColumnHandle staleC0 = createDummyHandle("c0", 0, HiveType.HIVE_INT, INTEGER); + Domain c2Domain = Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 900L)), false); + Domain c0Domain = Domain.singleValue(INTEGER, 7L); + + TupleDomain remapped = remapPredicateColumnIndicesToPhysical( + fileSchema, + TupleDomain.withColumnDomains(Map.of(staleC2, c2Domain, staleC0, c0Domain)), + false); + + Map domains = remapped.getDomains().orElseThrow(); + assertThat(domains).hasSize(2); + // Each domain now keys off the column's physical position, so it is matched against that column's statistics + assertThat(handleOf(domains, "c2").getBaseHiveColumnIndex()).isEqualTo(7); + assertThat(domains.get(handleOf(domains, "c2"))).isEqualTo(c2Domain); + assertThat(handleOf(domains, "c0").getBaseHiveColumnIndex()).isEqualTo(5); + assertThat(domains.get(handleOf(domains, "c0"))).isEqualTo(c0Domain); + } + + @Test + public void testRemapPredicateDropsColumnsAbsentFromFile() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + HiveColumnHandle present = createDummyHandle("c0", 0, HiveType.HIVE_INT, INTEGER); + // Added after this base file was written, so the file does not carry them. Two of them, because the + // projection remap's out-of-range sentinel is one value that every absent column would share. + HiveColumnHandle firstAbsent = createDummyHandle("c1", 1, HiveType.HIVE_INT, INTEGER); + HiveColumnHandle secondAbsent = createDummyHandle("c2", 2, HiveType.HIVE_INT, INTEGER); + Domain presentDomain = Domain.singleValue(INTEGER, 1L); + + TupleDomain remapped = remapPredicateColumnIndicesToPhysical( + fileSchema, + TupleDomain.withColumnDomains(Map.of( + present, presentDomain, + firstAbsent, Domain.singleValue(INTEGER, 2L), + secondAbsent, Domain.singleValue(INTEGER, 3L))), + false); + + // Both absent columns are dropped rather than mapped to that shared sentinel, which would have collided. + // Dropping them costs row group pruning only; the engine still applies the filter itself. + Map domains = remapped.getDomains().orElseThrow(); + assertThat(domains).hasSize(1); + assertThat(handleOf(domains, "c0").getBaseHiveColumnIndex()).isEqualTo(5); + assertThat(domains.get(handleOf(domains, "c0"))).isEqualTo(presentDomain); + } + + @Test + public void testRemapPredicateKeepsOneDomainPerPhysicalColumn() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + // Two handles whose names differ only by case resolve to the same file field, so both land on physical + // index 5 while remaining unequal to each other. The connector cannot produce this - Hive normalises + // column names to lower case - but pushing both down would hand getParquetTupleDomain one + // ColumnDescriptor twice, which it rejects by failing the whole split. + HiveColumnHandle upperCase = createDummyHandle("C0", 0, HiveType.HIVE_INT, INTEGER); + HiveColumnHandle lowerCase = createDummyHandle("c0", 3, HiveType.HIVE_INT, INTEGER); + Domain firstDomain = Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 10L)), false); + // Insertion-ordered so that "first wins" is a deterministic assertion + Map predicate = new LinkedHashMap<>(); + predicate.put(upperCase, firstDomain); + predicate.put(lowerCase, Domain.create(ValueSet.ofRanges(Range.lessThan(INTEGER, 20L)), false)); + + TupleDomain remapped = remapPredicateColumnIndicesToPhysical( + fileSchema, TupleDomain.withColumnDomains(predicate), false); + + // Only the first is pushed down, and no IllegalArgumentException escapes + Map domains = remapped.getDomains().orElseThrow(); + assertThat(domains).hasSize(1); + HiveColumnHandle survivor = handleOf(domains, "C0"); + assertThat(survivor.getBaseHiveColumnIndex()).isEqualTo(5); + assertThat(domains.get(survivor)).isEqualTo(firstDomain); + } + + @Test + public void testRemapPredicateKeepsBothProjectionsOfOneBaseColumn() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + // Two dereference handles projecting DIFFERENT subfields of the same struct column. Both resolve to base + // physical index 5, but getParquetTupleDomain builds a descriptor per subfield path, so it would accept + // both; deduplicating on the base index alone would silently discard one of the two domains. + HiveType structType = HiveType.valueOf("struct"); + RowType baseType = RowType.rowType(RowType.field("f", INTEGER), RowType.field("g", INTEGER)); + HiveColumnHandle onF = dereferenceHandle(structType, baseType, 0, "f"); + HiveColumnHandle onG = dereferenceHandle(structType, baseType, 1, "g"); + Domain fDomain = Domain.singleValue(INTEGER, 5L); + Domain gDomain = Domain.singleValue(INTEGER, 3L); + + TupleDomain remapped = remapPredicateColumnIndicesToPhysical( + fileSchema, + TupleDomain.withColumnDomains(Map.of(onF, fDomain, onG, gDomain)), + false); + + assertThat(remapped.getDomains().orElseThrow()) + .as("both subfield domains survive, each on the base column's physical index") + .isEqualTo(Map.of( + withBaseIndex(onF, 5), fDomain, + withBaseIndex(onG, 5), gDomain)); + } + + @Test + public void testRemapPreservesTheBaseTypeOfADereferenceHandle() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + // A handle projecting one field out of a struct column. HudiMetadata does not implement applyProjection, + // so the connector never builds one today, but the remap has to rebuild it without corrupting it: the + // constructor's type argument is the BASE column's type, while getType() is the projected field's. + RowType baseType = RowType.rowType(RowType.field("f", INTEGER)); + HiveColumnHandle dereference = dereferenceHandle(HiveType.valueOf("struct"), baseType, 0, "f"); + + HiveColumnHandle remapped = remapColumnIndicesToPhysical(fileSchema, List.of(dereference), false).get(0); + + assertThat(remapped.getBaseHiveColumnIndex()) + .as("physical index") + .isEqualTo(5); + assertThat(remapped.getBaseType()) + .as("base type, which is what the parquet page source reads") + .isEqualTo(baseType); + assertThat(remapped.getType()) + .as("projected field type") + .isEqualTo(INTEGER); + assertThat(remapped.getHiveColumnProjectionInfo()) + .as("projection info") + .isEqualTo(dereference.getHiveColumnProjectionInfo()); + } + + @Test + public void testRemapPredicateAllAndNonePassThrough() + { + MessageType fileSchema = hudiFileSchema(1); + + assertThat(remapPredicateColumnIndicesToPhysical(fileSchema, TupleDomain.all(), false)) + .isEqualTo(TupleDomain.all()); + assertThat(remapPredicateColumnIndicesToPhysical(fileSchema, TupleDomain.none(), false)) + .isEqualTo(TupleDomain.none()); + } + + @Test + public void testRemapPredicateCaseSensitivity() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + HiveColumnHandle upperCase = createDummyHandle("C0", 0, HiveType.HIVE_INT, INTEGER); + TupleDomain predicate = TupleDomain.withColumnDomains(Map.of(upperCase, Domain.singleValue(INTEGER, 1L))); + + // Case-insensitive: "C0" resolves to the file's "c0" at physical index 5 + Map insensitive = remapPredicateColumnIndicesToPhysical(fileSchema, predicate, false) + .getDomains().orElseThrow(); + assertThat(handleOf(insensitive, "C0").getBaseHiveColumnIndex()).isEqualTo(5); + + // Case-sensitive: no match, so the domain is dropped instead of being left on a stale ordinal + assertThat(remapPredicateColumnIndicesToPhysical(fileSchema, predicate, true).isAll()).isTrue(); + } + + @Test + public void testRemapPredicatePreservesEveryOtherHandleAttribute() + { + // Physical Schema: the five Hudi meta columns, then [c0] + MessageType fileSchema = hudiFileSchema(1); + + HiveColumnHandle original = new HiveColumnHandle( + "c0", + 0, + HiveType.HIVE_INT, + INTEGER, + Optional.empty(), + HiveColumnHandle.ColumnType.REGULAR, + Optional.of("a comment")); + Domain domain = Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 900L)), true); + + Map domains = remapPredicateColumnIndicesToPhysical( + fileSchema, + TupleDomain.withColumnDomains(Map.of(original, domain)), + false) + .getDomains().orElseThrow(); + + HiveColumnHandle remapped = handleOf(domains, "c0"); + assertHandle(remapped, "c0", 5, HiveType.HIVE_INT, INTEGER); + assertThat(remapped.getComment()) + .as("Comment mismatch for c0") + .isEqualTo(Optional.of("a comment")); + assertThat(domains.get(remapped)) + .as("Domain mismatch for c0") + .isEqualTo(domain); + } + + @Test + public void testPredicateOnStaleOrdinalStillPrunesRowGroups() + throws Exception + { + List projection = List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN)); + + MaterializedResult result = read(projection, greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY); + + // Correct results alone would also be produced by pushing nothing down; reading fewer rows than the file + // holds is only possible if the domain reached the column it was written for, and the matching rows must + // survive that pruning. The shadowed column never leaves 0..9, so a domain of "> 900" applied to it would + // prune every row group instead. + assertThat(result.getRowCount()) + .as("rows read out of %s", ROW_COUNT) + .isLessThan(ROW_COUNT); + assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN)) + .as("rows matching %s > %s after pruning", PREDICATE_COLUMN, THRESHOLD) + .isEqualTo(MATCHING_ROW_COUNT); + } + + @Test + public void testStaleOrdinalArrivingThroughADynamicFilter() + throws Exception + { + List projection = List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN)); + + // A dynamic filter reaches getCombinedPredicate by its own route, and its handles carry the same stale + // metastore ordinals the split's predicate does + MaterializedResult result = read(projection, TupleDomain.all(), false, + dynamicFilterOn(greaterThanThreshold(PREDICATE_COLUMN))); + + assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN)) + .as("rows matching a dynamic filter of %s > %s", PREDICATE_COLUMN, THRESHOLD) + .isEqualTo(MATCHING_ROW_COUNT); + } + + @Test + public void testPredicateOnColumnAddedAfterBaseFileWasWritten() + throws Exception + { + // The metastore carries one column more than this base file does, numbered 10 - an ordinal that is still + // in range physically, where it picks out "c5" + String addedColumn = "c" + DATA_COLUMN_COUNT; + List projection = List.of(dataColumn("c5"), dataColumn(PREDICATE_COLUMN), dataColumn(addedColumn)); + + // IS NULL, not a range: the added column is null in every row of this base file, so this predicate is + // satisfied by all of them. A range predicate would be unsatisfiable here and the buggy read's empty + // result would be the right answer by accident. + MaterializedResult result = read(projection, + TupleDomain.withColumnDomains(Map.of(dataColumn(addedColumn), Domain.onlyNull(INTEGER))), + false, DynamicFilter.EMPTY); + + // The added column must not stay on its stale metastore ordinal: pushed positionally it would land on + // "c5", which has no nulls at all, and every row group would be pruned. + assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT); + assertThat(result.getMaterializedRows().getFirst().getField(2)).as("value of %s", addedColumn).isNull(); + } + + @Test + public void testPositionalAndNameBasedResolutionAgree() + throws Exception + { + List projection = List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN)); + TupleDomain predicate = greaterThanThreshold(PREDICATE_COLUMN); + + MaterializedResult positional = read(projection, predicate, false, DynamicFilter.EMPTY); + MaterializedResult byName = read(projection, predicate, true, DynamicFilter.EMPTY); + + // Anchor the comparison: both modes regressing to no pushdown at all would otherwise agree happily + assertThat(byName.getRowCount()).as("rows read with use-column-names=true").isLessThan(ROW_COUNT); + assertThat(positional.getMaterializedRows()) + .as("hudi.parquet.use-column-names=false must read what use-column-names=true reads") + .isEqualTo(byName.getMaterializedRows()); + } + + /** + * Reads the whole base file through the page source the connector builds for a split with no log files, which + * is the only path on which it enables predicate pushdown. + */ + private static MaterializedResult read( + List projection, + TupleDomain predicate, + boolean useParquetColumnNames, + DynamicFilter dynamicFilter) + throws Exception + { + long fileSize = Files.size(baseFile); + HudiSplit split = new HudiSplit( + new HudiBaseFile(baseFile.toString(), baseFile.getFileName().toString(), fileSize, 0, 0, fileSize), + List.of(), + "000", + predicate, + List.of(), + SplitWeight.standard()); + HudiSessionProperties sessionProperties = new HudiSessionProperties( + new HudiConfig().setUseParquetColumnNames(useParquetColumnNames), + new ParquetReaderConfig()); + ConnectorSession session = TestingConnectorSession.builder() + .setPropertyMetadata(sessionProperties.getSessionProperties()) + .build(); + + List types = projection.stream().map(HiveColumnHandle::getType).toList(); + try (ConnectorPageSource pageSource = createPageSource( + session, + projection, + split, + new LocalInputFile(baseFile.toFile()), + baseFile.toString(), + 0L, + fileSize, + OptionalLong.of(fileSize), + new FileFormatDataSourceStats(), + ParquetReaderOptions.builder().build(), + DateTimeZone.UTC, + dynamicFilter, + true)) { + return materializeSourceDataStream(session, pageSource, types).toTestTypes(); + } + } + + /** + * Builds a file schema laid out like a Hudi base file: the five {@code _hoodie_*} meta columns followed by + * {@code dataColumnCount} int columns named {@code c0..cN}. A metastore synced with + * {@code hoodie.datasource.hive_sync.omit_metadata_fields=true} omits the meta columns, so a data column's + * metastore ordinal is its physical ordinal minus five. + */ + private static MessageType hudiFileSchema(int dataColumnCount) + { + List fields = new ArrayList<>(); + for (String metaColumn : HOODIE_META_COLUMNS) { + fields.add(Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, OPTIONAL).as(LogicalTypeAnnotation.stringType()).named(metaColumn)); + } + for (int i = 0; i < dataColumnCount; i++) { + fields.add(Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, OPTIONAL).named("c" + i)); + } + return new MessageType("hudi_base_file", fields); + } + + /** + * Builds the handle a metastore without the Hudi meta columns produces: numbered by its position among the + * data columns alone, which is {@code HOODIE_META_COLUMNS.size()} short of its physical position. Only + * {@code c0..cN} data column names are accepted - the numeric suffix IS the metastore ordinal - so a meta + * column name passed here would fail to parse rather than produce a meaningful handle. + */ + private static HiveColumnHandle dataColumn(String columnName) + { + return createBaseColumn(columnName, parseInt(columnName.substring(1)), HiveType.HIVE_INT, INTEGER, + HiveColumnHandle.ColumnType.REGULAR, Optional.empty()); + } + + /** A handle projecting the {@code fieldIndex}-th field, named {@code fieldName}, out of the struct column {@code c0}. */ + private static HiveColumnHandle dereferenceHandle(HiveType structType, RowType baseType, int fieldIndex, String fieldName) + { + return new HiveColumnHandle( + "c0", + 0, + structType, + baseType, + Optional.of(new HiveColumnProjectionInfo(List.of(fieldIndex), List.of(fieldName), HiveType.HIVE_INT, INTEGER)), + HiveColumnHandle.ColumnType.REGULAR, + Optional.empty()); + } + + /** The handle {@code remapColumnIndicesToPhysical} is expected to rebuild from {@code handle}. */ + private static HiveColumnHandle withBaseIndex(HiveColumnHandle handle, int baseHiveColumnIndex) + { + return new HiveColumnHandle( + handle.getBaseColumnName(), + baseHiveColumnIndex, + handle.getBaseHiveType(), + handle.getBaseType(), + handle.getHiveColumnProjectionInfo(), + handle.getColumnType(), + handle.getComment()); + } + + private static TupleDomain greaterThanThreshold(String columnName) + { + return TupleDomain.withColumnDomains(Map.of( + dataColumn(columnName), + Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, THRESHOLD)), false))); + } + + private static DynamicFilter dynamicFilterOn(TupleDomain predicate) + { + return new DynamicFilter() + { + @Override + public Set getColumnsCovered() + { + return Set.copyOf(predicate.getDomains().orElseThrow().keySet()); + } + + @Override + public CompletableFuture isBlocked() + { + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isComplete() + { + return true; + } + + @Override + public boolean isAwaitable() + { + return false; + } + + @Override + public TupleDomain getCurrentPredicate() + { + return predicate.transformKeys(ColumnHandle.class::cast); + } + }; + } + + private static long matchingRowCount(MaterializedResult result, List projection, String columnName) + { + int fieldIndex = projection.indexOf(dataColumn(columnName)); + return result.getMaterializedRows().stream() + .map(row -> row.getField(fieldIndex)) + .filter(value -> value != null && ((Number) value).longValue() > THRESHOLD) + .count(); + } + + private static int rowGroupCount(Path path) + throws IOException + { + try (ParquetFileReader reader = ParquetFileReader.open(new org.apache.parquet.io.LocalInputFile(path))) { + return reader.getRowGroups().size(); + } + } + + /** + * Returns the single remapped handle carrying the given base column name. + */ + private static HiveColumnHandle handleOf(Map domains, String baseColumnName) + { + return domains.keySet().stream() + .filter(handle -> handle.getBaseColumnName().equals(baseColumnName)) + .findFirst() + .orElseThrow(() -> new AssertionError("No domain was kept for column " + baseColumnName)); + } + + /** + * Creates a basic HiveColumnHandle for testing. + * Assumes REGULAR column type and no projection info or comments. + * The initial hiveColumnIndex is often irrelevant for this specific test, as we are testing the remapping logic. + * + * @param name Name of the column handle + * @param initialIndex The original index before remapping which might not be the physical one + * @param hiveType Hive type of column handle + * @param trinoType Trino type of column handle + */ + private HiveColumnHandle createDummyHandle( + String name, + int initialIndex, + HiveType hiveType, + Type trinoType) + { + return new HiveColumnHandle( + name, + initialIndex, + hiveType, + trinoType, + Optional.empty(), + HiveColumnHandle.ColumnType.REGULAR, + Optional.empty()); + } + + /** + * Asserts that a HiveColumnHandle has the expected properties after remapping. + */ + private void assertHandle( + HiveColumnHandle handle, + String expectedBaseName, + int expectedPhysicalIndex, + HiveType expectedHiveType, + Type expectedTrinoType) + { + assertThat(handle.getBaseColumnName()) + .as("BaseColumnName mismatch for %s", expectedBaseName) + .isEqualTo(expectedBaseName); + assertThat(handle.getBaseHiveColumnIndex()) + .as("BaseHiveColumnIndex (physical) mismatch for %s", expectedBaseName) + .isEqualTo(expectedPhysicalIndex); + assertThat(handle.getBaseHiveType()) + .as("BaseHiveType mismatch for %s", expectedBaseName) + .isEqualTo(expectedHiveType); + assertThat(handle.getType()) + .as("Trino Type mismatch for %s", expectedBaseName) + .isEqualTo(expectedTrinoType); + // Assert that other fields if they are relevant + assertThat(handle.getColumnType()) + .as("ColumnType mismatch for %s", expectedBaseName) + .isEqualTo(HiveColumnHandle.ColumnType.REGULAR); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPlugin.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPlugin.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiPlugin.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPlugin.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java new file mode 100644 index 0000000000000..b97bdc47750e6 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSessionProperties.java @@ -0,0 +1,71 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.trino.plugin.hive.parquet.ParquetReaderConfig; +import io.trino.spi.TrinoException; +import io.trino.spi.connector.ConnectorSession; +import io.trino.testing.TestingConnectorSession; +import org.junit.jupiter.api.Test; + +import static io.trino.plugin.hudi.HudiSessionProperties.getColumnsToHide; +import static io.trino.plugin.hudi.HudiSessionProperties.getRecordMergerImpls; +import static io.trino.plugin.hudi.HudiSessionProperties.getTargetSplitSize; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class TestHudiSessionProperties +{ + @Test + public void testSessionPropertyColumnsToHide() + { + HudiConfig config = new HudiConfig() + .setColumnsToHide(ImmutableList.of("col1", "col2")); + HudiSessionProperties sessionProperties = new HudiSessionProperties(config, new ParquetReaderConfig()); + ConnectorSession session = TestingConnectorSession.builder() + .setPropertyMetadata(sessionProperties.getSessionProperties()) + .build(); + assertThat(getColumnsToHide(session)) + .containsExactlyInAnyOrderElementsOf(ImmutableList.of("col1", "col2")); + } + + @Test + public void testSessionPropertyRecordMergerImpls() + { + HudiConfig config = new HudiConfig() + .setRecordMergerImpls(ImmutableList.of("com.example.MergerOne", "com.example.MergerTwo")); + HudiSessionProperties sessionProperties = new HudiSessionProperties(config, new ParquetReaderConfig()); + ConnectorSession session = TestingConnectorSession.builder() + .setPropertyMetadata(sessionProperties.getSessionProperties()) + .build(); + assertThat(getRecordMergerImpls(session)) + .containsExactly("com.example.MergerOne", "com.example.MergerTwo"); + } + + @Test + public void testSessionPropertyTargetSplitSizeRejectsZero() + { + // A zero target split size would make split generation loop forever, so reject it when the property is read + HudiSessionProperties sessionProperties = new HudiSessionProperties(new HudiConfig(), new ParquetReaderConfig()); + ConnectorSession session = TestingConnectorSession.builder() + .setPropertyMetadata(sessionProperties.getSessionProperties()) + .setPropertyValues(ImmutableMap.of("target_split_size", "0B")) + .build(); + assertThatThrownBy(() -> getTargetSplitSize(session)) + .isInstanceOf(TrinoException.class) + .hasMessageContaining("target_split_size must be at least 1B: 0B"); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java similarity index 94% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java index 32994032265fe..c97f836087e2e 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSharedMetastore.java @@ -17,7 +17,7 @@ import com.google.common.collect.ImmutableMap; import io.trino.Session; import io.trino.filesystem.Location; -import io.trino.plugin.hive.TestingHivePlugin; +import io.trino.plugin.hive.HivePlugin; import io.trino.plugin.hudi.testing.TpchHudiTablesInitializer; import io.trino.testing.AbstractTestQueryFramework; import io.trino.testing.DistributedQueryRunner; @@ -65,8 +65,15 @@ protected QueryRunner createQueryRunner() "hive.metastore.catalog.dir", dataDirectory.toString(), "fs.hadoop.enabled", "true")); - queryRunner.installPlugin(new TestingHivePlugin(dataDirectory)); - queryRunner.createCatalog("hive", "hive"); + queryRunner.installPlugin(new HivePlugin()); + queryRunner.createCatalog( + "hive", + "hive", + ImmutableMap.of( + // Intentionally sharing the file metastore directory with Hudi + "hive.metastore", "file", + "hive.metastore.catalog.dir", dataDirectory.toString(), + "fs.hadoop.enabled", "true")); queryRunner.execute("CREATE SCHEMA hive.default"); diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java similarity index 99% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java index f49933d3fb986..6f8971f3bbe6c 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java @@ -60,6 +60,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -727,6 +728,7 @@ public void testFileSkippingWithColumnNameUsingUppercaseLetters(ResourceHudiTabl .withRecordLevelIndexEnabled(false) .withSecondaryIndexEnabled(false) .withPartitionStatsIndexEnabled(false) + .withResolveColumnNameCasingEnabled(true) .build(); MaterializedResult prunedRes = getQueryRunner().execute(session, "SELECT * FROM " + table + " WHERE name='Alice'"); MaterializedResult totalRes = getQueryRunner().execute(session, "SELECT * FROM " + table); @@ -751,9 +753,10 @@ public void testRLIWithColumnNameUsingUppercaseLetters() .withRecordIndexTimeout("10s") .withSecondaryIndexEnabled(false) .withPartitionStatsIndexEnabled(false) + .withResolveColumnNameCasingEnabled(true) .build(); - MaterializedResult totalRes = getQueryRunner().execute(session, "SELECT * FROM " + HUDI_COW_TABLE_WITH_FIELD_NAMES_IN_CAPS); MaterializedResult prunedRes = getQueryRunner().execute(session, "SELECT * FROM " + HUDI_COW_TABLE_WITH_FIELD_NAMES_IN_CAPS + " WHERE id='1'"); + MaterializedResult totalRes = getQueryRunner().execute(session, "SELECT * FROM " + HUDI_COW_TABLE_WITH_FIELD_NAMES_IN_CAPS); int totalSplits = totalRes.getStatementStats().get().getTotalSplits(); int totalRows = totalRes.getRowCount(); int prunedSplits = prunedRes.getStatementStats().get().getTotalSplits(); @@ -775,6 +778,7 @@ public void testMultiKeyRLIWithColumnNameUsingUppercaseLetters() .withRecordIndexTimeout("10s") .withSecondaryIndexEnabled(false) .withPartitionStatsIndexEnabled(false) + .withResolveColumnNameCasingEnabled(true) .build(); MaterializedResult totalRes = getQueryRunner().execute(session, "SELECT * FROM " + HUDI_COW_TABLE_WITH_MULTI_KEYS_AND_FIELD_NAMES_IN_CAPS); MaterializedResult prunedRes = getQueryRunner().execute(session, "SELECT * FROM " + HUDI_COW_TABLE_WITH_MULTI_KEYS_AND_FIELD_NAMES_IN_CAPS + " WHERE id='1' and age=30"); @@ -801,7 +805,7 @@ public void testRecordLevelFileSkipping(ResourceHudiTablesInitializer.TestingTab .withRecordLevelIndexEnabled(true) .withSecondaryIndexEnabled(false) .withPartitionStatsIndexEnabled(false) - .withColumnStatsTimeout("10s") + .withRecordIndexTimeout("10s") .build(); MaterializedResult totalRes = getQueryRunner().execute(session, "SELECT * FROM " + table); MaterializedResult prunedRes = getQueryRunner().execute(session, "SELECT * FROM " + table @@ -1301,8 +1305,12 @@ private void testTimestampMicros(HiveTimestampPrecision timestampPrecision, Loca List.of(createBaseColumn("created", 0, HIVE_TIMESTAMP, columnType, REGULAR, Optional.empty())), hudiSplit, new LocalInputFile(parquetFile), + parquetFile.getPath(), + 0L, + parquetFile.length(), + OptionalLong.of(parquetFile.length()), new FileFormatDataSourceStats(), - new ParquetReaderOptions(), + ParquetReaderOptions.builder().build(), DateTimeZone.UTC, DynamicFilter.EMPTY, true)) { MaterializedResult result = materializeSourceDataStream(session, pageSource, List.of(columnType)).toTestTypes(); assertThat(result.getMaterializedRows()) diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSystemTables.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSystemTables.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestHudiSystemTables.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSystemTables.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUncompactedMetadataTable.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUncompactedMetadataTable.java new file mode 100644 index 0000000000000..cf14fbf3496db --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUncompactedMetadataTable.java @@ -0,0 +1,172 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.Session; +import io.trino.plugin.hudi.testing.UncompactedMetadataHudiTablesInitializer; +import io.trino.testing.AbstractTestQueryFramework; +import io.trino.testing.MaterializedResult; +import io.trino.testing.QueryRunner; +import org.junit.jupiter.api.Test; + +import static io.trino.plugin.hudi.testing.UncompactedMetadataHudiTablesInitializer.CORRUPTED_TABLE_NAME; +import static io.trino.plugin.hudi.testing.UncompactedMetadataHudiTablesInitializer.TABLE_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression tests for apache/hudi#19279: queries on tables whose metadata table (MDT) has + * UNCOMPACTED delta commits. Those deltas are native HFILE log files, which the connector previously + * rejected ("Native HFILE log files are not supported..."), failing every query through the unguarded + * partition-stats pruning path. The table written by {@link UncompactedMetadataHudiTablesInitializer} + * keeps its MDT deliberately uncompacted and its deltas are whole-file native HFILE logs (the zip + * fixtures predate that write path; their block-format deltas were always readable), so the queries + * below only succeed if the connector reads native HFILE log files in the MDT's + * {@code files}/{@code column_stats}/{@code partition_stats} partitions. + *

    + * The initializer also writes {@code CORRUPTED_TABLE_NAME}, an identical table whose MDT log files + * are corrupted so every MDT read throws; queries on it pin the fallbacks (direct file listing, + * unpruned split generation) instead of only the clean-read path. + */ +public class TestHudiUncompactedMetadataTable + extends AbstractTestQueryFramework +{ + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + return HudiQueryRunner.builder() + .setDataLoader(new UncompactedMetadataHudiTablesInitializer()) + .build(); + } + + @Test + public void testSnapshotReadWithUncompactedMetadataTable() + { + // MDT-backed file listing must read the files partition's HFILE log deltas + assertQuery( + mdtEnabled(), + "SELECT id, name, price FROM " + TABLE_NAME + " ORDER BY id", + "VALUES ('k1', 'k1_c3', CAST(15 AS BIGINT)), ('k2', 'k2_c1', 1000), ('k3', 'k3_c2', 20), ('k4', 'k4_c2', 2000)"); + assertThat(computeScalar(mdtEnabled(), "SELECT count(*) FROM " + TABLE_NAME)).isEqualTo(4L); + } + + @Test + public void testResultsMatchWithMetadataTableDisabled() + { + String query = "SELECT id, name, price, part_col FROM " + TABLE_NAME + " ORDER BY id"; + MaterializedResult withMdt = getQueryRunner().execute(mdtEnabled(), query); + MaterializedResult withoutMdt = getQueryRunner().execute(mdtDisabled(), query); + assertThat(withMdt.getMaterializedRows()).isEqualTo(withoutMdt.getMaterializedRows()); + } + + @Test + public void testPartitionStatsIndexPruningOverUncompactedStats() + { + // The exact crash from the issue: partition-stats pruning reads the partition_stats MDT + // partition, whose deltas are uncompacted HFILE log files. Partition p2 holds prices + // [1000, 2000], so `price < 100` lets the index prune it entirely. + MaterializedResult pruned = getQueryRunner().execute(partitionStatsPruningOnly(), + "SELECT id, price FROM " + TABLE_NAME + " WHERE price < 100"); + assertThat(pruned.getMaterializedRows()).hasSize(2); + + // Index pruning must scan exactly p1's file groups: the same split count as a + // metastore-pruned scan of p1, strictly fewer than the full scan. Split counts are + // compared instead of hardcoded because the number of file groups per partition depends + // on the write client's small-file packing. + int fullScanSplits = totalSplits(mdtEnabled(), "SELECT id, price FROM " + TABLE_NAME); + int p1ScanSplits = totalSplits(mdtEnabled(), "SELECT id, price FROM " + TABLE_NAME + " WHERE part_col = 'p1'"); + assertThat(p1ScanSplits).isLessThan(fullScanSplits); + assertThat(pruned.getStatementStats().get().getTotalSplits()).isEqualTo(p1ScanSplits); + } + + @Test + public void testReadFallsBackToDirectListingOnUnreadableMetadataTable() + { + // The corrupted twin table's MDT log deltas cannot be decoded, so the MDT-backed + // file-system-view load throws; HudiSnapshotDirectoryLister must fall back to listing + // files directly from storage and still return complete, correct rows. + assertQuery( + mdtEnabled(), + "SELECT id, name, price FROM " + CORRUPTED_TABLE_NAME + " ORDER BY id", + "VALUES ('k1', 'k1_c3', CAST(15 AS BIGINT)), ('k2', 'k2_c1', 1000), ('k3', 'k3_c2', 20), ('k4', 'k4_c2', 2000)"); + } + + @Test + public void testPartitionStatsPruningFallsBackUnprunedOnUnreadableMetadataTable() + { + // Same pruning setup as above, but the partition_stats read throws on the corrupted + // table: prunePartitionsSafely must degrade to "no pruning", so the scan covers the + // same splits as a full scan instead of failing the query. + MaterializedResult unpruned = getQueryRunner().execute(partitionStatsPruningOnly(), + "SELECT id, price FROM " + CORRUPTED_TABLE_NAME + " WHERE price < 100"); + assertThat(unpruned.getMaterializedRows()).hasSize(2); + + int fullScanSplits = totalSplits(mdtDisabled(), "SELECT id, price FROM " + CORRUPTED_TABLE_NAME); + assertThat(unpruned.getStatementStats().get().getTotalSplits()).isEqualTo(fullScanSplits); + } + + @Test + public void testColumnStatsFileSkippingOverUncompactedStats() + { + // Column-stats file skipping reads the column_stats MDT partition's HFILE log deltas. + // The wait timeout must be raised above its 1s default (matching the col-stats tests in + // TestHudiSmokeTest): shouldSkipFileSlice keeps the file on any failure or timeout, so + // with the default a broken col-stats read would still return correct rows and a + // value-only assertion would pass without the deltas ever being read. + Session session = SessionBuilder.from(getSession()) + .withMdtEnabled(true) + .withColStatsIndexEnabled(true) + .withRecordLevelIndexEnabled(false) + .withSecondaryIndexEnabled(false) + .withPartitionStatsIndexEnabled(false) + .withColumnStatsTimeout("10s") + .build(); + MaterializedResult skipped = getQueryRunner().execute(session, + "SELECT id, price FROM " + TABLE_NAME + " WHERE price = 15"); + assertThat(skipped.getMaterializedRows()).hasSize(1); + assertThat(skipped.getMaterializedRows().get(0).getFields()).containsExactly("k1", 15L); + + // File skipping must drop at least p2's file group (its prices [1000, 2000] exclude 15), + // so the filtered scan uses strictly fewer splits than the unfiltered full scan + int fullScanSplits = totalSplits(mdtEnabled(), "SELECT id, price FROM " + TABLE_NAME); + assertThat(skipped.getStatementStats().get().getTotalSplits()).isLessThan(fullScanSplits); + } + + private Session mdtEnabled() + { + return SessionBuilder.from(getSession()).withMdtEnabled(true).build(); + } + + private Session mdtDisabled() + { + return SessionBuilder.from(getSession()).withMdtEnabled(false).build(); + } + + /** MDT on with only the partition-stats index enabled, isolating partition pruning. */ + private Session partitionStatsPruningOnly() + { + return SessionBuilder.from(getSession()) + .withMdtEnabled(true) + .withColStatsIndexEnabled(false) + .withRecordLevelIndexEnabled(false) + .withSecondaryIndexEnabled(false) + .withPartitionStatsIndexEnabled(true) + .build(); + } + + private int totalSplits(Session session, String query) + { + return getQueryRunner().execute(session, query).getStatementStats().get().getTotalSplits(); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUtilColumnHandles.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUtilColumnHandles.java new file mode 100644 index 0000000000000..ee1fb1473ec13 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiUtilColumnHandles.java @@ -0,0 +1,303 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.metastore.HiveType; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hudi.testing.MaxRankRecordMerger; +import io.trino.plugin.hudi.testing.NonProjectionCompatibleRankMerger; +import io.trino.spi.TrinoException; +import io.trino.spi.type.DecimalType; +import io.trino.spi.type.Type; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.schema.HoodieSchemaField; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.util.collection.Pair; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static io.trino.plugin.hudi.HudiUtil.appendMissingSchemaColumns; +import static io.trino.plugin.hudi.HudiUtil.resolveMergeModeAndStrategyId; +import static io.trino.plugin.hudi.HudiUtil.usesNonProjectionCompatibleMerger; +import static io.trino.plugin.hudi.HudiUtil.validateCustomMergeStrategyId; +import static io.trino.spi.type.BigintType.BIGINT; +import static io.trino.spi.type.BooleanType.BOOLEAN; +import static io.trino.spi.type.DateType.DATE; +import static io.trino.spi.type.IntegerType.INTEGER; +import static io.trino.spi.type.TimestampType.TIMESTAMP_MICROS; +import static io.trino.spi.type.VarcharType.VARCHAR; +import static org.apache.hudi.common.model.HoodieRecordMerger.PAYLOAD_BASED_MERGE_STRATEGY_UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests {@link HudiUtil#toColumnHandle}, which types a {@link HiveColumnHandle} from a table-schema + * field's Avro type, plus the merge-resolution helpers around it ({@link + * HudiUtil#appendMissingSchemaColumns}, {@link HudiUtil#usesNonProjectionCompatibleMerger} and {@link + * HudiUtil#validateCustomMergeStrategyId}). Used by the reader context and page-source provider to + * resolve merge-required columns that are absent from the query projection (see apache/hudi#19249). + */ +class TestHudiUtilColumnHandles +{ + @Test + public void testNullableStringMatchesHudiMetaColumnHandle() + { + // Hudi meta columns (e.g. _hoodie_commit_time) are ["null","string"] unions; the resolved handle + // must be identical to the HIVE_STRING/VARCHAR handle prependHudiMetaAndOrderingColumns builds + Schema nullableString = SchemaBuilder.unionOf().nullType().and().stringType().endUnion(); + HiveColumnHandle handle = toColumnHandle("_hoodie_commit_time", nullableString); + + assertThat(handle.getName()).isEqualTo("_hoodie_commit_time"); + assertThat(handle.getType()).isEqualTo(VARCHAR); + assertThat(handle.getHiveType()).isEqualTo(HiveType.HIVE_STRING); + assertThat(handle.getColumnType()).isEqualTo(HiveColumnHandle.ColumnType.REGULAR); + assertThat(handle.isHidden()).isFalse(); + } + + @Test + public void testPrimitiveTypes() + { + assertColumnHandle(Schema.create(Schema.Type.INT), INTEGER, HiveType.HIVE_INT); + assertColumnHandle(Schema.create(Schema.Type.LONG), BIGINT, HiveType.HIVE_LONG); + assertColumnHandle(Schema.create(Schema.Type.BOOLEAN), BOOLEAN, HiveType.HIVE_BOOLEAN); + assertColumnHandle(Schema.create(Schema.Type.STRING), VARCHAR, HiveType.HIVE_STRING); + } + + @Test + public void testLogicalTypes() + { + assertColumnHandle( + LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT)), + DATE, HiveType.HIVE_DATE); + assertColumnHandle( + LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG)), + TIMESTAMP_MICROS, HiveType.HIVE_TIMESTAMP); + assertColumnHandle( + LogicalTypes.decimal(10, 2).addToSchema(Schema.create(Schema.Type.BYTES)), + DecimalType.createDecimalType(10, 2), HiveType.valueOf("decimal(10,2)")); + assertColumnHandle( + LogicalTypes.decimal(10, 2).addToSchema(Schema.createFixed("fixed_dec", null, null, 5)), + DecimalType.createDecimalType(10, 2), HiveType.valueOf("decimal(10,2)")); + } + + @Test + public void testNestedTypes() + { + HiveColumnHandle arrayHandle = toColumnHandle("arr", SchemaBuilder.array().items().stringType()); + assertThat(arrayHandle.getHiveType()).isEqualTo(HiveType.valueOf("array")); + + HiveColumnHandle mapHandle = toColumnHandle("map", SchemaBuilder.map().values().intType()); + assertThat(mapHandle.getHiveType()).isEqualTo(HiveType.valueOf("map")); + + HiveColumnHandle rowHandle = toColumnHandle("rec", SchemaBuilder.record("rec").fields() + .requiredInt("a") + .requiredString("b") + .endRecord()); + assertThat(rowHandle.getHiveType()).isEqualTo(HiveType.valueOf("struct")); + } + + @Test + public void testNullableUnionOfLogicalType() + { + Schema nullableDate = SchemaBuilder.unionOf().nullType().and() + .type(LogicalTypes.date().addToSchema(Schema.create(Schema.Type.INT))).endUnion(); + assertColumnHandle(nullableDate, DATE, HiveType.HIVE_DATE); + } + + @Test + public void testTypeWithoutHiveCounterpartThrows() + { + // Avro uuid maps to Trino UUID, which has no Hive counterpart; must fail with a clear error + Schema uuid = LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING)); + assertThatThrownBy(() -> toColumnHandle("id", uuid)) + .isInstanceOf(TrinoException.class) + .hasMessageContaining("Unsupported Hive type"); + } + + @Test + public void testAppendMissingSchemaColumns() + { + HoodieSchema dataSchema = HoodieSchema.fromAvroSchema(SchemaBuilder.record("rec").fields() + .requiredString("col_a") + .requiredLong("col_b") + .requiredInt("col_c") + .endRecord()); + HiveColumnHandle projected = HiveColumnHandle.createBaseColumn( + "col_b", 1, HiveType.HIVE_LONG, BIGINT, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()); + + List expanded = appendMissingSchemaColumns(dataSchema, List.of(projected)); + + // Projection handles keep their order and instances; missing fields append in schema order + assertThat(expanded).hasSize(3); + assertThat(expanded.get(0)).isSameAs(projected); + assertThat(expanded.get(1).getName()).isEqualTo("col_a"); + assertThat(expanded.get(1).getType()).isEqualTo(VARCHAR); + assertThat(expanded.get(2).getName()).isEqualTo("col_c"); + assertThat(expanded.get(2).getType()).isEqualTo(INTEGER); + } + + @Test + public void testAppendMissingSchemaColumnsMatchesCaseInsensitively() + { + HoodieSchema dataSchema = HoodieSchema.fromAvroSchema(SchemaBuilder.record("rec").fields() + .requiredString("COL_A") + .endRecord()); + HiveColumnHandle projected = HiveColumnHandle.createBaseColumn( + "col_a", 0, HiveType.HIVE_STRING, VARCHAR, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()); + + assertThat(appendMissingSchemaColumns(dataSchema, List.of(projected))) + .containsExactly(projected); + } + + @Test + public void testUsesNonProjectionCompatibleMerger() + { + // Merger without the isProjectionCompatible override (interface default: false) -> full-schema read + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.CUSTOM, + NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID, + NonProjectionCompatibleRankMerger.class.getName())) + .isTrue(); + + // Projection-compatible merger stays on the projected fast path + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.CUSTOM, + MaxRankRecordMerger.MERGE_STRATEGY_ID, + MaxRankRecordMerger.class.getName())) + .isFalse(); + + // Unresolvable merger: no expansion here; the file-group reader fails loudly on its own + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.CUSTOM, + NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID, + "")) + .isFalse(); + + // Non-CUSTOM merge modes never trigger the full-schema read + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.EVENT_TIME_ORDERING, + NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID, + NonProjectionCompatibleRankMerger.class.getName())) + .isFalse(); + } + + @Test + public void testUsesNonProjectionCompatibleMergerWithoutStrategyId() + { + // A CUSTOM mode without a strategy id cannot resolve a merger: no expansion here (and no NPE from + // createValidRecordMerger); the file-group reader then fails loudly on its own + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.CUSTOM, null, NonProjectionCompatibleRankMerger.class.getName())) + .isFalse(); + assertThat(usesNonProjectionCompatibleMerger( + RecordMergeMode.CUSTOM, "", NonProjectionCompatibleRankMerger.class.getName())) + .isFalse(); + } + + @Test + public void testValidateCustomMergeStrategyId() + { + // A version 8 table can resolve to CUSTOM merge mode with no persisted strategy id; the read must be + // rejected with an actionable error rather than NPE inside createValidRecordMerger + assertThatThrownBy(() -> validateCustomMergeStrategyId(null)) + .isInstanceOf(TrinoException.class) + .hasMessageContaining(HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key()); + assertThatThrownBy(() -> validateCustomMergeStrategyId("")) + .isInstanceOf(TrinoException.class) + .hasMessageContaining(HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key()); + + assertThatCode(() -> validateCustomMergeStrategyId(NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID)) + .doesNotThrowAnyException(); + } + + @Test + public void testResolveMergeModeAndStrategyIdPassesThroughV9Configs() + { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "9"); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_MODE, RecordMergeMode.CUSTOM.name()); + tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_STRATEGY_ID, NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID); + + Pair resolved = resolveMergeModeAndStrategyId(tableConfig); + assertThat(resolved.getLeft()).isEqualTo(RecordMergeMode.CUSTOM); + assertThat(resolved.getRight()).isEqualTo(NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID); + } + + @Test + public void testResolveMergeModeAndStrategyIdInfersPreV8Configs() + { + // A 0.x table with a custom payload class persists neither merge mode nor strategy id; both must + // be inferred, mirroring FileGroupReaderSchemaHandler.generateRequiredSchema (mode, below v9) and + // HoodieReaderContext.initRecordMerger (strategy id, below v8). The inferred payload-based + // strategy resolves HoodieAvroRecordMerger, which is not projection compatible, so such tables + // take the full-schema read path even with no merger impls configured. + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "6"); + tableConfig.setValue(HoodieTableConfig.PAYLOAD_CLASS_NAME, "com.example.CustomPayload"); + + Pair resolved = resolveMergeModeAndStrategyId(tableConfig); + assertThat(resolved.getLeft()).isEqualTo(RecordMergeMode.CUSTOM); + assertThat(resolved.getRight()).isEqualTo(PAYLOAD_BASED_MERGE_STRATEGY_UUID); + assertThat(usesNonProjectionCompatibleMerger(resolved.getLeft(), resolved.getRight(), "")).isTrue(); + } + + @Test + public void testResolveMergeModeAndStrategyIdInfersCommitTimeForDefaultPayload() + { + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "6"); + tableConfig.setValue(HoodieTableConfig.PAYLOAD_CLASS_NAME, OverwriteWithLatestAvroPayload.class.getName()); + + Pair resolved = resolveMergeModeAndStrategyId(tableConfig); + assertThat(resolved.getLeft()).isEqualTo(RecordMergeMode.COMMIT_TIME_ORDERING); + assertThat(usesNonProjectionCompatibleMerger(resolved.getLeft(), resolved.getRight(), "")).isFalse(); + } + + @Test + public void testResolveMergeModeAndStrategyIdKeepsRawStrategyIdForV8() + { + // Version 8 tables infer the merge MODE (the schema handler gates on below-9) but NOT the + // strategy id (initRecordMerger gates on below-8); the raw null strategy id must flow through + // un-inferred and be rejected by usesNonProjectionCompatibleMerger instead of NPE-ing + HoodieTableConfig tableConfig = new HoodieTableConfig(); + tableConfig.setValue(HoodieTableConfig.VERSION, "8"); + tableConfig.setValue(HoodieTableConfig.PAYLOAD_CLASS_NAME, "com.example.CustomPayload"); + + Pair resolved = resolveMergeModeAndStrategyId(tableConfig); + assertThat(resolved.getLeft()).isEqualTo(RecordMergeMode.CUSTOM); + assertThat(resolved.getRight()).isNull(); + assertThat(usesNonProjectionCompatibleMerger(resolved.getLeft(), resolved.getRight(), "")).isFalse(); + } + + private static void assertColumnHandle(Schema avroSchema, Type expectedType, HiveType expectedHiveType) + { + HiveColumnHandle handle = toColumnHandle("col", avroSchema); + assertThat(handle.getName()).isEqualTo("col"); + assertThat(handle.getType()).isEqualTo(expectedType); + assertThat(handle.getHiveType()).isEqualTo(expectedHiveType); + } + + private static HiveColumnHandle toColumnHandle(String name, Schema avroSchema) + { + return HudiUtil.toColumnHandle(HoodieSchemaField.of(name, HoodieSchema.fromAvroSchema(avroSchema))); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java new file mode 100644 index 0000000000000..59442f8cdbe6a --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java @@ -0,0 +1,193 @@ +/* + * 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. + */ +package io.trino.plugin.hudi; + +import io.trino.metastore.HiveType; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HivePartitionKey; +import io.trino.plugin.hudi.file.HudiBaseFile; +import io.trino.plugin.hudi.util.PrefilledColumnValues; +import io.trino.spi.SplitWeight; +import io.trino.spi.block.Block; +import io.trino.spi.block.BlockBuilder; +import io.trino.spi.block.RunLengthEncodedBlock; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.type.DecimalType; +import io.trino.spi.type.Type; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static io.trino.plugin.hive.HiveColumnHandle.fileModifiedTimeColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.fileSizeColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.partitionColumnHandle; +import static io.trino.plugin.hive.HiveColumnHandle.pathColumnHandle; +import static io.trino.spi.type.BigintType.BIGINT; +import static io.trino.spi.type.DateTimeEncoding.unpackMillisUtc; +import static io.trino.spi.type.DateType.DATE; +import static io.trino.spi.type.IntegerType.INTEGER; +import static io.trino.spi.type.TimestampWithTimeZoneType.TIMESTAMP_TZ_MILLIS; +import static io.trino.spi.type.VarcharType.VARCHAR; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests {@link PrefilledColumnValues}: per-split constant values for partition columns and Trino's + * hidden metadata columns, delegated to trino-hive's canonical prefilled-column implementation. + */ +class TestPrefilledColumnValues +{ + private static final String FILE_PATH = "s3://bucket/table/year=2020/month=5/file1.parquet"; + private static final long FILE_SIZE = 1234; + private static final long FILE_MODIFIED_TIME = 1700000000123L; + + @Test + public void testPartitionKeyValues() + { + PrefilledColumnValues values = prefilledValues( + new HivePartitionKey("pk_string", "abc"), + new HivePartitionKey("pk_int", "42"), + new HivePartitionKey("pk_bigint", "123456789012"), + new HivePartitionKey("pk_date", "2021-12-09"), + new HivePartitionKey("pk_decimal", "12.34")); + + assertThat(VARCHAR.getSlice(singleValueBlock(values, partitionKey("pk_string", VARCHAR, HiveType.HIVE_STRING)), 0).toStringUtf8()) + .isEqualTo("abc"); + assertThat(INTEGER.getInt(singleValueBlock(values, partitionKey("pk_int", INTEGER, HiveType.HIVE_INT)), 0)) + .isEqualTo(42); + assertThat(BIGINT.getLong(singleValueBlock(values, partitionKey("pk_bigint", BIGINT, HiveType.HIVE_LONG)), 0)) + .isEqualTo(123456789012L); + assertThat(DATE.getInt(singleValueBlock(values, partitionKey("pk_date", DATE, HiveType.HIVE_DATE)), 0)) + .isEqualTo((int) LocalDate.parse("2021-12-09").toEpochDay()); + DecimalType decimalType = DecimalType.createDecimalType(10, 2); + assertThat(decimalType.getLong(singleValueBlock(values, partitionKey("pk_decimal", decimalType, HiveType.valueOf("decimal(10,2)"))), 0)) + .isEqualTo(1234); + } + + @Test + public void testHiveNullPartitionValue() + { + // Trino's HivePartitionKey encodes a null partition value as the literal string "\N" + PrefilledColumnValues values = prefilledValues(new HivePartitionKey("pk_string", "\\N")); + HiveColumnHandle handle = partitionKey("pk_string", VARCHAR, HiveType.HIVE_STRING); + + Block block = singleValueBlock(values, handle); + assertThat(block.isNull(0)).isTrue(); + + // Values are resolved once per column and reused, so both read paths have to keep returning null + // for a hive-null column after the first read has populated the memo. + BlockBuilder blockBuilder = VARCHAR.createBlockBuilder(null, 2); + values.appendTo(handle, blockBuilder); + values.appendTo(handle, blockBuilder); + Block repeated = blockBuilder.build(); + assertThat(repeated.isNull(0)).isTrue(); + assertThat(repeated.isNull(1)).isTrue(); + assertThat(values.toRleBlock(handle, 1).isNull(0)).isTrue(); + } + + @Test + public void testHiddenColumns() + { + PrefilledColumnValues values = prefilledValues( + new HivePartitionKey("year", "2020"), + new HivePartitionKey("month", "5")); + + assertThat(VARCHAR.getSlice(singleValueBlock(values, pathColumnHandle()), 0).toStringUtf8()) + .isEqualTo(FILE_PATH); + assertThat(BIGINT.getLong(singleValueBlock(values, fileSizeColumnHandle()), 0)) + .isEqualTo(FILE_SIZE); + long packedTimestamp = TIMESTAMP_TZ_MILLIS.getLong(singleValueBlock(values, fileModifiedTimeColumnHandle()), 0); + assertThat(unpackMillisUtc(packedTimestamp)).isEqualTo(FILE_MODIFIED_TIME); + } + + @Test + public void testPartitionNamePreservesKeyOrder() + { + // Keys must render in the split's partition-column order, not e.g. hash order + PrefilledColumnValues values = prefilledValues( + new HivePartitionKey("year", "2020"), + new HivePartitionKey("month", "5"), + new HivePartitionKey("day", "17")); + + assertThat(VARCHAR.getSlice(singleValueBlock(values, partitionColumnHandle()), 0).toStringUtf8()) + .isEqualTo("year=2020/month=5/day=17"); + } + + @Test + public void testUnknownColumnIsNullFilled() + { + PrefilledColumnValues values = prefilledValues(); + HiveColumnHandle dataColumn = HiveColumnHandle.createBaseColumn( + "some_col", 0, HiveType.HIVE_STRING, VARCHAR, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()); + + assertThat(values.isPrefilled(dataColumn)).isFalse(); + Block block = values.toRleBlock(dataColumn, 3); + assertThat(block.getPositionCount()).isEqualTo(3); + assertThat(block.isNull(0)).isTrue(); + } + + @Test + public void testRleBlockShape() + { + PrefilledColumnValues values = prefilledValues(new HivePartitionKey("year", "2020")); + HiveColumnHandle handle = partitionKey("year", VARCHAR, HiveType.HIVE_STRING); + + assertThat(values.isPrefilled(handle)).isTrue(); + Block block = values.toRleBlock(handle, 5); + assertThat(block).isInstanceOf(RunLengthEncodedBlock.class); + assertThat(block.getPositionCount()).isEqualTo(5); + assertThat(VARCHAR.getSlice(block, 4).toStringUtf8()).isEqualTo("2020"); + + assertThat(values.toRleBlock(handle, 0).getPositionCount()).isEqualTo(0); + } + + @Test + public void testAppendTo() + { + PrefilledColumnValues values = prefilledValues(new HivePartitionKey("pk_int", "42")); + HiveColumnHandle handle = partitionKey("pk_int", INTEGER, HiveType.HIVE_INT); + + BlockBuilder blockBuilder = INTEGER.createBlockBuilder(null, 2); + values.appendTo(handle, blockBuilder); + values.appendTo(handle, blockBuilder); + Block block = blockBuilder.build(); + + assertThat(block.getPositionCount()).isEqualTo(2); + assertThat(INTEGER.getInt(block, 1)).isEqualTo(42); + } + + private static PrefilledColumnValues prefilledValues(HivePartitionKey... partitionKeys) + { + HudiBaseFile baseFile = new HudiBaseFile(FILE_PATH, "file1.parquet", FILE_SIZE, FILE_MODIFIED_TIME, 0, FILE_SIZE); + HudiSplit split = new HudiSplit( + baseFile, + List.of(), + "001", + TupleDomain.all(), + List.of(partitionKeys), + SplitWeight.standard()); + return PrefilledColumnValues.create(split); + } + + private static HiveColumnHandle partitionKey(String name, Type type, HiveType hiveType) + { + return HiveColumnHandle.createBaseColumn(name, -1, hiveType, type, HiveColumnHandle.ColumnType.PARTITION_KEY, Optional.empty()); + } + + private static Block singleValueBlock(PrefilledColumnValues values, HiveColumnHandle handle) + { + return values.toRleBlock(handle, 1); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestingHudiConnectorFactory.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestingHudiConnectorFactory.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestingHudiConnectorFactory.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestingHudiConnectorFactory.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestingHudiPlugin.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestingHudiPlugin.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/TestingHudiPlugin.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/TestingHudiPlugin.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/io/TestInlineSeekableDataInputStream.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/io/TestInlineSeekableDataInputStream.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/io/TestInlineSeekableDataInputStream.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/io/TestInlineSeekableDataInputStream.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java similarity index 98% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java index 24c1b16dff5f7..6e9db6e1785b2 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/partition/TestHudiPartitionInfoLoader.java @@ -16,7 +16,6 @@ import com.google.common.collect.ImmutableList; import io.airlift.units.DataSize; import io.trino.filesystem.Location; -import io.trino.filesystem.cache.DefaultCachingHostAddressProvider; import io.trino.metastore.Partition; import io.trino.metastore.StorageFormat; import io.trino.plugin.hive.HiveColumnHandle; @@ -43,6 +42,7 @@ import java.util.Deque; import java.util.Iterator; import java.util.List; +import java.util.OptionalLong; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.Executors; @@ -161,12 +161,14 @@ private static HudiSplitFactory createSplitFactory() TABLE_PATH, HoodieTableType.COPY_ON_WRITE, ImmutableList.of(), + ImmutableList.of(), TupleDomain.all(), TupleDomain.all(), + OptionalLong.empty(), "", "101"); HudiSplitWeightProvider weightProvider = new SizeBasedSplitWeightProvider(0.05, DataSize.of(128, MEGABYTE)); - return new HudiSplitFactory(tableHandle, weightProvider, DataSize.of(128, MEGABYTE), new DefaultCachingHostAddressProvider()); + return new HudiSplitFactory(tableHandle, weightProvider, DataSize.of(128, MEGABYTE)); } private static HiveHudiPartitionInfo createTestPartition(String partitionPath) diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupportTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupportTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupportTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/query/index/HudiRecordLevelIndexSupportTest.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/query/index/TestingColumnHandle.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/query/index/TestingColumnHandle.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/query/index/TestingColumnHandle.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/query/index/TestingColumnHandle.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java similarity index 67% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java index 9c4f4a6176fcf..067c0f7b3dbdf 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/split/TestHudiSplitFactory.java @@ -15,7 +15,6 @@ import com.google.common.collect.ImmutableList; import io.airlift.units.DataSize; -import io.trino.filesystem.cache.DefaultCachingHostAddressProvider; import io.trino.plugin.hive.HivePartitionKey; import io.trino.plugin.hudi.HudiSplit; import io.trino.plugin.hudi.HudiTableHandle; @@ -32,9 +31,11 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.OptionalLong; import static io.airlift.units.DataSize.Unit.MEGABYTE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class TestHudiSplitFactory { @@ -101,19 +102,63 @@ public void testCreateHudiSplitsWithOversizedFileExceedingSlop() } @Test - public void testCreateHudiSplitsWithLargerBlockSize() + public void testCreateHudiSplitsIgnoresBlockSize() { - // Test with 1MB target split size and 32MB base file - // - should create 4 splits because the block size of 8MB is larger than the target split size + // Test with 2MB target and 8MB base file whose reported block size is 8MB + // - the block size must be ignored, so 4 splits of the 2MB target size are expected + // (previously the 8MB block size beat the target and produced 1 split of 8MB) testSplitCreation( - DataSize.of(1, MEGABYTE), - DataSize.of(32, MEGABYTE), + DataSize.of(2, MEGABYTE), + DataSize.of(8, MEGABYTE), Option.empty(), ImmutableList.of( - Pair.of(0L, DataSize.of(8, MEGABYTE)), - Pair.of(DataSize.of(8, MEGABYTE).toBytes(), DataSize.of(8, MEGABYTE)), - Pair.of(DataSize.of(16, MEGABYTE).toBytes(), DataSize.of(8, MEGABYTE)), - Pair.of(DataSize.of(24, MEGABYTE).toBytes(), DataSize.of(8, MEGABYTE)))); + Pair.of(0L, DataSize.of(2, MEGABYTE)), + Pair.of(DataSize.of(2, MEGABYTE).toBytes(), DataSize.of(2, MEGABYTE)), + Pair.of(DataSize.of(4, MEGABYTE).toBytes(), DataSize.of(2, MEGABYTE)), + Pair.of(DataSize.of(6, MEGABYTE).toBytes(), DataSize.of(2, MEGABYTE)))); + } + + @Test + public void testCreateHudiSplitsWithFileSmallerThanDefaultTarget() + { + // Regression test for the split inflation reported in trinodb/trino#29842 (hudi#19231): + // a ~120MB file with the default 128MB target must produce exactly 1 split + testSplitCreation( + DataSize.of(128, MEGABYTE), + DataSize.of(120, MEGABYTE), + Option.empty(), + ImmutableList.of( + Pair.of(0L, DataSize.of(120, MEGABYTE)))); + } + + @Test + public void testCreateHudiSplitsWithFileLargerThanDefaultTarget() + { + // Test with 128MB target and 500MB base file + // - should be sliced at target boundaries into 3 x 128MB + 116MB remainder, even though the + // reported block size (500MB, the file length) would otherwise force a single split + testSplitCreation( + DataSize.of(128, MEGABYTE), + DataSize.of(500, MEGABYTE), + Option.empty(), + ImmutableList.of( + Pair.of(0L, DataSize.of(128, MEGABYTE)), + Pair.of(DataSize.of(128, MEGABYTE).toBytes(), DataSize.of(128, MEGABYTE)), + Pair.of(DataSize.of(256, MEGABYTE).toBytes(), DataSize.of(128, MEGABYTE)), + Pair.of(DataSize.of(384, MEGABYTE).toBytes(), DataSize.of(116, MEGABYTE)))); + } + + @Test + public void testCreateHudiSplitsWithZeroTargetSplitSize() + { + // A zero target split size must be rejected on construction, before any file slice is seen, + // instead of looping forever once split generation reaches a non-empty base file + assertThatThrownBy(() -> new HudiSplitFactory( + createTableHandle(), + new SizeBasedSplitWeightProvider(0.05, DataSize.of(128, MEGABYTE)), + DataSize.ofBytes(0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("targetSplitSize"); } @Test @@ -151,9 +196,8 @@ private static void testSplitCreation( FileSlice fileSlice = createFileSlice(baseFileSize, logFileSize); - List splits = HudiSplitFactory.createHudiSplits( - tableHandle, PARTITION_KEYS, fileSlice, COMMIT_TIME, weightProvider, targetSplitSize, - new DefaultCachingHostAddressProvider()); + List splits = new HudiSplitFactory(tableHandle, weightProvider, targetSplitSize) + .createSplits(PARTITION_KEYS, fileSlice, COMMIT_TIME); assertThat(splits).hasSize(expectedSplitInfo.size()); @@ -181,8 +225,10 @@ private static HudiTableHandle createTableHandle() "/test/path", HoodieTableType.MERGE_ON_READ, ImmutableList.of(), + ImmutableList.of(), TupleDomain.all(), TupleDomain.all(), + OptionalLong.empty(), "", "101"); } @@ -191,14 +237,17 @@ private static FileSlice createFileSlice(DataSize baseFileSize, Option { String fileId = "5a4f6a70-0306-40a8-952b-045b0d8ff0d4-0"; HoodieFileGroupId fileGroupId = new HoodieFileGroupId("partition", fileId); - long blockSize = 8L * 1024 * 1024; + // Block size mirrors the file length, which is what HudiTrinoStorage now reports. Split + // generation must ignore it, so every multi-split expectation below would collapse to a + // single whole-file split if the block size were allowed back into the sizing decision. String baseFilePath = "/test/path/" + fileGroupId + "_4-19-0_" + COMMIT_TIME + ".parquet"; String logFilePath = "/test/path/." + fileId + "_2025062515374131546.log.1_0-53-80"; + long logFileSizeInBytes = logFileSize.isPresent() ? logFileSize.get().toBytes() : 0L; StoragePathInfo baseFileInfo = new StoragePathInfo( - new StoragePath(baseFilePath), baseFileSize.toBytes(), false, (short) 0, blockSize, System.currentTimeMillis()); + new StoragePath(baseFilePath), baseFileSize.toBytes(), false, (short) 0, baseFileSize.toBytes(), System.currentTimeMillis()); StoragePathInfo logFileInfo = new StoragePathInfo( - new StoragePath(logFilePath), logFileSize.isPresent() ? logFileSize.get().toBytes() : 0L, - false, (short) 0, blockSize, System.currentTimeMillis()); + new StoragePath(logFilePath), logFileSizeInBytes, + false, (short) 0, logFileSizeInBytes, System.currentTimeMillis()); HoodieBaseFile baseFile = new HoodieBaseFile(baseFileInfo); return new FileSlice(fileGroupId, COMMIT_TIME, baseFile, logFileSize.isPresent() ? ImmutableList.of(new HoodieLogFile(logFileInfo)) : ImmutableList.of()); diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/storage/TestHudiTrinoStorage.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/storage/TestHudiTrinoStorage.java new file mode 100644 index 0000000000000..a085a73af5512 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/storage/TestHudiTrinoStorage.java @@ -0,0 +1,150 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.storage; + +import io.trino.filesystem.FileEntry; +import io.trino.filesystem.Location; +import io.trino.filesystem.TrinoFileSystem; +import io.trino.filesystem.memory.MemoryFileSystem; +import org.apache.hudi.storage.StoragePath; +import org.apache.hudi.storage.StoragePathInfo; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestHudiTrinoStorage +{ + @Test + void testConvertToPathInfo() + { + FileEntry fileEntry = new FileEntry( + Location.of("memory:///table/data.parquet"), + 42, + Instant.ofEpochMilli(1234567890123L), + Optional.empty()); + + StoragePathInfo pathInfo = HudiTrinoStorage.convertToPathInfo(fileEntry); + + assertThat(pathInfo.getPath()).isEqualTo(new StoragePath("memory:///table/data.parquet")); + assertThat(pathInfo.getLength()).isEqualTo(42); + assertThat(pathInfo.isFile()).isTrue(); + assertThat(pathInfo.getBlockReplication()).isEqualTo((short) 0); + assertThat(pathInfo.getBlockSize()).isEqualTo(42); + assertThat(pathInfo.getModificationTime()).isEqualTo(1234567890123L); + } + + @Test + void testGetPathInfoForFile() + throws IOException + { + TrinoFileSystem fileSystem = new MemoryFileSystem(); + writeFile(fileSystem, "memory:///table/data.parquet", 42); + HudiTrinoStorage storage = new HudiTrinoStorage(fileSystem, new TrinoStorageConfiguration()); + + StoragePathInfo pathInfo = storage.getPathInfo(new StoragePath("memory:///table/data.parquet")); + + assertThat(pathInfo.getLength()).isEqualTo(42); + assertThat(pathInfo.isFile()).isTrue(); + assertThat(pathInfo.getBlockSize()).isEqualTo(42); + assertThat(pathInfo.getModificationTime()).isGreaterThan(0); + } + + @Test + void testGetPathInfoForDirectory() + throws IOException + { + TrinoFileSystem fileSystem = new MemoryFileSystem(); + writeFile(fileSystem, "memory:///table/data.parquet", 42); + HudiTrinoStorage storage = new HudiTrinoStorage(fileSystem, new TrinoStorageConfiguration()); + + StoragePathInfo pathInfo = storage.getPathInfo(new StoragePath("memory:///table")); + + assertThat(pathInfo.isDirectory()).isTrue(); + assertThat(pathInfo.getLength()).isEqualTo(0); + assertThat(pathInfo.getBlockSize()).isEqualTo(0); + } + + @Test + void testListFiles() + throws IOException + { + HudiTrinoStorage storage = createStorageWithFiles(); + + List entries = storage.listFiles(new StoragePath("memory:///table")); + + assertThat(entries).hasSize(3); + assertThat(entries.get(0).getPath()).isEqualTo(new StoragePath("memory:///table/a.parquet")); + assertThat(entries.get(1).getPath()).isEqualTo(new StoragePath("memory:///table/b.parquet")); + assertThat(entries.get(2).getPath()).isEqualTo(new StoragePath("memory:///table/nested/c.parquet")); + assertThat(entries.get(0).getLength()).isEqualTo(10); + assertThat(entries.get(1).getLength()).isEqualTo(20); + assertThat(entries.get(2).getLength()).isEqualTo(30); + for (StoragePathInfo entry : entries) { + assertThat(entry.getBlockSize()).isEqualTo(entry.getLength()); + } + } + + @Test + void testListDirectEntries() + throws IOException + { + HudiTrinoStorage storage = createStorageWithFiles(); + + List entries = storage.listDirectEntries(new StoragePath("memory:///table")); + + assertThat(entries).hasSize(2); + assertThat(entries.get(0).getPath()).isEqualTo(new StoragePath("memory:///table/a.parquet")); + assertThat(entries.get(1).getPath()).isEqualTo(new StoragePath("memory:///table/b.parquet")); + for (StoragePathInfo entry : entries) { + assertThat(entry.getBlockSize()).isEqualTo(entry.getLength()); + } + } + + @Test + void testListDirectEntriesWithFilter() + throws IOException + { + HudiTrinoStorage storage = createStorageWithFiles(); + + List entries = storage.listDirectEntries( + new StoragePath("memory:///table"), + path -> path.getName().equals("b.parquet")); + + assertThat(entries).hasSize(1); + assertThat(entries.get(0).getPath()).isEqualTo(new StoragePath("memory:///table/b.parquet")); + assertThat(entries.get(0).getLength()).isEqualTo(20); + assertThat(entries.get(0).getBlockSize()).isEqualTo(20); + } + + private static HudiTrinoStorage createStorageWithFiles() + throws IOException + { + TrinoFileSystem fileSystem = new MemoryFileSystem(); + writeFile(fileSystem, "memory:///table/a.parquet", 10); + writeFile(fileSystem, "memory:///table/b.parquet", 20); + writeFile(fileSystem, "memory:///table/nested/c.parquet", 30); + return new HudiTrinoStorage(fileSystem, new TrinoStorageConfiguration()); + } + + private static void writeFile(TrinoFileSystem fileSystem, String location, int length) + throws IOException + { + fileSystem.newOutputFile(Location.of(location)).createOrOverwrite(new byte[length]); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java new file mode 100644 index 0000000000000..9bcf0d6bcac4e --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java @@ -0,0 +1,307 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.trino.filesystem.Location; +import io.trino.filesystem.TrinoFileSystem; +import io.trino.filesystem.TrinoFileSystemFactory; +import io.trino.metastore.Column; +import io.trino.metastore.HiveMetastore; +import io.trino.metastore.HiveMetastoreFactory; +import io.trino.metastore.PrincipalPrivileges; +import io.trino.metastore.StorageFormat; +import io.trino.metastore.Table; +import io.trino.plugin.hudi.HudiConnector; +import io.trino.spi.security.ConnectorIdentity; +import io.trino.testing.QueryRunner; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericRecord; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.common.HoodieJavaEngineContext; +import org.apache.hudi.common.bootstrap.index.NoOpBootstrapIndex; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieAvroRecord; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.marker.MarkerType; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieCompactionConfig; +import org.apache.hudi.config.HoodieIndexConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.google.common.io.MoreFiles.deleteRecursively; +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; +import static io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_INPUT_FORMAT; +import static io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_REALTIME_INPUT_FORMAT; +import static io.trino.hive.formats.HiveClassNames.MAPRED_PARQUET_OUTPUT_FORMAT_CLASS; +import static io.trino.hive.formats.HiveClassNames.PARQUET_HIVE_SERDE_CLASS; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static io.trino.plugin.hive.TableType.EXTERNAL_TABLE; +import static java.nio.file.Files.createTempDirectory; +import static java.util.Objects.requireNonNull; + +/** + * Shared machinery for the non-partitioned Merge-On-Read fixtures that exist to exercise record merging at + * read time. The table is written by the Hudi Java write client into a local staging directory and then + * mirrored into the Trino filesystem the connector reads from; two metastore tables are registered against + * that location, a read-optimized one (base files only) and a real-time one (suffix {@code _rt}) that merges + * base + log files through the file group reader. + *

    + * Inline compaction is off for every fixture so the log files written by the delta commits survive and must + * be merged at read time, and the metadata table is off because MDT writes need hbase dependencies that are + * not on the Trino test classpath. + *

    + * Subclasses supply the schema, the merge-related table and write configuration, and the commits; everything + * a fixture does not vary lives here. + */ +public abstract class AbstractMergerHudiTablesInitializer + implements HudiTablesInitializer +{ + /** Every fixture built on this base is keyed on {@code key} and ordered by {@code ts}, in a single unnamed partition. */ + protected static final String RECORD_KEY_FIELD = "key"; + protected static final String ORDERING_FIELD = "ts"; + + private static final String PARTITION_PATH = ""; + + /** Hudi metadata columns, prepended to every table's data columns in the metastore definition. */ + static final List HUDI_META_COLUMNS = ImmutableList.of( + new Column("_hoodie_commit_time", HIVE_STRING, Optional.empty(), Map.of()), + new Column("_hoodie_commit_seqno", HIVE_STRING, Optional.empty(), Map.of()), + new Column("_hoodie_record_key", HIVE_STRING, Optional.empty(), Map.of()), + new Column("_hoodie_partition_path", HIVE_STRING, Optional.empty(), Map.of()), + new Column("_hoodie_file_name", HIVE_STRING, Optional.empty(), Map.of())); + + private final String tableName; + + private TrinoFileSystem fileSystem; + private Location tableLocation; + private java.nio.file.Path stagingDir; + private Path stagingTablePath; + private HoodieJavaWriteClient writeClient; + + protected AbstractMergerHudiTablesInitializer(String tableName) + { + this.tableName = requireNonNull(tableName, "tableName is null"); + } + + @Override + public final void initializeTables(QueryRunner queryRunner, Location externalLocation, String schemaName) + throws Exception + { + fileSystem = ((HudiConnector) queryRunner.getCoordinator().getConnector("hudi")).getInjector() + .getInstance(TrinoFileSystemFactory.class) + .create(ConnectorIdentity.ofUser("test")); + HiveMetastore metastore = ((HudiConnector) queryRunner.getCoordinator().getConnector("hudi")).getInjector() + .getInstance(HiveMetastoreFactory.class) + .createMetastore(Optional.empty()); + + tableLocation = externalLocation.appendPath(tableName); + stagingDir = createTempDirectory(tableName.replace('_', '-')); + stagingTablePath = new Path(stagingDir.resolve(tableName).toUri()); + + boolean initialized = false; + try { + initTable(); + afterTableInit(); + writeClient = createWriteClient(); + writeInitialCommits(writeClient); + syncToTrino(); + + metastore.createTable(createTableDefinition(schemaName, tableName, false), PrincipalPrivileges.NO_PRIVILEGES); + metastore.createTable(createTableDefinition(schemaName, tableName + "_rt", true), PrincipalPrivileges.NO_PRIVILEGES); + initialized = true; + } + finally { + // Only fixtures that keep writing commits after initialization need the staging directory and the + // write client to outlive this call; they are responsible for calling close(). + if (!initialized || !keepsWriterOpen()) { + close(); + } + } + } + + /** The table's data columns, in schema order; the Hudi metadata columns are prepended by this class. */ + protected abstract List dataColumns(); + + /** + * Whether the metastore definition prepends {@link #HUDI_META_COLUMNS}. The base file always carries them, so + * returning {@code false} models hive sync with {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}: + * every data column's metastore ordinal then sits five below its physical position in the file. + */ + protected boolean includeMetaColumnsInMetastore() + { + return true; + } + + /** The Avro schema the write client writes, matching {@link #dataColumns()}. */ + protected abstract Schema avroSchema(); + + /** + * Applies the fixture's merge-related table configuration (merge mode, merge strategy id, payload class). + * The table type, record key fields and ordering fields are set by this class. + */ + protected abstract void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder); + + /** Applies the fixture's merge-related write configuration (merge mode, merge strategy id, merger impl classes). */ + protected abstract void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder); + + /** Writes the commits that make up the fixture's initial state. */ + protected abstract void writeInitialCommits(HoodieJavaWriteClient client) + throws IOException; + + /** Runs on the freshly created table, before the write client exists and before any data is written. */ + protected void afterTableInit() + throws IOException {} + + /** Inline compaction is disabled, so this only has to stay above the number of delta commits a fixture writes. */ + protected int maxDeltaCommitsBeforeCompaction() + { + return 100; + } + + /** Whether the staging directory and write client survive {@link #initializeTables}; such fixtures must call {@link #close()}. */ + protected boolean keepsWriterOpen() + { + return false; + } + + public void close() + throws IOException + { + if (writeClient != null) { + writeClient.close(); + writeClient = null; + } + if (stagingDir != null) { + deleteRecursively(stagingDir, ALLOW_INSECURE); + stagingDir = null; + } + } + + /** Local directory the write client writes into, before {@link #syncToTrino()} mirrors it to the connector. */ + protected java.nio.file.Path stagingTableDirectory() + { + return stagingDir.resolve(tableName); + } + + protected HoodieJavaWriteClient writeClient() + { + return writeClient; + } + + protected static HoodieRecord avroRecord(GenericRecord record, String key) + { + return new HoodieAvroRecord<>(hoodieKey(key), new HoodieAvroPayload(Option.of(record)), null); + } + + /** Addresses a record in the single unnamed partition, e.g. for hard deletes via {@code writeClient.delete}. */ + protected static HoodieKey hoodieKey(String key) + { + return new HoodieKey(key, PARTITION_PATH); + } + + /** Mirrors the staged table into the Trino filesystem so the connector observes the commits written so far. */ + protected void syncToTrino() + { + try { + if (fileSystem.directoryExists(tableLocation).orElse(false)) { + fileSystem.deleteDirectory(tableLocation); + } + ResourceHudiTablesInitializer.copyDir(stagingTableDirectory(), fileSystem, tableLocation); + } + catch (IOException e) { + throw new RuntimeException("Failed to sync staged Hudi table to Trino filesystem", e); + } + } + + private void initTable() + { + HoodieTableMetaClient.TableBuilder tableBuilder = HoodieTableMetaClient.newTableBuilder() + .setTableType(HoodieTableType.MERGE_ON_READ) + .setTableName(tableName) + .setTimelineLayoutVersion(1) + .setBootstrapIndexClass(NoOpBootstrapIndex.class.getName()) + .setRecordKeyFields(RECORD_KEY_FIELD) + .setOrderingFields(ORDERING_FIELD); + configureTableConfig(tableBuilder); + try { + tableBuilder.initTable(new HadoopStorageConfiguration(new Configuration()), stagingTablePath.toString()); + } + catch (IOException e) { + throw new RuntimeException("Could not init table " + tableName, e); + } + } + + private HoodieJavaWriteClient createWriteClient() + { + Configuration conf = new Configuration(); + HoodieWriteConfig.Builder writeConfigBuilder = HoodieWriteConfig.newBuilder() + .withPath(stagingTablePath.toString()) + .withSchema(avroSchema().toString()) + .withParallelism(2, 2) + .withDeleteParallelism(2) + .forTable(tableName) + // No withPreCombineField here: the ordering field is carried by the table config + // (setOrderingFields), and the deprecated builder method fails the -Werror compile gate. + .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build()) + // Keep log files around so merging runs at read time. + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .withInlineCompaction(false) + .withMaxNumDeltaCommitsBeforeCompaction(maxDeltaCommitsBeforeCompaction()) + .build()) + .withEmbeddedTimelineServerEnabled(false) + .withMarkersType(MarkerType.DIRECT.name()) + // MDT writes require hbase deps not present in the Trino runtime. + .withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build()); + configureWriteConfig(writeConfigBuilder); + return new HoodieJavaWriteClient<>(new HoodieJavaEngineContext(new HadoopStorageConfiguration(conf)), writeConfigBuilder.build()); + } + + private Table createTableDefinition(String schemaName, String metastoreTableName, boolean isRtTable) + { + StorageFormat storageFormat = StorageFormat.create( + PARQUET_HIVE_SERDE_CLASS, + isRtTable ? HUDI_PARQUET_REALTIME_INPUT_FORMAT : HUDI_PARQUET_INPUT_FORMAT, + MAPRED_PARQUET_OUTPUT_FORMAT_CLASS); + + return Table.builder() + .setDatabaseName(schemaName) + .setTableName(metastoreTableName) + .setTableType(EXTERNAL_TABLE.name()) + .setOwner(Optional.of("public")) + .setDataColumns(ImmutableList.builder() + .addAll(includeMetaColumnsInMetastore() ? HUDI_META_COLUMNS : ImmutableList.of()) + .addAll(dataColumns()) + .build()) + .setParameters(ImmutableMap.of("serialization.format", "1", "EXTERNAL", "TRUE")) + .withStorage(storageBuilder -> storageBuilder + .setStorageFormat(storageFormat) + .setLocation(tableLocation.toString())) + .build(); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java new file mode 100644 index 0000000000000..8fa5c7fb3d790 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java @@ -0,0 +1,129 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table in {@link RecordMergeMode#COMMIT_TIME_ORDERING} that + * exercises the read-side merge-mode dispatch (issue apache/hudi#18898). ONLY a record merge mode is set + * (no payload class), so table creation persists the mode as-is, which is exactly the dispatch input + * {@code HudiTrinoReaderContext.getRecordMerger} switches on. + *

    + * A base commit is followed by a log commit whose update carries an ordering value LOWER than the base + * row's: latest-write-wins must KEEP the update, the exact mirror of the event-time obsolete-update case + * in {@link EventTimeDeletesHudiTablesInitializer}, which is what discriminates the two merger dispatches. + * A final commit hard-deletes a key ({@code writeClient.delete}); commit-time deletes always win. See + * {@code TestHudiMorMergeModeSemantics}. + */ +public class CommitTimeOrderingHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "commit_time_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public CommitTimeOrderingHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: base parquet file with 3 keys at ts 100. + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 100L), + record(schema, "k2", "k2_base", 20L, 100L), + record(schema, "k3", "k3_base", 30L, 100L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit (log file): update k1 with a LOWER ts (50). Commit-time ordering keeps the + // LATEST WRITE regardless of the ordering value -- the exact mirror of the event-time k6 + // case, which discriminates OverwriteWithLatestMerger from event-time merging. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 11L, 50L)), secondCommit); + client.commit(secondCommit, secondStatuses); + + // Third commit: hard delete of k2 (commit-time deletes always win). + String deleteCommit = client.startCommit(); + List deleteStatuses = client.delete( + ImmutableList.of(hoodieKey("k2")), deleteCommit); + client.commit(deleteCommit, deleteStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CompositeHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CompositeHudiTablesInitializer.java new file mode 100644 index 0000000000000..3fa4b7176f2d3 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CompositeHudiTablesInitializer.java @@ -0,0 +1,47 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.filesystem.Location; +import io.trino.testing.QueryRunner; + +import java.util.List; + +/** + * Runs several {@link HudiTablesInitializer}s against one query runner, in order, so a single test class can + * load more than one table fixture. Each delegate must create its own tables under a distinct name, and must + * finish its writes within {@code initializeTables}: a fixture that keeps its write client open (see + * {@code AbstractMergerHudiTablesInitializer.keepsWriterOpen}) cannot be composed here, because + * {@link HudiTablesInitializer} declares no close for this class to forward. + */ +public class CompositeHudiTablesInitializer + implements HudiTablesInitializer +{ + private final List delegates; + + public CompositeHudiTablesInitializer(HudiTablesInitializer... delegates) + { + this.delegates = ImmutableList.copyOf(delegates); + } + + @Override + public void initializeTables(QueryRunner queryRunner, Location externalLocation, String schemaName) + throws Exception + { + for (HudiTablesInitializer delegate : delegates) { + delegate.initializeTables(queryRunner, externalLocation, schemaName); + } + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CustomMergerHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CustomMergerHudiTablesInitializer.java new file mode 100644 index 0000000000000..b107cde6741a0 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CustomMergerHudiTablesInitializer.java @@ -0,0 +1,129 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table at test runtime that is configured to use a custom record + * merger ({@link KeyBasedTestRecordMerger}) via {@link RecordMergeMode#CUSTOM}. The table is written with one + * {@code insert} (producing base files) followed by one {@code upsert} of the same keys (producing log files), + * with inline compaction disabled so the log files survive and must be merged at read time. + *

    + * Two tables are registered in the metastore: a read-optimized table (base files only) and a real-time table + * (suffix {@code _rt}) that merges base + log files through the file group reader. + *

    + * Data is laid out so the key-based merge result is distinguishable from both the base-only view and the + * built-in newest-wins behavior (see {@code TestHudiCustomMerger}). + */ +public class CustomMergerHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "custom_merger_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public CustomMergerHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder + .setPayloadClassName(HoodieAvroPayload.class.getName()) + .setRecordMergeMode(RecordMergeMode.CUSTOM) + .setRecordMergeStrategyId(KeyBasedTestRecordMerger.MERGE_STRATEGY_ID); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder + .withRecordMergeMode(RecordMergeMode.CUSTOM) + .withRecordMergeStrategyId(KeyBasedTestRecordMerger.MERGE_STRATEGY_ID) + .withRecordMergeImplClasses(KeyBasedTestRecordMerger.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: bulk insert base records (produces base parquet files). + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 1L), + record(schema, "k2", "k2_base", 100L, 1L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit: upserts the same keys (produces log files since inline compaction is disabled). + // k1 update has a larger value (99 > 10) -> keep-max keeps the update. + // k2 update has a smaller value (5 < 100) -> keep-max keeps the original base record. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 99L, 2L), + record(schema, "k2", "k2_updated", 5L, 2L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java new file mode 100644 index 0000000000000..0d1de49d5a3a1 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java @@ -0,0 +1,135 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.model.AWSDmsAvroPayload; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static org.apache.hudi.common.model.AWSDmsAvroPayload.DELETE_OPERATION_VALUE; +import static org.apache.hudi.common.model.AWSDmsAvroPayload.OP_FIELD; + +/** + * Creates a non-partitioned Merge-On-Read table whose merge semantics come from the + * {@link AWSDmsAvroPayload} class persisted in the table config (issue apache/hudi#18898). ONLY the payload + * class is set (no merge mode / strategy id), so table creation translates it exactly as a real writer + * would: at the current table version this "deprecated" payload becomes COMMIT_TIME_ORDERING plus PREFIXED + * delete-key props ({@code hoodie.record.merge.property.hoodie.payload.delete.field=Op}, marker {@code D}). + *

    + * A base commit is followed by a log record with {@code Op='D'}, which deletes the row at merge time via + * {@code DeleteContext}, with the payload never executing at read, plus a log record with the non-marker + * {@code Op='U'} whose update must APPLY rather than delete -- pinning the marker-value comparison itself. + *

    + * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is NOT a {@code BaseAvroPayload}), + * so rows a semantic payload would drop at write time land as DATA records and every merge decision happens + * at read time. See {@code TestHudiMorPayloadSemantics}. + */ +public class DmsPayloadHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "dms_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public DmsPayloadHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + // The Avro/parquet field is 'Op' (AWSDms hardcodes that casing), but a real Hive + // metastore lowercases column names on DDL -- exactly the case mismatch the connector's + // merge-column matching must bridge + new Column(OP_FIELD.toLowerCase(Locale.ROOT), HIVE_STRING, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(OP_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setPayloadClassName(AWSDmsAvroPayload.class.getName()); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withWritePayLoad(AWSDmsAvroPayload.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, "I", 100L), + record(schema, "k2", "k2_base", 20L, "I", 100L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Log records, both written as DATA records by the pass-through HoodieAvroPayload. Only k2's + // marker value deletes at merge time via DeleteContext (delete key Op, marker D from the + // translated table config); k1 carries the NON-marker Op='U' and its update must apply, so a + // marker comparison that fires on any non-null Op fails the suite. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 11L, "U", 200L), + record(schema, "k2", "k2_deleted", 22L, DELETE_OPERATION_VALUE, 200L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, String op, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(OP_FIELD, op); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java new file mode 100644 index 0000000000000..12cb59371b99b --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java @@ -0,0 +1,156 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.JsonProperties; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_BOOLEAN; +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static org.apache.hudi.common.model.HoodieRecord.HOODIE_IS_DELETED_FIELD; + +/** + * Creates a non-partitioned Merge-On-Read table in {@link RecordMergeMode#EVENT_TIME_ORDERING} that + * exercises the read-side merge-mode dispatch with deletes (issue apache/hudi#18898). ONLY a record merge + * mode is set (no payload class), so table creation persists the mode as-is, which is exactly the dispatch + * input {@code HudiTrinoReaderContext.getRecordMerger} switches on. + *

    + * A base commit is followed by a log commit carrying an update, a soft delete + * ({@code _hoodie_is_deleted=true}), an OBSOLETE soft delete and an OBSOLETE update (both with an ordering + * value LOWER than the base row's, so event-time merging must keep the base row), and then a hard-delete + * commit ({@code writeClient.delete}) that produces a native delete log file read back through the + * connector's {@code getFileRecordIterator}. + *

    + * Records are wrapped in {@link HoodieAvroPayload}, which implements {@code HoodieRecordPayload} directly + * (NOT {@code BaseAvroPayload}), so rows with {@code _hoodie_is_deleted=true} are written as DATA records + * and delete semantics are evaluated at READ time. See {@code TestHudiMorMergeModeSemantics}. + */ +public class EventTimeDeletesHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "deletes_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public EventTimeDeletesHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of()), + new Column(HOODIE_IS_DELETED_FIELD, HIVE_BOOLEAN, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field( + HOODIE_IS_DELETED_FIELD, + Schema.createUnion(Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.BOOLEAN)), + null, + JsonProperties.NULL_VALUE)); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: base parquet file with 6 keys, all at ordering value (ts) 100. + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 100L, false), + record(schema, "k2", "k2_base", 20L, 100L, false), + record(schema, "k3", "k3_base", 30L, 100L, false), + record(schema, "k4", "k4_base", 40L, 100L, false), + record(schema, "k5", "k5_base", 50L, 100L, false), + record(schema, "k6", "k6_base", 60L, 100L, false)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit (log file). Event-time merging must resolve each key by ordering value: + // - k1: update with HIGHER ts (200) -> update wins + // - k3: soft delete with HIGHER ts (200) -> row deleted at read time + // - k4: soft delete with LOWER ts (50) -> OBSOLETE delete, base row survives + // - k6: update with LOWER ts (50) -> OBSOLETE update, base row survives + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 11L, 200L, false), + record(schema, "k3", "k3_deleted", 33L, 200L, true), + record(schema, "k4", "k4_deleted", 44L, 50L, true), + record(schema, "k6", "k6_updated", 66L, 50L, false)), secondCommit); + client.commit(secondCommit, secondStatuses); + + // Third commit: HARD delete of k2. At the current table version this produces a native + // delete log file, which the file-group reader reads back through the connector's + // getFileRecordIterator with the synthetic delete-log schema (record key + ordering). + // Hard deletes carry the sentinel ordering value and win regardless of merge mode. + String deleteCommit = client.startCommit(); + List deleteStatuses = client.delete( + ImmutableList.of(hoodieKey("k2")), deleteCommit); + client.commit(deleteCommit, deleteStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long ts, boolean deleted) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(ORDERING_FIELD, ts); + record.put(HOODIE_IS_DELETED_FIELD, deleted); + // HoodieAvroPayload passes the record through untouched (it is not a BaseAvroPayload), so a row + // with _hoodie_is_deleted=true is WRITTEN as a data record and only deleted at merge/read time. + return avroRecord(record, key); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTableUnzipper.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTableUnzipper.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTableUnzipper.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTableUnzipper.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTablesInitializer.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTablesInitializer.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTablesInitializer.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTestUtils.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTestUtils.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/HudiTestUtils.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTestUtils.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/IncrementalCustomMergerHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/IncrementalCustomMergerHudiTablesInitializer.java new file mode 100644 index 0000000000000..57d69ee3e584c --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/IncrementalCustomMergerHudiTablesInitializer.java @@ -0,0 +1,364 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import io.trino.metastore.HiveType; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static io.trino.metastore.HiveType.HIVE_BOOLEAN; +import static io.trino.metastore.HiveType.HIVE_DOUBLE; +import static io.trino.metastore.HiveType.HIVE_INT; +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static java.lang.String.format; + +/** + * Creates a non-partitioned Merge-On-Read table configured with the {@link MaxRankRecordMerger} custom merger + * ({@link RecordMergeMode#CUSTOM}) and drives a controlled sequence of commits so an end-to-end test can read the + * table through Trino after every commit and validate the merged result. + *

    + * The schema has 30 data columns (record key, the {@code merge_rank} decision column, an ordering field, and 27 + * additional columns spanning string/long/int/double/boolean types). {@link #NUM_RECORDS} record keys are + * bulk-inserted in the first commit; each subsequent commit upserts roughly two thirds of the keys with freshly + * derived values. Inline compaction is disabled so every delta commit survives as a log file and must be merged at + * read time. + *

    + * Every record column is a pure deterministic function of {@code (recordIndex, commitNumber)} (see + * {@link #valueFor}). Because the merge keeps the record with the maximum {@code merge_rank} (ties to the newer + * commit), the winning commit for each key is known in closed form, so the full expected row for the real-time + * table is reproduced exactly by {@link #expectedRows()} without reading anything back. + *

    + * Unlike the single-shot fixtures, the write client and its staging directory stay open after initialization so + * {@link #writeAndSyncNextCommit()} can add commits mid-test; the test must call {@link #close()} when done. + */ +public class IncrementalCustomMergerHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "custom_merger_e2e"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public static final int TOTAL_COMMITS = 20; + public static final int NUM_RECORDS = 10_000; + + private static final String RANK_FIELD = MaxRankRecordMerger.RANK_COLUMN; + + private enum Kind + { + STRING, LONG, INT, DOUBLE, BOOLEAN + } + + private record ColumnSpec(String name, Kind kind) {} + + /** The 30 data columns, in the order they appear in the schema and in query projections. */ + private static final List COLUMN_SPECS = buildColumnSpecs(); + + private static final List DATA_COLUMNS = buildDataColumns(); + private static final Schema AVRO_SCHEMA = buildAvroSchema(); + + /** Winning commit per record index under the max-rank merge policy (-1 = not yet inserted). */ + private final int[] winningCommit = new int[NUM_RECORDS]; + /** Most recent commit that touched each record index (used to measure divergence from newest-wins). */ + private final int[] latestCommit = new int[NUM_RECORDS]; + private int currentCommit; + + public IncrementalCustomMergerHudiTablesInitializer() + { + super(TABLE_NAME); + Arrays.fill(winningCommit, -1); + Arrays.fill(latestCommit, -1); + } + + @Override + protected List dataColumns() + { + return DATA_COLUMNS; + } + + @Override + protected Schema avroSchema() + { + return AVRO_SCHEMA; + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder + .setPayloadClassName(HoodieAvroPayload.class.getName()) + .setRecordMergeMode(RecordMergeMode.CUSTOM) + .setRecordMergeStrategyId(MaxRankRecordMerger.MERGE_STRATEGY_ID); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder + .withRecordMergeMode(RecordMergeMode.CUSTOM) + .withRecordMergeStrategyId(MaxRankRecordMerger.MERGE_STRATEGY_ID) + .withRecordMergeImplClasses(MaxRankRecordMerger.class.getName()); + } + + @Override + protected int maxDeltaCommitsBeforeCompaction() + { + return TOTAL_COMMITS + 100; + } + + @Override + protected boolean keepsWriterOpen() + { + return true; + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + // First commit: bulk insert all keys (produces the base parquet files the read-optimized table reads). + currentCommit = 1; + String firstCommit = client.startCommit(); + List statuses = client.bulkInsert(buildRecords(currentCommit), firstCommit); + client.commit(firstCommit, statuses); + recordCommit(currentCommit); + } + + /** + * Writes the next delta commit (upsert of ~2/3 of the keys) and mirrors it into the Trino filesystem so the + * connector observes the new commit on the next query. Must be called after {@link #initializeTables}. + */ + public void writeAndSyncNextCommit() + { + currentCommit++; + String commitTime = writeClient().startCommit(); + List statuses = writeClient().upsert(buildRecords(currentCommit), commitTime); + writeClient().commit(commitTime, statuses); + recordCommit(currentCommit); + syncToTrino(); + } + + /** Ordered data column names (record key first), matching {@link #expectedRows()} value positions. */ + public List dataColumnNames() + { + return COLUMN_SPECS.stream().map(ColumnSpec::name).collect(toImmutableList()); + } + + /** Expected real-time (merged) rows after the commits written so far: key -> values in column order. */ + public Map expectedRows() + { + Map rows = new LinkedHashMap<>(); + for (int ki = 0; ki < NUM_RECORDS; ki++) { + if (winningCommit[ki] < 0) { + continue; + } + Object[] row = rowFor(ki, winningCommit[ki]); + rows.put((String) row[0], row); + } + return rows; + } + + /** Expected read-optimized (base-file-only) rows: always the first-commit values for every key. */ + public Map baseRows() + { + Map rows = new LinkedHashMap<>(); + for (int ki = 0; ki < NUM_RECORDS; ki++) { + Object[] row = rowFor(ki, 1); + rows.put((String) row[0], row); + } + return rows; + } + + /** Number of keys whose merged winner is not their most recently committed version (custom != newest-wins). */ + public int divergentKeyCount() + { + int count = 0; + for (int ki = 0; ki < NUM_RECORDS; ki++) { + if (winningCommit[ki] >= 0 && winningCommit[ki] != latestCommit[ki]) { + count++; + } + } + return count; + } + + private void recordCommit(int commit) + { + for (int ki = 0; ki < NUM_RECORDS; ki++) { + if (!isUpdated(ki, commit)) { + continue; + } + latestCommit[ki] = commit; + int prev = winningCommit[ki]; + // Mirror MaxRankRecordMerger: the incoming (newer) record wins on a tie or a strictly larger rank. + if (prev < 0 || mergeRank(ki, commit) >= mergeRank(ki, prev)) { + winningCommit[ki] = commit; + } + } + } + + private List> buildRecords(int commit) + { + List> records = new ArrayList<>(); + for (int ki = 0; ki < NUM_RECORDS; ki++) { + if (isUpdated(ki, commit)) { + records.add(record(ki, commit)); + } + } + return records; + } + + private static boolean isUpdated(int recordIndex, int commit) + { + // The first commit inserts every key; later commits upsert ~2/3 of the keys, rotating which third is skipped. + return commit == 1 || Math.floorMod(recordIndex + commit, 3) != 0; + } + + private static HoodieRecord record(int recordIndex, int commit) + { + GenericRecord record = new GenericData.Record(AVRO_SCHEMA); + for (int i = 0; i < COLUMN_SPECS.size(); i++) { + record.put(COLUMN_SPECS.get(i).name(), valueFor(i, recordIndex, commit)); + } + return avroRecord(record, key(recordIndex)); + } + + private static Object[] rowFor(int recordIndex, int commit) + { + Object[] row = new Object[COLUMN_SPECS.size()]; + for (int i = 0; i < row.length; i++) { + row[i] = valueFor(i, recordIndex, commit); + } + return row; + } + + /** + * The single source of truth for every column value, used both when writing records and when reproducing the + * expected rows. Returns boxed types matching how Trino surfaces the column (String/Long/Integer/Double/Boolean). + */ + private static Object valueFor(int columnIndex, int recordIndex, int commit) + { + ColumnSpec spec = COLUMN_SPECS.get(columnIndex); + if (spec.name().equals(RECORD_KEY_FIELD)) { + return key(recordIndex); + } + if (spec.name().equals(RANK_FIELD)) { + return mergeRank(recordIndex, commit); + } + if (spec.name().equals(ORDERING_FIELD)) { + return (long) commit; + } + return switch (spec.kind()) { + case STRING -> "v_" + columnIndex + "_" + recordIndex + "_" + commit; + case LONG -> recordIndex * 1_000_003L + (long) commit * columnIndex; + case INT -> (int) Math.floorMod(recordIndex * 31L + (long) commit * columnIndex, 1_000_000L); + case DOUBLE -> recordIndex + commit * 0.5 + columnIndex * 0.125; + case BOOLEAN -> (recordIndex + commit + columnIndex) % 2 == 0; + }; + } + + private static String key(int recordIndex) + { + return format("key%05d", recordIndex); + } + + /** Deterministic, non-monotonic rank in [0, 100000) so the winning commit is rarely the latest one. */ + private static long mergeRank(int recordIndex, int commit) + { + return Math.floorMod(recordIndex * 2_654_435_761L + commit * 40_503L, 100_000L); + } + + private static List buildColumnSpecs() + { + ImmutableList.Builder specs = ImmutableList.builder(); + specs.add(new ColumnSpec(RECORD_KEY_FIELD, Kind.STRING)); + specs.add(new ColumnSpec(RANK_FIELD, Kind.LONG)); + specs.add(new ColumnSpec(ORDERING_FIELD, Kind.LONG)); + for (int i = 0; i < 7; i++) { + specs.add(new ColumnSpec("s" + i, Kind.STRING)); + } + for (int i = 0; i < 7; i++) { + specs.add(new ColumnSpec("l" + i, Kind.LONG)); + } + for (int i = 0; i < 6; i++) { + specs.add(new ColumnSpec("i" + i, Kind.INT)); + } + for (int i = 0; i < 5; i++) { + specs.add(new ColumnSpec("d" + i, Kind.DOUBLE)); + } + for (int i = 0; i < 2; i++) { + specs.add(new ColumnSpec("b" + i, Kind.BOOLEAN)); + } + List built = specs.build(); + if (built.size() != 30) { + throw new IllegalStateException("Expected 30 data columns but built " + built.size()); + } + return built; + } + + private static List buildDataColumns() + { + ImmutableList.Builder columns = ImmutableList.builder(); + for (ColumnSpec spec : COLUMN_SPECS) { + columns.add(new Column(spec.name(), hiveType(spec.kind()), Optional.empty(), Map.of())); + } + return columns.build(); + } + + private static Schema buildAvroSchema() + { + List fields = new ArrayList<>(); + for (ColumnSpec spec : COLUMN_SPECS) { + fields.add(new Schema.Field(spec.name(), Schema.create(avroType(spec.kind())))); + } + return Schema.createRecord(TABLE_NAME, null, null, false, fields); + } + + private static HiveType hiveType(Kind kind) + { + return switch (kind) { + case STRING -> HIVE_STRING; + case LONG -> HIVE_LONG; + case INT -> HIVE_INT; + case DOUBLE -> HIVE_DOUBLE; + case BOOLEAN -> HIVE_BOOLEAN; + }; + } + + private static Schema.Type avroType(Kind kind) + { + return switch (kind) { + case STRING -> Schema.Type.STRING; + case LONG -> Schema.Type.LONG; + case INT -> Schema.Type.INT; + case DOUBLE -> Schema.Type.DOUBLE; + case BOOLEAN -> Schema.Type.BOOLEAN; + }; + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/KeyBasedTestRecordMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/KeyBasedTestRecordMerger.java new file mode 100644 index 0000000000000..c970e4086628c --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/KeyBasedTestRecordMerger.java @@ -0,0 +1,84 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.engine.RecordContext; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.table.read.BufferedRecord; + +import java.io.IOException; + +/** + * Test-only custom {@link HoodieRecordMerger} used to verify that the Hudi Trino connector resolves and + * applies a user-supplied record merger for Merge-On-Read tables whose record merge mode is {@code CUSTOM}. + *

    + * The merge policy is deliberately distinct from the built-in mergers and depends only on the record key + * (which is projection-independent): for keys ending in an odd digit it keeps the newer record, otherwise it + * keeps the older one. This is neither the built-in newest-wins (event-time) behavior nor the base-only view, + * so its effect is observable in query results regardless of which columns a query projects. + *

    + * Operates on Avro records ({@link HoodieRecord.HoodieRecordType#AVRO}), which is the record type the Trino + * reader context uses ({@code EngineType.JAVA}). + */ +public class KeyBasedTestRecordMerger + implements HoodieRecordMerger +{ + /** + * Unique strategy id identifying this custom merger. The test table's + * {@code hoodie.record.merge.strategy.id} is set to this value so the reader resolves this implementation. + */ + public static final String MERGE_STRATEGY_ID = "f9b5c1a2-0d3e-4c7a-8b6f-2a1e4d9c0b7a"; + + @Override + public BufferedRecord merge(BufferedRecord older, BufferedRecord newer, RecordContext recordContext, TypedProperties props) + throws IOException + { + // Deletes are passed through so tombstones still win; this test does not exercise deletes. + if (older == null || older.isDelete() || newer.isDelete()) { + return newer; + } + return keepNewer(newer.getRecordKey()) ? newer : older; + } + + private static boolean keepNewer(String recordKey) + { + char last = recordKey.charAt(recordKey.length() - 1); + return Character.isDigit(last) && ((last - '0') % 2 == 1); + } + + /** + * Merging depends only on the record key, so it works on projected records. Without this override the + * file-group reader reads ALL table columns for merging, which the Trino connector does not support -- + * it can only resolve columns in the projection plus the merger's declared mandatory fields. + */ + @Override + public boolean isProjectionCompatible() + { + return true; + } + + @Override + public HoodieRecord.HoodieRecordType getRecordType() + { + return HoodieRecord.HoodieRecordType.AVRO; + } + + @Override + public String getMergingStrategy() + { + return MERGE_STRATEGY_ID; + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/MaxRankRecordMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/MaxRankRecordMerger.java new file mode 100644 index 0000000000000..1a3d2a532a745 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/MaxRankRecordMerger.java @@ -0,0 +1,108 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.engine.RecordContext; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.read.BufferedRecord; + +import java.io.IOException; +import java.util.Arrays; +import java.util.LinkedHashSet; + +/** + * Test-only custom {@link HoodieRecordMerger} that keeps, for each record key, the record with the largest value + * in the {@code merge_rank} column. Ties are broken in favor of the newer (later-committed) record. + *

    + * This policy is deliberately distinct from every built-in merge mode: it depends on an arbitrary data column + * rather than commit time (newest-wins) or the ordering/precombine field (event-time ordering). Because the + * winning commit for a key is frequently not the most recent one, the merged result is observably + * different from both the read-optimized (base-only) view and the built-in newest-wins behavior, which is what + * {@code TestHudiCustomMergerEndToEnd} validates after every commit. + *

    + * Operates on Avro records ({@link HoodieRecord.HoodieRecordType#AVRO}), the record type the Trino reader + * context uses ({@code EngineType.JAVA}). + */ +public class MaxRankRecordMerger + implements HoodieRecordMerger +{ + /** + * Unique strategy id identifying this custom merger. The test table's + * {@code hoodie.record.merge.strategy.id} is set to this value so the reader resolves this implementation. + */ + public static final String MERGE_STRATEGY_ID = "3c2d7e10-9a4b-4f81-bd6e-7f0a1c5e8d24"; + + /** Name of the column whose value decides the merge. */ + public static final String RANK_COLUMN = "merge_rank"; + + @Override + public BufferedRecord merge(BufferedRecord older, BufferedRecord newer, RecordContext recordContext, TypedProperties props) + throws IOException + { + // Deletes are passed through so tombstones still win; this test does not exercise deletes. + if (older == null || older.isDelete() || newer.isDelete()) { + return newer; + } + long olderRank = rankOf(older, recordContext); + long newerRank = rankOf(newer, recordContext); + // Keep the newer record on ties so the policy stays deterministic and matches the expected-state fold. + return newerRank >= olderRank ? newer : older; + } + + private static long rankOf(BufferedRecord record, RecordContext recordContext) + { + HoodieSchema schema = recordContext.getSchemaFromBufferRecord(record); + Object value = recordContext.getValue(record.getRecord(), schema, RANK_COLUMN); + return ((Number) value).longValue(); + } + + @Override + public String[] getMandatoryFieldsForMerging(HoodieSchema dataSchema, HoodieTableConfig cfg, TypedProperties properties) + { + // Declare merge_rank (read in merge()) as mandatory, on top of the default key/ordering fields, so the + // reader includes it in the read schema even when a query does not project it. + LinkedHashSet fields = new LinkedHashSet<>( + Arrays.asList(HoodieRecordMerger.super.getMandatoryFieldsForMerging(dataSchema, cfg, properties))); + fields.add(RANK_COLUMN); + return fields.toArray(new String[0]); + } + + /** + * Merging depends only on {@code merge_rank}, which is declared mandatory above, so it works on projected + * records. Without this override the file-group reader reads ALL table columns for merging (see + * {@link NonProjectionCompatibleRankMerger} for that path); declaring projection compatibility keeps reads + * on the cheaper projected fast path. + */ + @Override + public boolean isProjectionCompatible() + { + return true; + } + + @Override + public HoodieRecord.HoodieRecordType getRecordType() + { + return HoodieRecord.HoodieRecordType.AVRO; + } + + @Override + public String getMergingStrategy() + { + return MERGE_STRATEGY_ID; + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleMergerHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleMergerHudiTablesInitializer.java new file mode 100644 index 0000000000000..c007d60607ec7 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleMergerHudiTablesInitializer.java @@ -0,0 +1,133 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table at test runtime configured with the + * NON-projection-compatible {@link NonProjectionCompatibleRankMerger} via {@link RecordMergeMode#CUSTOM}. + * One {@code bulkInsert} (base files) is followed by one {@code upsert} of the same keys (log files), with + * inline compaction disabled so the merger runs at read time over a full-table-schema read. + *

    + * Data is laid out so each merge direction is distinguishable: one key's winning rank comes from the LOG + * record and the other's from the BASE record, so a correct result proves both sides of the merge supplied + * the (never projected) {@code merge_rank} column. See {@code TestHudiNonProjectionCompatibleMerger}. + */ +public class NonProjectionCompatibleMergerHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "non_projection_merger_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + private static final String RANK_FIELD = NonProjectionCompatibleRankMerger.RANK_COLUMN; + + public NonProjectionCompatibleMergerHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(RANK_FIELD, HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(RANK_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder + .setPayloadClassName(HoodieAvroPayload.class.getName()) + .setRecordMergeMode(RecordMergeMode.CUSTOM) + .setRecordMergeStrategyId(NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder + .withRecordMergeMode(RecordMergeMode.CUSTOM) + .withRecordMergeStrategyId(NonProjectionCompatibleRankMerger.MERGE_STRATEGY_ID) + .withRecordMergeImplClasses(NonProjectionCompatibleRankMerger.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: bulk insert base records (produces base parquet files). + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 5L, 1L), + record(schema, "k2", "k2_base", 100L, 9L, 1L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit: upserts the same keys (produces log files since inline compaction is disabled). + // k1 update has a HIGHER rank (7 > 5) -> merge keeps the update (99): the LOG record's rank decides. + // k2 update has a LOWER rank (1 < 9) -> merge keeps the base record (100): the BASE record's rank + // decides, which only works when the base read carries merge_rank despite it never being projected. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 99L, 7L, 2L), + record(schema, "k2", "k2_updated", 4L, 1L, 2L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long rank, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(RANK_FIELD, rank); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleRankMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleRankMerger.java new file mode 100644 index 0000000000000..4e332bc3be5a1 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleRankMerger.java @@ -0,0 +1,85 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.engine.RecordContext; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieRecordMerger; +import org.apache.hudi.common.schema.HoodieSchema; +import org.apache.hudi.common.table.read.BufferedRecord; + +import java.io.IOException; + +/** + * Test-only custom {@link HoodieRecordMerger} with the same keep-max-{@code merge_rank} policy as + * {@link MaxRankRecordMerger}, but deliberately WITHOUT the {@code isProjectionCompatible()} and + * {@code getMandatoryFieldsForMerging()} overrides. It therefore reports the interface default + * {@code isProjectionCompatible() == false} and never declares {@code merge_rank} as mandatory, so + * nothing prepends the rank column into the connector's read projection. The file-group reader reacts + * by demanding the FULL table schema as its required schema for any split with log files -- for the + * base and log reads alike -- making this merger the acceptance case for full-schema reads + * (apache/hudi#19249): a query that does not project {@code merge_rank} merges correctly only if both + * sides of the merge supply it. + */ +public class NonProjectionCompatibleRankMerger + implements HoodieRecordMerger +{ + /** + * Unique strategy id identifying this custom merger. The test table's + * {@code hoodie.record.merge.strategy.id} is set to this value so the reader resolves this implementation. + */ + public static final String MERGE_STRATEGY_ID = "8e1f4a6b-2c9d-4d35-9a7e-5b0c8f3d1e42"; + + /** Name of the column whose value decides the merge. */ + public static final String RANK_COLUMN = "merge_rank"; + + @Override + public BufferedRecord merge(BufferedRecord older, BufferedRecord newer, RecordContext recordContext, TypedProperties props) + throws IOException + { + // Deletes are passed through so tombstones still win; this test does not exercise deletes. + if (older == null || older.isDelete() || newer.isDelete()) { + return newer; + } + long olderRank = rankOf(older, recordContext); + long newerRank = rankOf(newer, recordContext); + // Keep the newer record on ties so the policy stays deterministic. + return newerRank >= olderRank ? newer : older; + } + + private static long rankOf(BufferedRecord record, RecordContext recordContext) + { + HoodieSchema schema = recordContext.getSchemaFromBufferRecord(record); + Object value = recordContext.getValue(record.getRecord(), schema, RANK_COLUMN); + // A null here means the reader failed to supply merge_rank on this side of the merge (the very + // regression this merger exists to catch); fail loudly instead of merging arbitrarily. + if (value == null) { + throw new IllegalStateException("merge_rank is missing from a record of schema " + schema); + } + return ((Number) value).longValue(); + } + + @Override + public HoodieRecord.HoodieRecordType getRecordType() + { + return HoodieRecord.HoodieRecordType.AVRO; + } + + @Override + public String getMergingStrategy() + { + return MERGE_STRATEGY_ID; + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleTestRecordMerger.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleTestRecordMerger.java new file mode 100644 index 0000000000000..bf1d1c5672eb7 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleTestRecordMerger.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +/** + * Test-only merger that is identical to {@link KeyBasedTestRecordMerger} except that it reports + * {@code isProjectionCompatible() == false}, which makes the file-group reader request the FULL table schema + * instead of the connector's projection. + *

    + * The connector expands the read projection to the full table schema for such mergers, so narrow queries + * still merge correctly. This merger exists purely to exercise that full-schema path; it shares + * {@link KeyBasedTestRecordMerger#MERGE_STRATEGY_ID} so it resolves against the same test table. + */ +public class NonProjectionCompatibleTestRecordMerger + extends KeyBasedTestRecordMerger +{ + @Override + public boolean isProjectionCompatible() + { + return false; + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedMetaColumnsHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedMetaColumnsHudiTablesInitializer.java new file mode 100644 index 0000000000000..1be2c44cf65df --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedMetaColumnsHudiTablesInitializer.java @@ -0,0 +1,157 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned table whose METASTORE column list omits the five {@code _hoodie_*} meta columns the + * base file itself carries -- the layout hive sync leaves behind with + * {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}. Every data column's metastore ordinal is therefore + * five below its physical position, which is the shape {@code hudi.parquet.use-column-names=false} resolves + * positionally and the one apache/hudi#19387 was about. Every other fixture in this module registers the meta + * columns ({@code ResourceHudiTablesInitializer.TestingTable.getDataColumns} prepends them unconditionally), so + * metastore ordinal equals physical ordinal there and the bug cannot appear. + *

    + * The column list looks padded on purpose. Physical fields 0..4 are always the meta columns, so a data column at + * metastore ordinal {@code i} is read positionally at physical field {@code i}, which is a meta column for any + * {@code i < 5}. A meta column can never be part of a projection here -- the metastore does not expose it -- so + * {@code descriptorsByPath} has no entry for it and a stray domain is silently discarded rather than misapplied. + * Only from the SIXTH data column on does the stale ordinal land on a real, projectable column. Hence + * {@code late_value} at metastore ordinal 5, shadowed by {@code shadowed_value} at physical field 5, and the four + * fillers in between. + *

    + * The values are disjoint so the damage is unambiguous: {@code shadowed_value} stays in 1..5 while + * {@code late_value} is above 1000, so a domain meant for {@code late_value} but matched against + * {@code shadowed_value}'s statistics prunes the only row group and the query returns nothing. + *

    + * A single bulk-insert commit, so the file slice has no log files: predicate pushdown is only enabled for + * base-file-only splits. See {@code TestHudiConnectorParquetColumnNamesTest}. + */ +public class OmittedMetaColumnsHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "omitted_meta_columns_mor"; + + /** The column the predicate goes on: metastore ordinal 5, physical field 10. */ + public static final String LATE_COLUMN = "late_value"; + /** The column physically sitting at {@link #LATE_COLUMN}'s stale ordinal, and therefore the one that shadows it. */ + public static final String SHADOWED_COLUMN = "shadowed_value"; + /** Below every {@link #LATE_COLUMN} value and above every {@link #SHADOWED_COLUMN} one. */ + public static final long THRESHOLD = 900; + + private static final int ROW_COUNT = 5; + private static final List FILLER_COLUMNS = ImmutableList.of("filler_1", "filler_2", "filler_3", "filler_4"); + + public OmittedMetaColumnsHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected boolean includeMetaColumnsInMetastore() + { + return false; + } + + @Override + protected List dataColumns() + { + ImmutableList.Builder columns = ImmutableList.builder(); + columns.add(new Column(SHADOWED_COLUMN, HIVE_LONG, Optional.empty(), Map.of())); + FILLER_COLUMNS.forEach(name -> columns.add(new Column(name, HIVE_LONG, Optional.empty(), Map.of()))); + columns.add(new Column(LATE_COLUMN, HIVE_LONG, Optional.empty(), Map.of())); + columns.add(new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of())); + columns.add(new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + return columns.build(); + } + + @Override + protected Schema avroSchema() + { + List fields = new ArrayList<>(); + fields.add(new Schema.Field(SHADOWED_COLUMN, Schema.create(Schema.Type.LONG))); + FILLER_COLUMNS.forEach(name -> fields.add(new Schema.Field(name, Schema.create(Schema.Type.LONG)))); + fields.add(new Schema.Field(LATE_COLUMN, Schema.create(Schema.Type.LONG))); + fields.add(new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING))); + fields.add(new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, fields); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + List> records = new ArrayList<>(); + for (int row = 1; row <= ROW_COUNT; row++) { + records.add(record(schema, "k" + row, row, 1000L + row)); + } + // One commit only: a file slice with log files would take the merge path, which disables pushdown. + String commit = client.startCommit(); + List statuses = client.bulkInsert(records, commit); + client.commit(commit, statuses); + } + + /** The expected rows of {@code SELECT key, shadowed_value, late_value ... WHERE late_value > THRESHOLD}. */ + public static String expectedRowsAboveThreshold() + { + List rows = new ArrayList<>(); + for (int row = 1; row <= ROW_COUNT; row++) { + rows.add("('k%s', CAST(%s AS BIGINT), CAST(%s AS BIGINT))".formatted(row, row, 1000 + row)); + } + return "VALUES " + String.join(", ", rows); + } + + private static HoodieRecord record(Schema schema, String key, long shadowedValue, long lateValue) + { + GenericRecord record = new GenericData.Record(schema); + record.put(SHADOWED_COLUMN, shadowedValue); + FILLER_COLUMNS.forEach(name -> record.put(name, 0L)); + record.put(LATE_COLUMN, lateValue); + record.put(RECORD_KEY_FIELD, key); + record.put(ORDERING_FIELD, 100L); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedOrderingFieldHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedOrderingFieldHudiTablesInitializer.java new file mode 100644 index 0000000000000..c9e31efef18d2 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedOrderingFieldHudiTablesInitializer.java @@ -0,0 +1,126 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.DefaultHoodieRecordPayload; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table in {@link RecordMergeMode#EVENT_TIME_ORDERING} whose + * METASTORE column list deliberately omits the ordering field {@code ts} that the Avro table schema (and the + * data files) carry -- the shape hive sync leaves behind when a column is not synced. Event-time merging + * still needs {@code ts} on both sides of every merge, so reads of the real-time table only produce correct + * results when the merge path recovers the column from the resolved table schema; without that recovery the + * base read cannot supply {@code ts} and the read fails. + *

    + * Data is laid out so each merge direction is distinguishable: {@code k1}'s winning event time is on the LOG + * record and {@code k2}'s on the BASE record. See {@code TestHudiNonProjectionCompatibleMerger}. + */ +public class OmittedOrderingFieldHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "omitted_ordering_field_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public OmittedOrderingFieldHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + // No ts column: the fixture's whole point is a metastore that does not know the ordering field. + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder + .setPayloadClassName(DefaultHoodieRecordPayload.class.getName()) + .setRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: bulk insert base records (produces base parquet files). + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 5L), + record(schema, "k2", "k2_base", 100L, 9L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit: upserts the same keys (produces log files since inline compaction is disabled). + // k1's update carries a NEWER event time (7 > 5) -> the update wins (99): the LOG record's ts decides. + // k2's update carries an OLDER event time (1 < 9) -> the base record wins (100): the BASE record's ts + // decides, which only works when the base read carries ts despite the metastore not knowing it. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 99L, 7L), + record(schema, "k2", "k2_updated", 4L, 1L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedRankFieldHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedRankFieldHudiTablesInitializer.java new file mode 100644 index 0000000000000..94cf8deada0e9 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OmittedRankFieldHudiTablesInitializer.java @@ -0,0 +1,135 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table using the projection-compatible {@link MaxRankRecordMerger} + * via {@link RecordMergeMode#CUSTOM}, whose METASTORE column list deliberately omits the merger's mandatory + * {@code merge_rank} column that the Avro table schema (and the data files) carry. The merger reads + * {@code merge_rank} on both sides of every merge, so reads of the real-time table only produce correct + * results when the merge path recovers merger-declared mandatory columns from the resolved table schema by + * asking the merger itself; without that the base read cannot supply {@code merge_rank} and the read fails. + *

    + * Data is laid out so each merge direction is distinguishable: {@code k1}'s winning rank is on the LOG + * record and {@code k2}'s on the BASE record. See {@code TestHudiNonProjectionCompatibleMerger}. + */ +public class OmittedRankFieldHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "omitted_rank_field_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + private static final String RANK_FIELD = MaxRankRecordMerger.RANK_COLUMN; + + public OmittedRankFieldHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + // No merge_rank column: the fixture's whole point is a metastore that does not know the column the + // merger declares mandatory. + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(RANK_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder + .setPayloadClassName(HoodieAvroPayload.class.getName()) + .setRecordMergeMode(RecordMergeMode.CUSTOM) + .setRecordMergeStrategyId(MaxRankRecordMerger.MERGE_STRATEGY_ID); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder + .withRecordMergeMode(RecordMergeMode.CUSTOM) + .withRecordMergeStrategyId(MaxRankRecordMerger.MERGE_STRATEGY_ID) + .withRecordMergeImplClasses(MaxRankRecordMerger.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // First commit: bulk insert base records (produces base parquet files). + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 5L, 1L), + record(schema, "k2", "k2_base", 100L, 9L, 1L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit: upserts the same keys (produces log files since inline compaction is disabled). + // k1's update has a HIGHER rank (7 > 5) -> keep-max keeps the update (99): the LOG record's rank decides. + // k2's update has a LOWER rank (1 < 9) -> keep-max keeps the base record (100): the BASE record's rank + // decides, which only works when the base read carries merge_rank despite the metastore not knowing it. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 99L, 7L, 2L), + record(schema, "k2", "k2_updated", 4L, 1L, 2L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long rank, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(RANK_FIELD, rank); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java new file mode 100644 index 0000000000000..ba460111d5d30 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java @@ -0,0 +1,123 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.JsonProperties; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.OverwriteNonDefaultsWithLatestAvroPayload; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table whose merge semantics come from the + * {@link OverwriteNonDefaultsWithLatestAvroPayload} class persisted in the table config (issue + * apache/hudi#18898). ONLY the payload class is set (no merge mode / strategy id), so table creation + * translates it exactly as a real writer would: into COMMIT_TIME_ORDERING plus + * {@code PARTIAL_UPDATE_MODE=IGNORE_DEFAULTS}. + *

    + * A base commit is followed by an update whose column is null (the schema default), which must keep the + * STORED value for that column at merge time. + *

    + * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is NOT a {@code BaseAvroPayload}), + * so every merge decision happens at read time from the table config. See + * {@code TestHudiMorPayloadSemantics}. + */ +public class OverwriteNonDefaultsPayloadHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "overwrite_non_defaults_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public OverwriteNonDefaultsPayloadHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("a", HIVE_STRING, Optional.empty(), Map.of()), + new Column("b", HIVE_STRING, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + Schema nullableString = Schema.createUnion(Schema.create(Schema.Type.NULL), Schema.create(Schema.Type.STRING)); + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("a", nullableString, null, JsonProperties.NULL_VALUE), + new Schema.Field("b", nullableString, null, JsonProperties.NULL_VALUE), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setPayloadClassName(OverwriteNonDefaultsWithLatestAvroPayload.class.getName()); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withWritePayLoad(OverwriteNonDefaultsWithLatestAvroPayload.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "base_a", "base_b", 100L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Update with b=null (the schema default): IGNORE_DEFAULTS partial merging must keep the + // stored 'base_b' while taking the updated 'new_a'. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "new_a", null, 200L)), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String a, String b, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("a", a); + record.put("b", b); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/PayloadOnlyMergerHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/PayloadOnlyMergerHudiTablesInitializer.java new file mode 100644 index 0000000000000..b1b4456cbc3ae --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/PayloadOnlyMergerHudiTablesInitializer.java @@ -0,0 +1,209 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableConfig; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Creates a non-partitioned Merge-On-Read table at TABLE VERSION 6 whose only merge configuration is a + * {@link org.apache.hudi.common.model.HoodieRecordPayload} class ({@link RankBasedTestPayload}), the way a + * genuine pre-1.0 table looks on storage. + *

    + * Such a table resolves at read time to {@code CUSTOM} merge mode with the payload-based merge strategy, which + * in turn resolves {@code HoodieAvroRecordMerger}. That merger is not projection compatible, so the file group + * reader demands the FULL table schema for base and log reads alike and nothing prepends the payload's decision + * column into the connector's read projection. + *

    + * One {@code bulkInsert} (base files) is followed by one {@code upsert} of the same keys (log files), with data + * laid out so each merge direction is distinguishable: {@code k1}'s winning rank is on the LOG record and + * {@code k2}'s on the BASE record. See {@code TestHudiNonProjectionCompatibleMerger}. + */ +public class PayloadOnlyMergerHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "payload_only_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + private static final String RANK_FIELD = RankBasedTestPayload.RANK_COLUMN; + private static final HoodieTableVersion TABLE_VERSION = HoodieTableVersion.SIX; + + public PayloadOnlyMergerHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(RANK_FIELD, HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(RANK_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + // Only the payload class: the merge mode and the merge strategy id must stay out of hoodie.properties + // so the reader has to infer them, which is what makes this a pre-1.0 payload-only table. + tableBuilder + .setTableVersion(TABLE_VERSION) + .setPayloadClassName(RankBasedTestPayload.class.getName()); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder + .withWriteTableVersion(TABLE_VERSION.versionCode()) + // Without this the write client silently upgrades the table to the current version on the first commit. + .withAutoUpgradeVersion(false) + .withWritePayLoad(RankBasedTestPayload.class.getName()); + } + + @Override + protected void afterTableInit() + throws IOException + { + stripInferredMergeStrategyId(); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + throws IOException + { + Schema schema = avroSchema(); + // First commit: bulk insert base records (produces base parquet files). + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, 5L, 1L), + record(schema, "k2", "k2_base", 100L, 9L, 1L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Second commit: upserts the same keys (produces log files since inline compaction is disabled). + // k1 update has a HIGHER rank (7 > 5) -> the payload keeps the update (99): the LOG record's rank decides. + // k2 update has a LOWER rank (1 < 9) -> the payload keeps the base record (100): the BASE record's rank + // decides, which only works when the base read carries merge_rank despite it never being projected. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 99L, 7L, 2L), + record(schema, "k2", "k2_updated", 4L, 1L, 2L)), secondCommit); + client.commit(secondCommit, secondStatuses); + + // The commits go through the table config; re-check that the writer left the fixture payload-only. + stripInferredMergeStrategyId(); + } + + /** + * Removes the merge strategy id that table creation infers and persists even for a version 6 table, + * then verifies what is left. The merge MODE never reaches disk here: its config is since-version + * 1.0.0, so creation itself drops it for a version 6 table ({@code HoodieTableConfig.dropInvalidConfigs}); + * the {@code checkAbsent} below just pins that. The fixture has to mimic a genuine pre-1.0 table, which + * persists its payload class and nothing else about merging; leaving the inferred id in would hand the + * reader the answer this fixture exists to make it derive. + */ + private void stripInferredMergeStrategyId() + throws IOException + { + Path metaDirectory = stagingTableDirectory().resolve(".hoodie"); + Path propertiesFile = metaDirectory.resolve("hoodie.properties"); + List retainedLines = Files.readAllLines(propertiesFile, UTF_8).stream() + .filter(line -> !isEntryFor(line, HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key())) + .toList(); + Files.write(propertiesFile, retainedLines, UTF_8); + // The Hadoop local filesystem verifies this sidecar when the write client reopens the file, and an + // out-of-band edit leaves it stale; dropping it makes the file checksum-free rather than corrupt. + Files.deleteIfExists(metaDirectory.resolve(".hoodie.properties.crc")); + + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(propertiesFile)) { + properties.load(input); + } + checkProperty(properties, HoodieTableConfig.VERSION.key(), String.valueOf(TABLE_VERSION.versionCode())); + checkProperty(properties, HoodieTableConfig.PAYLOAD_CLASS_NAME.key(), RankBasedTestPayload.class.getName()); + checkAbsent(properties, HoodieTableConfig.RECORD_MERGE_MODE.key()); + checkAbsent(properties, HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key()); + } + + private static boolean isEntryFor(String line, String key) + { + return line.startsWith(key + "=") || line.startsWith(key + ":") || line.startsWith(key + " "); + } + + private static void checkProperty(Properties properties, String key, String expected) + { + String actual = properties.getProperty(key); + if (!expected.equals(actual)) { + throw new IllegalStateException("Expected %s=%s in hoodie.properties of %s but found %s".formatted(key, expected, TABLE_NAME, actual)); + } + } + + private static void checkAbsent(Properties properties, String key) + { + if (properties.containsKey(key)) { + throw new IllegalStateException("Expected no %s in hoodie.properties of %s but found %s".formatted(key, TABLE_NAME, properties.getProperty(key))); + } + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, long rank, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(RANK_FIELD, rank); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/RankBasedTestPayload.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/RankBasedTestPayload.java new file mode 100644 index 0000000000000..c9480a1dc233f --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/RankBasedTestPayload.java @@ -0,0 +1,79 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; +import org.apache.hudi.common.util.Option; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; + +import java.io.IOException; + +/** + * Test-only {@link org.apache.hudi.common.model.HoodieRecordPayload} that keeps whichever of the two records + * being merged carries the larger {@code merge_rank}, ties going to the incoming (newer) record. + *

    + * The policy makes payload-based merging distinguishable from both the base-only view (no merging happened at + * all) and the built-in newest-wins behavior (merging happened, but not through the payload), which is what + * makes it usable as the read-side acceptance case for a table whose only merge configuration is its payload + * class. It also forces {@code merge_rank} to be read on BOTH sides of the merge, so a query that does not + * project the column only produces the right answer over a full-table-schema read. + *

    + * Both constructors are required: Hudi instantiates payloads reflectively through + * {@code HoodieRecordUtils.loadPayload}, which looks up either {@code (GenericRecord, Comparable)} or + * {@code (Option)}. + */ +public class RankBasedTestPayload + extends OverwriteWithLatestAvroPayload +{ + /** Name of the column whose value decides the merge, matching the rank-merger fixtures. */ + public static final String RANK_COLUMN = "merge_rank"; + + public RankBasedTestPayload(GenericRecord record, Comparable orderingVal) + { + super(record, orderingVal); + } + + public RankBasedTestPayload(Option record) + { + super(record); + } + + @Override + public Option combineAndGetUpdateValue(IndexedRecord currentValue, Schema schema) + throws IOException + { + // getInsertValue applies the standard empty/delete handling, so an empty result here is a deletion. + Option incomingRecord = getInsertValue(schema); + if (incomingRecord.isEmpty()) { + return incomingRecord; + } + return rankOf(incomingRecord.get()) >= rankOf(currentValue) ? incomingRecord : Option.of(currentValue); + } + + private static long rankOf(IndexedRecord record) + { + GenericRecord genericRecord = (GenericRecord) record; + Schema.Field field = genericRecord.getSchema().getField(RANK_COLUMN); + Object value = field == null ? null : genericRecord.get(field.pos()); + // A null here means the reader failed to supply merge_rank on this side of the merge (the very + // regression this payload exists to catch); fail loudly instead of merging arbitrarily. + if (value == null) { + throw new IllegalStateException("merge_rank is missing from a record of schema " + genericRecord.getSchema()); + } + return ((Number) value).longValue(); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/StringOrderingHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/StringOrderingHudiTablesInitializer.java new file mode 100644 index 0000000000000..c8d59e9aacdfb --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/StringOrderingHudiTablesInitializer.java @@ -0,0 +1,129 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.RecordMergeMode; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table in {@link RecordMergeMode#EVENT_TIME_ORDERING} whose + * ordering field is a STRING rather than a long. + *

    + * This is the shape that broke on this release line. Base-file records reach the file-group reader through + * {@code HudiAvroSerializer.serialize}, which builds them from Trino blocks, while classic Avro log blocks + * deserialize inline and yield Avro's own {@code Utf8}. The two meet in + * {@code BufferedRecordMergerFactory#shouldKeepNewerRecord}, which compares their ordering values directly, + * and {@code Utf8.compareTo} casts its argument to {@code Utf8} -- so a String base ordering value against a + * Utf8 log ordering value threw ClassCastException and failed the query. + *

    + * A long ordering field cannot catch this: Long is the same class on both sides. The other MoR fixtures all + * order on a long, which is why only the Trino E2E stock-ticks table (string {@code ts}) caught it. + *

    + * Ordering values are written zero-padded so that lexicographic string ordering matches the intended + * event-time ordering. + */ +public class StringOrderingHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "string_ordering_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + public StringOrderingHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column("name", HIVE_STRING, Optional.empty(), Map.of()), + new Column("value", HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_STRING, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("value", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.STRING))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + // Base parquet file: both keys at ts "2018-08-31 10:00:00". + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", "k1_base", 10L, "2018-08-31 10:00:00"), + record(schema, "k2", "k2_base", 20L, "2018-08-31 10:00:00")), firstCommit); + client.commit(firstCommit, firstStatuses); + + // Log commit. Both keys also exist in the base file, so the merge path compares a base ordering + // value against a log one for each -- which is what reproduces the type mismatch. + // - k1: update with a LATER ts -> update wins + // - k2: update with an EARLIER ts -> OBSOLETE, base row survives + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", "k1_updated", 11L, "2018-08-31 11:00:00"), + record(schema, "k2", "k2_updated", 22L, "2018-08-31 09:00:00")), secondCommit); + client.commit(secondCommit, secondStatuses); + } + + private static HoodieRecord record(Schema schema, String key, String name, long value, String ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("value", value); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java new file mode 100644 index 0000000000000..07d437decb134 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java @@ -0,0 +1,131 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import io.trino.metastore.Column; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; + +/** + * Creates a non-partitioned Merge-On-Read table whose merge semantics come from the {@link SummingTestPayload} + * class persisted in the table config (issue apache/hudi#18898). ONLY the payload class is set (no merge mode + * / strategy id), so table creation translates it exactly as a real writer would: this user-defined payload is + * NOT in the deprecation set, so it is persisted as RECORD_MERGE_MODE=CUSTOM with the payload-based merge + * strategy id. Reads resolve {@code HoodieAvroRecordMerger} (no {@code hudi.record-merger-impls} needed) and + * run the payload's {@code combineAndGetUpdateValue}, observable as SUMMED values. + *

    + * A final commit hard-deletes a key ({@code writeClient.delete}): the native delete log record routes to + * {@code HoodieAvroRecordMerger} but wins on its {@code isCommitTimeOrderingDelete} short-circuit (the + * delete carries the sentinel ordering value), before any payload is constructed -- the delete coverage + * both ordering arms already have. + *

    + * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is NOT a {@code BaseAvroPayload}), so + * every merge decision happens at read time from the table config. See {@code TestHudiMorPayloadSemantics}. + */ +public class SummingPayloadHudiTablesInitializer + extends AbstractMergerHudiTablesInitializer +{ + public static final String TABLE_NAME = "summing_mor"; + public static final String RT_TABLE_NAME = TABLE_NAME + "_rt"; + + private static final String SUM_FIELD = SummingTestPayload.SUM_COLUMN; + + public SummingPayloadHudiTablesInitializer() + { + super(TABLE_NAME); + } + + @Override + protected List dataColumns() + { + return ImmutableList.of( + new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of()), + new Column(SUM_FIELD, HIVE_LONG, Optional.empty(), Map.of()), + new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())); + } + + @Override + protected Schema avroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field(SUM_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + @Override + protected void configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder) + { + tableBuilder.setPayloadClassName(SummingTestPayload.class.getName()); + } + + @Override + protected void configureWriteConfig(HoodieWriteConfig.Builder writeConfigBuilder) + { + writeConfigBuilder.withWritePayLoad(SummingTestPayload.class.getName()); + } + + @Override + protected void writeInitialCommits(HoodieJavaWriteClient client) + { + Schema schema = avroSchema(); + String firstCommit = client.startCommit(); + List firstStatuses = client.bulkInsert(ImmutableList.of( + record(schema, "k1", 10L, 100L), + record(schema, "k2", 20L, 100L)), firstCommit); + client.commit(firstCommit, firstStatuses); + + // The payload's combineAndGetUpdateValue SUMS stored and incoming values: 10 + 99 = 109 -- + // a result neither overwrite (99) nor base-only (10) can produce. + String secondCommit = client.startCommit(); + List secondStatuses = client.upsert(ImmutableList.of( + record(schema, "k1", 99L, 200L)), secondCommit); + client.commit(secondCommit, secondStatuses); + + // Third commit: hard delete of k2. The native delete log record reaches the payload-based + // CUSTOM merge arm, where it wins on HoodieAvroRecordMerger's isCommitTimeOrderingDelete + // short-circuit (writeClient.delete records carry the sentinel ordering value), before any + // payload is constructed -- the delete path of the user-merger dispatch. + String deleteCommit = client.startCommit(); + List deleteStatuses = client.delete( + ImmutableList.of(hoodieKey("k2")), deleteCommit); + client.commit(deleteCommit, deleteStatuses); + } + + private static HoodieRecord record(Schema schema, String key, long value, long ts) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put(SUM_FIELD, value); + record.put(ORDERING_FIELD, ts); + return avroRecord(record, key); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java new file mode 100644 index 0000000000000..7a009fbeab22b --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java @@ -0,0 +1,72 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; +import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload; +import org.apache.hudi.common.util.Option; + +import java.io.IOException; + +/** + * Test-only user-defined {@link org.apache.hudi.common.model.HoodieRecordPayload} whose read-side merge + * SUMS the {@code value} column of the stored and incoming records. A merged row therefore carries a + * value no built-in merge policy can produce (overwrite yields the incoming value, base-only the stored + * value), which proves end-to-end that the connector's CUSTOM merge branch executed this payload's + * {@code combineAndGetUpdateValue} (issue apache/hudi#18898). + *

    + * Unlike the built-in payloads named in the issue, this class is NOT in hudi's payloads-under-deprecation + * set, so v9+ table creation persists it as {@code RECORD_MERGE_MODE=CUSTOM} with the payload-based merge + * strategy id -- the configuration that routes reads through {@code HoodieAvroRecordMerger} and this + * payload, with no {@code hudi.record-merger-impls} connector property involved. + */ +public class SummingTestPayload + extends OverwriteWithLatestAvroPayload +{ + /** Name of the column whose stored and incoming values are summed at merge time. */ + public static final String SUM_COLUMN = "value"; + + public SummingTestPayload(GenericRecord record, Comparable orderingVal) + { + super(record, orderingVal); + } + + public SummingTestPayload(Option record) + { + super(record); + } + + @Override + public Option combineAndGetUpdateValue(IndexedRecord currentValue, Schema schema) + throws IOException + { + Option incoming = getInsertValue(schema); + if (incoming.isEmpty()) { + return Option.empty(); + } + GenericRecord newer = (GenericRecord) incoming.get(); + GenericRecord older = (GenericRecord) currentValue; + + long sum = ((Number) older.get(SUM_COLUMN)).longValue() + ((Number) newer.get(SUM_COLUMN)).longValue(); + GenericRecord merged = new GenericData.Record(newer.getSchema()); + for (Schema.Field field : newer.getSchema().getFields()) { + merged.put(field.pos(), newer.get(field.pos())); + } + merged.put(SUM_COLUMN, sum); + return Option.of(merged); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java similarity index 95% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java index 10d1b9873ad35..47902849e0d96 100644 --- a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/TpchHudiTablesInitializer.java @@ -19,8 +19,6 @@ import io.trino.filesystem.Location; import io.trino.filesystem.TrinoFileSystem; import io.trino.filesystem.TrinoFileSystemFactory; -import io.trino.hdfs.HdfsContext; -import io.trino.hdfs.HdfsEnvironment; import io.trino.metastore.Column; import io.trino.metastore.HiveMetastore; import io.trino.metastore.HiveMetastoreFactory; @@ -45,6 +43,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; import org.apache.hudi.client.common.HoodieJavaEngineContext; import org.apache.hudi.common.bootstrap.index.NoOpBootstrapIndex; import org.apache.hudi.common.config.HoodieMetadataConfig; @@ -54,7 +53,6 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.marker.MarkerType; -import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieArchivalConfig; import org.apache.hudi.config.HoodieIndexConfig; @@ -64,12 +62,10 @@ import org.intellij.lang.annotations.Language; import java.io.IOException; -import java.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoField; import java.util.ArrayList; import java.util.Collection; -import java.util.Date; import java.util.List; import java.util.Map; import java.util.Optional; @@ -90,9 +86,7 @@ import static io.trino.metastore.HiveType.HIVE_INT; import static io.trino.metastore.HiveType.HIVE_LONG; import static io.trino.metastore.HiveType.HIVE_STRING; -import static io.trino.plugin.hive.HiveTestUtils.HDFS_ENVIRONMENT; import static io.trino.plugin.hive.TableType.EXTERNAL_TABLE; -import static io.trino.testing.TestingConnectorSession.SESSION; import static java.lang.String.format; import static java.nio.file.Files.createTempDirectory; import static java.util.Collections.unmodifiableList; @@ -112,7 +106,6 @@ public class TpchHudiTablesInitializer new Column("_hoodie_record_key", HIVE_STRING, Optional.empty(), Map.of()), new Column("_hoodie_partition_path", HIVE_STRING, Optional.empty(), Map.of()), new Column("_hoodie_file_name", HIVE_STRING, Optional.empty(), Map.of())); - private static final HdfsContext CONTEXT = new HdfsContext(SESSION); private final List> tpchTables; @@ -156,7 +149,7 @@ public void initializeTables(QueryRunner queryRunner, Location externalLocation, public void load(TpchTable tpchTables, QueryRunner queryRunner, java.nio.file.Path tableDirectory) { - try (HoodieJavaWriteClient writeClient = createWriteClient(tpchTables, HDFS_ENVIRONMENT, new Path(tableDirectory.toUri()))) { + try (HoodieJavaWriteClient writeClient = createWriteClient(tpchTables, new Path(tableDirectory.toUri()))) { RecordConverter recordConverter = createRecordConverter(tpchTables); @Language("SQL") String sql = generateScanSql(TPCH_TINY, tpchTables); @@ -168,9 +161,9 @@ public void load(TpchTable tpchTables, QueryRunner queryRunner, java.nio.file .map(MaterializedRow::getFields) .map(recordConverter::toRecord) .collect(Collectors.toList()); - String timestamp = HoodieInstantTimeGenerator.formatDate(Date.from(Instant.now())); - writeClient.startCommitWithTime(timestamp); - writeClient.insert(records, timestamp); + String instantTime = writeClient.startCommit(); + List writeStatuses = writeClient.insert(records, instantTime); + writeClient.commit(instantTime, writeStatuses); } } @@ -211,10 +204,10 @@ private static Table createTableDefinition(String schemaName, TpchTable table .build(); } - private static HoodieJavaWriteClient createWriteClient(TpchTable table, HdfsEnvironment hdfsEnvironment, Path tablePath) + private static HoodieJavaWriteClient createWriteClient(TpchTable table, Path tablePath) { Schema schema = createAvroSchema(table); - Configuration conf = hdfsEnvironment.getConfiguration(CONTEXT, tablePath); + Configuration conf = new Configuration(); try { HoodieTableMetaClient.newTableBuilder() diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/TypeInfoHelper.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/TypeInfoHelper.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/testing/TypeInfoHelper.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/testing/TypeInfoHelper.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/UncompactedMetadataHudiTablesInitializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/UncompactedMetadataHudiTablesInitializer.java new file mode 100644 index 0000000000000..9b94c60a3bdab --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/UncompactedMetadataHudiTablesInitializer.java @@ -0,0 +1,407 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.testing; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.trino.filesystem.Location; +import io.trino.filesystem.TrinoFileSystem; +import io.trino.filesystem.TrinoFileSystemFactory; +import io.trino.metastore.Column; +import io.trino.metastore.HiveMetastore; +import io.trino.metastore.HiveMetastoreFactory; +import io.trino.metastore.Partition; +import io.trino.metastore.PartitionStatistics; +import io.trino.metastore.PartitionWithStatistics; +import io.trino.metastore.PrincipalPrivileges; +import io.trino.metastore.StorageFormat; +import io.trino.metastore.Table; +import io.trino.plugin.hudi.HudiConnector; +import io.trino.spi.security.ConnectorIdentity; +import io.trino.testing.QueryRunner; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hudi.client.HoodieJavaWriteClient; +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.client.common.HoodieJavaEngineContext; +import org.apache.hudi.common.bootstrap.index.NoOpBootstrapIndex; +import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.model.HoodieAvroPayload; +import org.apache.hudi.common.model.HoodieAvroRecord; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.marker.MarkerType; +import org.apache.hudi.storage.HoodieStorageUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieCompactionConfig; +import org.apache.hudi.config.HoodieIndexConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieMetadataException; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.metadata.HoodieBackedTableMetadata; +import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.io.MoreFiles.deleteRecursively; +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; +import static io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_INPUT_FORMAT; +import static io.trino.hive.formats.HiveClassNames.MAPRED_PARQUET_OUTPUT_FORMAT_CLASS; +import static io.trino.hive.formats.HiveClassNames.PARQUET_HIVE_SERDE_CLASS; +import static io.trino.metastore.HiveType.HIVE_LONG; +import static io.trino.metastore.HiveType.HIVE_STRING; +import static io.trino.plugin.hive.HivePartitionManager.extractPartitionValues; +import static io.trino.plugin.hive.TableType.EXTERNAL_TABLE; +import static java.nio.file.Files.createTempDirectory; + +/** + * Creates a partitioned COW table at test runtime with an ENABLED, UNCOMPACTED metadata table (MDT): + * {@code hoodie.metadata.compact.max.delta.commits} is set high and several commits are written, + * leaving the MDT's {@code files}/{@code column_stats}/{@code partition_stats} partitions with + * NATIVE HFILE log files that the connector must read at query time (issue apache/hudi#19279): + * {@code *.log.hfile} deltas where the whole file is an HFile, as current writers produce. The zip + * fixtures cannot cover this even where their MDTs are uncompacted (e.g. {@code hudi_trips_cow_v8}): + * they predate the native-log write path, so their MDT deltas are {@code #HUDI#} block-format logs + * carrying HFILE_DATA_BLOCKs, which the connector already read through its HFile content reader; + * only whole-file native HFILE logs hit the previously unimplemented + * {@code getFileFormatUtils(HFILE)} path. + *

    + * Note: MDT writing here is fully native -- HFILE base files and log files are written via hudi-io's + * pure-Java {@code HFileWriterImpl}, so no hbase dependency is involved (the "requires hbase" note in + * older initializers is stale). + *

    + * Data layout (partitions {@code part_col=p1} / {@code part_col=p2}, hive-style paths so the MDT + * partition listing and the metastore agree on names): + *

    + * commit 1 (insert, MDT off): k1(p1, price 10,  ts 100), k2(p2, price 1000, ts 100)
    + * commit 2 (insert, MDT on):  k3(p1, price 20,  ts 200), k4(p2, price 2000, ts 200)
    + * commit 3 (upsert, MDT on):  k1(p1, price 15,  ts 300)
    + * 
    + * The MDT is enabled only from commit 2 so its bootstrap sees existing data; a bootstrap over an + * empty table would register the col-stats index definition with no source fields, permanently + * disabling stats-based pruning in the connector (see {@code writeTable}). + * Final rows: k1=15, k2=1000, k3=20, k4=2000. Partition p1 holds prices [15, 20] and p2 holds + * [1000, 2000], so a predicate like {@code price < 100} lets the partition-stats index prune p2. + *

    + * A second, identical table {@link #CORRUPTED_TABLE_NAME} is written whose MDT log files are + * corrupted in place before upload: every MDT read of it throws, pinning the connector's + * fallbacks (direct file listing in {@code HudiSnapshotDirectoryLister}, unpruned split + * generation in {@code HudiBackgroundSplitLoader}) instead of only the clean-read path. + */ +public class UncompactedMetadataHudiTablesInitializer + implements HudiTablesInitializer +{ + public static final String TABLE_NAME = "hudi_uncompacted_mdt_pt_cow"; + public static final String CORRUPTED_TABLE_NAME = "hudi_corrupted_mdt_pt_cow"; + + private static final String RECORD_KEY_FIELD = "id"; + private static final String PARTITION_FIELD = "part_col"; + private static final String ORDERING_FIELD = "ts"; + private static final List PARTITION_PATHS = ImmutableList.of(PARTITION_FIELD + "=p1", PARTITION_FIELD + "=p2"); + + private static final List DATA_COLUMNS = ImmutableList.builder() + .addAll(AbstractMergerHudiTablesInitializer.HUDI_META_COLUMNS) + .add(new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), Map.of())) + .add(new Column("name", HIVE_STRING, Optional.empty(), Map.of())) + .add(new Column("price", HIVE_LONG, Optional.empty(), Map.of())) + .add(new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), Map.of())) + .build(); + + private static final List PARTITION_COLUMNS = + ImmutableList.of(new Column(PARTITION_FIELD, HIVE_STRING, Optional.empty(), Map.of())); + + @Override + public void initializeTables(QueryRunner queryRunner, Location externalLocation, String schemaName) + throws Exception + { + TrinoFileSystem fileSystem = ((HudiConnector) queryRunner.getCoordinator().getConnector("hudi")).getInjector() + .getInstance(TrinoFileSystemFactory.class) + .create(ConnectorIdentity.ofUser("test")); + HiveMetastore metastore = ((HudiConnector) queryRunner.getCoordinator().getConnector("hudi")).getInjector() + .getInstance(HiveMetastoreFactory.class) + .createMetastore(Optional.empty()); + + java.nio.file.Path tempDir = createTempDirectory("uncompacted-mdt"); + try { + for (String tableName : ImmutableList.of(TABLE_NAME, CORRUPTED_TABLE_NAME)) { + java.nio.file.Path tempTableDir = tempDir.resolve(tableName); + writeTable(new Path(tempTableDir.toUri()), tableName); + if (tableName.equals(CORRUPTED_TABLE_NAME)) { + corruptMetadataLogFiles(tempTableDir); + verifyMetadataTableUnreadable(new Path(tempTableDir.toUri())); + } + Location tableLocation = externalLocation.appendPath(tableName); + ResourceHudiTablesInitializer.copyDir(tempTableDir, fileSystem, tableLocation); + + metastore.createTable(createTableDefinition(schemaName, tableName, tableLocation), PrincipalPrivileges.NO_PRIVILEGES); + metastore.addPartitions(schemaName, tableName, createPartitions(schemaName, tableName, tableLocation)); + } + } + finally { + deleteRecursively(tempDir, ALLOW_INSECURE); + } + } + + /** + * Corrupts the MDT log files so that any metadata-table read of the table throws. The deltas + * here are NATIVE HFILE log files (the whole file is an HFile): hudi-io HFiles end with a + * fixed 4096-byte trailer whose magic and protobuf fields sit at the trailer's START, followed + * by padding, so the corrupted window is placed ~4KB before EOF to land on those fields -- + * flipping bytes near EOF would only hit padding and reads would still succeed. + * {@link #verifyMetadataTableUnreadable} then proves the corruption took. Every MDT partition + * directory must end up with at least one corrupted log file, so that none of the read paths + * (files listing, col-stats skipping, partition-stats pruning) can see a clean partition. + */ + private static void corruptMetadataLogFiles(java.nio.file.Path tableDir) + throws IOException + { + java.nio.file.Path metadataDir = tableDir.resolve(".hoodie").resolve("metadata"); + List logFiles; + try (Stream walk = Files.walk(metadataDir)) { + logFiles = walk + .filter(Files::isRegularFile) + .filter(file -> file.getFileName().toString().contains(".log.")) + // Only files directly inside an MDT partition directory, not timeline or + // marker leftovers under the MDT's own .hoodie + .filter(file -> metadataDir.equals(file.getParent().getParent())) + .collect(toImmutableList()); + } + Set partitionsWithLogFiles = new HashSet<>(); + Set partitionsCorrupted = new HashSet<>(); + for (java.nio.file.Path logFile : logFiles) { + partitionsWithLogFiles.add(logFile.getParent()); + byte[] bytes = Files.readAllBytes(logFile); + int start = bytes.length - 4160; + int end = bytes.length - 3648; + if (start < 64) { + // Too small to even hold an HFile trailer: a record-less file-group bootstrap + // marker that no read decodes. The per-partition check below still requires a + // corrupted data-bearing file next to it. + continue; + } + for (int i = start; i < end; i++) { + bytes[i] ^= 0x5A; + } + Files.write(logFile, bytes); + partitionsCorrupted.add(logFile.getParent()); + } + checkState(!partitionsWithLogFiles.isEmpty(), "No MDT log files found under %s", metadataDir); + Set untouched = new HashSet<>(partitionsWithLogFiles); + untouched.removeAll(partitionsCorrupted); + checkState(untouched.isEmpty(), + "No MDT log file corrupted in partition(s) %s; reads of those partitions would still succeed and their fallback tests would pass vacuously", untouched); + } + + /** + * Proves the corruption is effective, not just that bytes were flipped: a metadata-table read + * of the corrupted table must throw. This guards the fallback tests against the fixed trailer + * offsets in {@link #corruptMetadataLogFiles} ever missing (e.g. after an HFile layout change), + * in which case those tests would pass vacuously against a clean MDT read. + */ + private static void verifyMetadataTableUnreadable(Path tablePath) + throws Exception + { + HadoopStorageConfiguration storageConf = new HadoopStorageConfiguration(new Configuration()); + List partitions; + try (HoodieTableMetadata metadata = new HoodieBackedTableMetadata( + new HoodieJavaEngineContext(storageConf), + HoodieStorageUtils.getStorage(tablePath.toString(), storageConf), + HoodieMetadataConfig.newBuilder().enable(true).build(), + tablePath.toString())) { + // The files partition backs getAllPartitionPaths; its log delta is corrupted like all others + partitions = metadata.getAllPartitionPaths(); + } + catch (HoodieMetadataException expected) { + // Only the wrapper BaseTableMetadata puts around a genuine read failure counts: the + // bare IllegalArgumentException it throws for a never-initialized MDT must NOT pass, + // or this guard would go quiet if the fixture's MDT bootstrap ever stopped happening + return; + } + throw new IllegalStateException( + "Metadata table of " + CORRUPTED_TABLE_NAME + " is still readable (partitions: " + partitions + + "); corruptMetadataLogFiles no longer lands on the HFile trailer fields"); + } + + private static void writeTable(Path tablePath, String tableName) + { + Schema schema = createAvroSchema(); + initTable(tablePath, tableName); + + // Commit 1 runs with the MDT off so the MDT bootstraps AFTER data exists. Index + // definitions get their source fields from ColumnStatsIndexer.postInitialization, which + // registers an EMPTY field list when the bootstrap sees no records, and + // HoodieJavaWriteClient.updateColumnsToIndexWithColStats is a no-op (HUDI-8801; the + // Spark client refreshes the definition on each commit), so a definition registered over + // an empty table keeps empty source fields forever and the connector's canApply() then + // rejects the col-stats/partition-stats indexes, silently disabling pruning. Once + // HUDI-8801 fixes the Java client, this two-client split can collapse back to one. + try (HoodieJavaWriteClient writeClient = createWriteClient(schema, tablePath, tableName, false)) { + String firstCommit = writeClient.startCommit(); + List firstStatuses = writeClient.insert(ImmutableList.of( + record(schema, "k1", "k1_c1", 10L, 100L, PARTITION_PATHS.get(0)), + record(schema, "k2", "k2_c1", 1000L, 100L, PARTITION_PATHS.get(1))), firstCommit); + writeClient.commit(firstCommit, firstStatuses); + } + + try (HoodieJavaWriteClient writeClient = createWriteClient(schema, tablePath, tableName, true)) { + String secondCommit = writeClient.startCommit(); + List secondStatuses = writeClient.insert(ImmutableList.of( + record(schema, "k3", "k3_c2", 20L, 200L, PARTITION_PATHS.get(0)), + record(schema, "k4", "k4_c2", 2000L, 200L, PARTITION_PATHS.get(1))), secondCommit); + writeClient.commit(secondCommit, secondStatuses); + + String thirdCommit = writeClient.startCommit(); + List thirdStatuses = writeClient.upsert(ImmutableList.of( + record(schema, "k1", "k1_c3", 15L, 300L, PARTITION_PATHS.get(0))), thirdCommit); + writeClient.commit(thirdCommit, thirdStatuses); + } + } + + private static void initTable(Path tablePath, String tableName) + { + try { + HoodieTableMetaClient.newTableBuilder() + .setTableType(HoodieTableType.COPY_ON_WRITE) + .setTableName(tableName) + .setTimelineLayoutVersion(1) + .setBootstrapIndexClass(NoOpBootstrapIndex.class.getName()) + .setPayloadClassName(HoodieAvroPayload.class.getName()) + .setRecordKeyFields(RECORD_KEY_FIELD) + .setPartitionFields(PARTITION_FIELD) + .setHiveStylePartitioningEnable(true) + .setOrderingFields(ORDERING_FIELD) + .initTable(new HadoopStorageConfiguration(new Configuration()), tablePath.toString()); + } + catch (IOException e) { + throw new RuntimeException("Could not init table " + tableName, e); + } + } + + private static HoodieJavaWriteClient createWriteClient(Schema schema, Path tablePath, String tableName, boolean metadataEnabled) + { + Configuration conf = new Configuration(); + HoodieWriteConfig cfg = HoodieWriteConfig.newBuilder() + .withPath(tablePath.toString()) + .withSchema(schema.toString()) + .withParallelism(2, 2) + .withDeleteParallelism(2) + .forTable(tableName) + .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build()) + .withCompactionConfig(HoodieCompactionConfig.newBuilder() + .withInlineCompaction(false) + .withMaxNumDeltaCommitsBeforeCompaction(100) + .build()) + .withEmbeddedTimelineServerEnabled(false) + .withMarkersType(MarkerType.DIRECT.name()) + // The whole point of this initializer: an ENABLED metadata table with stats + // indexes whose compaction never fires within this test, so its partitions keep + // native HFILE log deltas. MDT HFILE writing is native (hudi-io HFileWriterImpl) + // -- no hbase involved. metadataEnabled is false for the first commit only; see + // writeTable. + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(metadataEnabled) + // Partition stats follow the column stats config on this branch, so the + // line above enables both. hoodie.metadata.index.partition.stats.enable is + // deprecated here and has no builder setter. + .withMetadataIndexColumnStats(metadataEnabled) + .withMaxNumDeltaCommitsBeforeCompaction(100) + .build()) + .build(); + return new HoodieJavaWriteClient<>(new HoodieJavaEngineContext(new HadoopStorageConfiguration(conf)), cfg); + } + + private static HoodieRecord record(Schema schema, String key, String name, long price, long ts, String partitionPath) + { + GenericRecord record = new GenericData.Record(schema); + record.put(RECORD_KEY_FIELD, key); + record.put("name", name); + record.put("price", price); + record.put(ORDERING_FIELD, ts); + // Keep the partition column in the data files like Spark-written tables do; Trino reads its + // value from the metastore partition, not from parquet + record.put(PARTITION_FIELD, partitionPath.substring(partitionPath.indexOf('=') + 1)); + HoodieKey hoodieKey = new HoodieKey(key, partitionPath); + return new HoodieAvroRecord<>(hoodieKey, new HoodieAvroPayload(Option.of(record)), null); + } + + private static Schema createAvroSchema() + { + List fields = ImmutableList.of( + new Schema.Field(RECORD_KEY_FIELD, Schema.create(Schema.Type.STRING)), + new Schema.Field("name", Schema.create(Schema.Type.STRING)), + new Schema.Field("price", Schema.create(Schema.Type.LONG)), + new Schema.Field(ORDERING_FIELD, Schema.create(Schema.Type.LONG)), + new Schema.Field(PARTITION_FIELD, Schema.create(Schema.Type.STRING))); + return Schema.createRecord(TABLE_NAME, null, null, false, new ArrayList<>(fields)); + } + + private static Table createTableDefinition(String schemaName, String tableName, Location location) + { + return Table.builder() + .setDatabaseName(schemaName) + .setTableName(tableName) + .setTableType(EXTERNAL_TABLE.name()) + .setOwner(Optional.of("public")) + .setDataColumns(DATA_COLUMNS) + .setPartitionColumns(PARTITION_COLUMNS) + .setParameters(ImmutableMap.of("serialization.format", "1", "EXTERNAL", "TRUE")) + .withStorage(storageBuilder -> storageBuilder + .setStorageFormat(storageFormat()) + .setLocation(location.toString())) + .build(); + } + + private static List createPartitions(String schemaName, String tableName, Location tableLocation) + { + List partitions = new ArrayList<>(); + for (String partitionName : PARTITION_PATHS) { + // Hive-style partition paths, so the partition NAME and the relative PATH coincide + Partition partition = Partition.builder() + .setDatabaseName(schemaName) + .setTableName(tableName) + .setValues(extractPartitionValues(partitionName)) + .withStorage(storageBuilder -> storageBuilder + .setStorageFormat(storageFormat()) + .setLocation(tableLocation.appendPath(partitionName).toString())) + .setColumns(DATA_COLUMNS) + .build(); + partitions.add(new PartitionWithStatistics(partition, partitionName, PartitionStatistics.empty())); + } + return partitions; + } + + private static StorageFormat storageFormat() + { + return StorageFormat.create( + PARQUET_HIVE_SERDE_CLASS, + HUDI_PARQUET_INPUT_FORMAT, + MAPRED_PARQUET_OUTPUT_FORMAT_CLASS); + } +} diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/util/FileOperationAssertions.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/FileOperationAssertions.java new file mode 100644 index 0000000000000..546db8446a9e5 --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/FileOperationAssertions.java @@ -0,0 +1,176 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.util; + +import com.google.common.collect.HashMultiset; +import com.google.common.collect.Multiset; +import io.airlift.log.Logger; +import io.trino.plugin.hudi.util.FileOperationUtils.FileOperation; +import io.trino.testing.QueryRunner; +import org.intellij.lang.annotations.Language; + +import java.util.Comparator; +import java.util.function.Supplier; + +import static io.trino.filesystem.tracing.CacheFileSystemTraceUtils.getCacheOperationSpans; +import static io.trino.filesystem.tracing.CacheFileSystemTraceUtils.getFileLocation; +import static io.trino.filesystem.tracing.CacheFileSystemTraceUtils.isTrinoSchemaOrPermissions; +import static io.trino.testing.MultisetAssertions.assertMultisetsEqual; +import static java.util.stream.Collectors.toCollection; + +public final class FileOperationAssertions +{ + private static final Logger log = Logger.get(FileOperationAssertions.class); + + private FileOperationAssertions() {} + + /** + * Asserts that file system accesses match expected operations. + * This version uses manual filtering for Input/InputFile operations. + * On assertion failure, logs a detailed comparison at WARN level to aid debugging. + */ + public static void assertFileSystemAccesses( + QueryRunner queryRunner, + @Language("SQL") String query, + Multiset expectedCacheAccesses) + throws InterruptedException + { + queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); + // Async table-stats computation can outlive the synchronous query and emit spans into + // the exporter after execute returns. A fixed Thread.sleep races with this: when stats + // from query N is still running while query N+1's measurement happens, spans leak + // across the boundary and counts get scrambled. Poll until the span set is stable for + // two consecutive reads. + Multiset actualCacheAccesses = waitForStableFileOperations(() -> getFileOperations(queryRunner)); + try { + assertMultisetsEqual(actualCacheAccesses, expectedCacheAccesses); + } + catch (AssertionError e) { + logFileAccessDebugInfo(queryRunner, actualCacheAccesses, expectedCacheAccesses); + throw e; + } + } + + /** + * Asserts that file system accesses match expected operations for Alluxio cache tests. + * This version uses getCacheOperationSpans for filtering. + * On assertion failure, logs a detailed comparison at WARN level to aid debugging. + */ + public static void assertAlluxioFileSystemAccesses( + QueryRunner queryRunner, + @Language("SQL") String query, + Multiset expectedCacheAccesses) + throws InterruptedException + { + queryRunner.executeWithPlan(queryRunner.getDefaultSession(), query); + // See assertFileSystemAccesses for the rationale behind polling instead of a fixed sleep. + Multiset actualCacheAccesses = waitForStableFileOperations(() -> getAlluxioFileOperations(queryRunner)); + try { + assertMultisetsEqual(actualCacheAccesses, expectedCacheAccesses); + } + catch (AssertionError e) { + logFileAccessDebugInfo(queryRunner, actualCacheAccesses, expectedCacheAccesses); + throw e; + } + } + + /** + * Returns the file-operation set once two consecutive reads (200ms apart) agree. Bounded by a + * 30-second ceiling so a runaway test fails loudly instead of hanging. + */ + private static Multiset waitForStableFileOperations(Supplier> reader) + throws InterruptedException + { + long deadlineMillis = System.currentTimeMillis() + 30_000L; + Multiset previous = null; + while (System.currentTimeMillis() < deadlineMillis) { + Thread.sleep(200L); + Multiset current = reader.get(); + if (previous != null && current.equals(previous)) { + return current; + } + previous = current; + } + return previous != null ? previous : reader.get(); + } + + /** + * Gets file operations from query runner spans using manual filtering. + */ + public static Multiset getFileOperations(QueryRunner queryRunner) + { + return queryRunner.getSpans().stream() + .filter(span -> span.getName().startsWith("Input.") || span.getName().startsWith("InputFile.") || span.getName().startsWith("FileSystemCache.")) + .filter(span -> !span.getName().startsWith("InputFile.newInput")) + .filter(span -> !span.getName().startsWith("InputFile.exists")) + .filter(span -> !isTrinoSchemaOrPermissions(getFileLocation(span))) + .map(FileOperation::create) + .collect(toCollection(HashMultiset::create)); + } + + /** + * Gets file operations for Alluxio cache tests using getCacheOperationSpans. + */ + public static Multiset getAlluxioFileOperations(QueryRunner queryRunner) + { + return getCacheOperationSpans(queryRunner) + .stream() + .filter(span -> !span.getName().startsWith("InputFile.exists")) + .map(FileOperation::create) + .collect(toCollection(HashMultiset::create)); + } + + private static void logFileAccessDebugInfo( + QueryRunner queryRunner, + Multiset actualCacheAccesses, + Multiset expectedCacheAccesses) + { + // Log all file paths accessed for debugging + log.warn("=== All File Paths Accessed ==="); + queryRunner.getSpans().stream() + .filter(span -> span.getName().equals("InputFile.lastModified") || span.getName().equals("InputFile.length")) + .forEach(span -> log.warn("%s: %s", span.getName(), getFileLocation(span))); + + // Log actual and expected cache accesses + log.warn("=== Actual Cache Accesses ==="); + logSortedMultiset(actualCacheAccesses); + + log.warn("=== Expected Cache Accesses ==="); + logSortedMultiset(expectedCacheAccesses); + + // Calculate and log differences + Multiset extraInActual = HashMultiset.create(actualCacheAccesses); + extraInActual.removeAll(expectedCacheAccesses); + + Multiset missingFromActual = HashMultiset.create(expectedCacheAccesses); + missingFromActual.removeAll(actualCacheAccesses); + + if (!extraInActual.isEmpty()) { + log.warn("=== Extra in Actual (not expected) ==="); + logSortedMultiset(extraInActual); + } + + if (!missingFromActual.isEmpty()) { + log.warn("=== Missing from Actual (expected but not found) ==="); + logSortedMultiset(missingFromActual); + } + } + + private static void logSortedMultiset(Multiset multiset) + { + multiset.entrySet().stream() + .sorted(Comparator.comparing(a -> a.getElement().toString())) + .forEach(entry -> log.warn("%s: %s", entry.getElement(), entry.getCount())); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/FileOperationUtils.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/FileOperationUtils.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/FileOperationUtils.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/util/FileOperationUtils.java diff --git a/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java new file mode 100644 index 0000000000000..514218d48adde --- /dev/null +++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java @@ -0,0 +1,176 @@ +/* + * 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. + */ +package io.trino.plugin.hudi.util; + +import io.trino.metastore.HiveType; +import io.trino.plugin.hive.HiveColumnHandle; +import io.trino.plugin.hive.HivePartitionKey; +import io.trino.plugin.hudi.HudiSplit; +import io.trino.plugin.hudi.file.HudiBaseFile; +import io.trino.spi.Page; +import io.trino.spi.PageBuilder; +import io.trino.spi.SplitWeight; +import io.trino.spi.block.Block; +import io.trino.spi.block.BlockBuilder; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.type.DecimalType; +import org.apache.avro.Conversions; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.generic.GenericData; +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; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static io.trino.spi.type.BigintType.BIGINT; +import static io.trino.spi.type.IntegerType.INTEGER; +import static io.trino.spi.type.VarcharType.VARCHAR; +import static org.assertj.core.api.Assertions.assertThat; + +class TestHudiAvroSerializer +{ + /** + * A short decimal is stored in Trino as the unscaled value, which is exactly what Avro writes into the + * fixed bytes, so the read is a plain big-endian two's complement decode. The cases below pin the parts + * that decode gets wrong if it is ever rewritten: sign extension for negatives, scale 0, and the + * full-width value at the maximum short-decimal precision. + */ + @ParameterizedTest + @MethodSource("shortDecimals") + public void testAppendShortDecimalFromAvroFixed(int precision, int scale, String value, long expectedUnscaled) + { + DecimalType type = DecimalType.createDecimalType(precision, scale); + + BlockBuilder blockBuilder = type.createBlockBuilder(null, 1); + HudiAvroSerializer.appendTo(type, avroDecimalFixed(precision, scale, value), blockBuilder); + Block block = blockBuilder.build(); + + assertThat(type.getLong(block, 0)).isEqualTo(expectedUnscaled); + } + + private static Stream shortDecimals() + { + return Stream.of( + Arguments.of(10, 2, "123.45", 12345L), + Arguments.of(10, 2, "-0.07", -7L), + Arguments.of(10, 2, "0.00", 0L), + Arguments.of(10, 4, "123.4567", 1234567L), + Arguments.of(5, 0, "42", 42L), + Arguments.of(5, 0, "-42", -42L), + // Widest short decimal: 18 digits, both signs + Arguments.of(18, 2, "9999999999999999.99", 999999999999999999L), + Arguments.of(18, 2, "-9999999999999999.99", -999999999999999999L)); + } + + @Test + public void testBuildRecordInPage() + { + // Schema field order (b, a) deliberately differs from projection order (a, b, pk_int), + // so correct output proves positions are resolved from the record's schema. + Schema schema = recordSchema("rec1"); + HudiAvroSerializer serializer = new HudiAvroSerializer(projectedColumns(), prefilledValues()); + PageBuilder pageBuilder = new PageBuilder(List.of(BIGINT, VARCHAR, INTEGER)); + + serializer.buildRecordInPage(pageBuilder, record(schema, 1L, "one")); + // Second record with the same schema instance exercises the cached field positions + serializer.buildRecordInPage(pageBuilder, record(schema, 2L, "two")); + // A schema instance with the opposite field order must invalidate the cache; reusing the + // stale positions would swap the a and b values + serializer.buildRecordInPage(pageBuilder, record(reversedRecordSchema("rec2"), 3L, "three")); + + Page page = pageBuilder.build(); + assertThat(page.getPositionCount()).isEqualTo(3); + for (int position = 0; position < 3; position++) { + assertThat(BIGINT.getLong(page.getBlock(0), position)).isEqualTo(position + 1); + assertThat(INTEGER.getInt(page.getBlock(2), position)).isEqualTo(42); + } + assertThat(VARCHAR.getSlice(page.getBlock(1), 0).toStringUtf8()).isEqualTo("one"); + assertThat(VARCHAR.getSlice(page.getBlock(1), 1).toStringUtf8()).isEqualTo("two"); + assertThat(VARCHAR.getSlice(page.getBlock(1), 2).toStringUtf8()).isEqualTo("three"); + } + + /** + * Encodes the value the way an Avro writer does, via Avro's own conversion: a fixed sized from the + * precision, holding the unscaled value left-padded to that width with the sign byte (0xFF for + * negatives). Building the fixed from the minimal two's-complement encoding instead would leave the + * padding bytes, and so sign extension across them, untested. + */ + private static GenericData.Fixed avroDecimalFixed(int precision, int scale, String value) + { + LogicalTypes.Decimal decimalType = LogicalTypes.decimal(precision, scale); + Schema fixedSchema = decimalType.addToSchema( + Schema.createFixed("fix", null, null, decimalFixedSize(precision))); + return (GenericData.Fixed) new Conversions.DecimalConversion() + .toFixed(new BigDecimal(value), fixedSchema, decimalType); + } + + /** Bytes needed to hold the widest unscaled value at this precision, i.e. the fixed size Avro sizes a decimal to. */ + private static int decimalFixedSize(int precision) + { + return BigInteger.TEN.pow(precision).subtract(BigInteger.ONE).toByteArray().length; + } + + private static Schema recordSchema(String name) + { + return SchemaBuilder.record(name).fields() + .name("b").type().stringType().noDefault() + .name("a").type().longType().noDefault() + .endRecord(); + } + + private static Schema reversedRecordSchema(String name) + { + return SchemaBuilder.record(name).fields() + .name("a").type().longType().noDefault() + .name("b").type().stringType().noDefault() + .endRecord(); + } + + private static GenericData.Record record(Schema schema, long a, String b) + { + GenericData.Record record = new GenericData.Record(schema); + record.put("a", a); + record.put("b", b); + return record; + } + + private static List projectedColumns() + { + return List.of( + HiveColumnHandle.createBaseColumn("a", 0, HiveType.HIVE_LONG, BIGINT, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()), + HiveColumnHandle.createBaseColumn("b", 1, HiveType.HIVE_STRING, VARCHAR, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()), + HiveColumnHandle.createBaseColumn("pk_int", -1, HiveType.HIVE_INT, INTEGER, HiveColumnHandle.ColumnType.PARTITION_KEY, Optional.empty())); + } + + private static PrefilledColumnValues prefilledValues() + { + HudiBaseFile baseFile = new HudiBaseFile("s3://bucket/table/file1.parquet", "file1.parquet", 1234, 1700000000123L, 0, 1234); + HudiSplit split = new HudiSplit( + baseFile, + List.of(), + "001", + TupleDomain.all(), + List.of(new HivePartitionKey("pk_int", "42")), + SplitWeight.standard()); + return PrefilledColumnValues.create(split); + } +} diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsExtendedNullFilterTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsExtendedNullFilterTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsExtendedNullFilterTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsExtendedNullFilterTest.java diff --git a/hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsTest.java b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsTest.java similarity index 100% rename from hudi-trino-plugin/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsTest.java rename to hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestTupleDomainUtilsTest.java diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md similarity index 91% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md index 9883fe6db2a77..c8a1da4cf8f48 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v6_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md similarity index 91% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md index ade3e61723df0..26bb6c2a659bb 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_comprehensive_types_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md similarity index 72% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md index 39e06481bcd78..63fc089b0e7cc 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_table_with_field_names_in_caps.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip similarity index 74% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip index 2f2238b22339f..47c0532a9369d 100644 Binary files a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip and b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_pt_tbl.zip differ diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md similarity index 59% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md index 4f1c017fedd60..8983ed64c5d49 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_field_names_in_caps.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md similarity index 60% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md index 9a7ccd9eaf575..b8e067165e0ed 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_table_with_multi_keys_and_field_names_in_caps.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md similarity index 78% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md index f38d43d149c2b..75239267a8065 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_custom_keygen_pt_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md similarity index 59% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md index f73eb1591343b..39b5fb127dfab 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_table_with_field_names_in_caps.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md similarity index 72% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md index 733443b8619dd..4112d12c5699b 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v6_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md similarity index 75% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md index 6d78d7eb6768f..5c727a0cf6342 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_fg_pt_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md similarity index 78% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md index 119c8ffdd8e7c..e8d65b1394f96 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Revision: 6f65998117a2d1228fc96d36053bd0d394499afe diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_multi_pt_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md similarity index 66% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md index db288b2dfc233..e6b2e02ee0dc9 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_extractable_partition_path.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.md similarity index 65% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.md index 26f65f3fcd78f..33ee239ad9af0 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.md @@ -1,3 +1,20 @@ + + # Hudi Test Resources ## Generating Hudi Resources diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip similarity index 62% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip index 019860e88cff3..55632f95ca9fd 100644 Binary files a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip and b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_cow.zip differ diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_mor.md similarity index 74% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_mor.md index 90b168a74156c..3dd4b36f4d162 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_mor.md @@ -1,3 +1,20 @@ + + # Hudi Test Resources ## Generating Hudi Resources diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_non_part_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_non_part_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_stock_ticks_cow.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_stock_ticks_cow.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_stock_ticks_cow.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_stock_ticks_cow.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_stock_ticks_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_stock_ticks_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_stock_ticks_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_stock_ticks_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md similarity index 77% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md index 9e426b9fd3783..f7d9d9655feb9 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_epoch_to_yyyy_mm_dd_hh_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md similarity index 78% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md index f547b378067f3..1a26c6021884c 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_timestamp_keygen_pt_scalar_to_yyyy_mm_dd_hh_v8_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md b/hudi-trino/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md similarity index 59% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md index a263f502e4155..222e3932177b3 100644 --- a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md +++ b/hudi-trino/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.md @@ -1,3 +1,20 @@ + + ## Create script Structure of table: diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.zip b/hudi-trino/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.zip rename to hudi-trino/src/test/resources/hudi-testing-data/hudi_trips_cow_v8.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/stock_ticks_cow.zip b/hudi-trino/src/test/resources/hudi-testing-data/stock_ticks_cow.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/stock_ticks_cow.zip rename to hudi-trino/src/test/resources/hudi-testing-data/stock_ticks_cow.zip diff --git a/hudi-trino-plugin/src/test/resources/hudi-testing-data/stock_ticks_mor.zip b/hudi-trino/src/test/resources/hudi-testing-data/stock_ticks_mor.zip similarity index 100% rename from hudi-trino-plugin/src/test/resources/hudi-testing-data/stock_ticks_mor.zip rename to hudi-trino/src/test/resources/hudi-testing-data/stock_ticks_mor.zip diff --git a/hudi-trino-plugin/src/test/resources/long_timestamp.parquet b/hudi-trino/src/test/resources/long_timestamp.parquet similarity index 100% rename from hudi-trino-plugin/src/test/resources/long_timestamp.parquet rename to hudi-trino/src/test/resources/long_timestamp.parquet diff --git a/hudi-utilities/pom.xml b/hudi-utilities/pom.xml index 802add77a2e90..11ec8522bfa19 100644 --- a/hudi-utilities/pom.xml +++ b/hudi-utilities/pom.xml @@ -234,6 +234,7 @@ org.projectlombok lombok + provided diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HiveIncrementalPuller.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HiveIncrementalPuller.java index 2510edce72a8c..538253c9f60c0 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HiveIncrementalPuller.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HiveIncrementalPuller.java @@ -28,13 +28,12 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.hadoop.fs.permission.FsAction; import org.apache.hadoop.fs.permission.FsPermission; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.stringtemplate.v4.ST; import java.io.File; @@ -62,10 +61,9 @@ * - Only the source table can be incrementally pulled (usually the largest table) - The incrementally pulled table * can't be referenced more than once. */ +@Slf4j public class HiveIncrementalPuller { - private static final Logger LOG = LoggerFactory.getLogger(HiveIncrementalPuller.class); - public static class Config implements Serializable { @Parameter(names = {"--hiveUrl"}) @@ -133,15 +131,14 @@ private void validateIncrementalSQL() throws IOException { incrementalSQL = scanner.useDelimiter("\\Z").next(); } if (!incrementalSQL.contains(config.sourceDb + "." + config.sourceTable)) { - LOG.error("Incremental SQL does not have " + config.sourceDb + "." + config.sourceTable - + ", which means its pulling from a different table. Fencing this from happening."); + log.error("Incremental SQL does not have {}.{}, which means its pulling from a different table. Fencing this from happening.", + config.sourceDb, config.sourceTable); throw new HoodieIncrementalPullSQLException( "Incremental SQL does not have " + config.sourceDb + "." + config.sourceTable); } if (!incrementalSQL.contains("`_hoodie_commit_time` > '%s'")) { - LOG.error("Incremental SQL : " + incrementalSQL - + " does not contain `_hoodie_commit_time` > '%s'. Please add " - + "this clause for incremental to work properly."); + log.error("Incremental SQL : {} does not contain `_hoodie_commit_time` > '%s'. Please add this clause for incremental to work properly.", + incrementalSQL); throw new HoodieIncrementalPullSQLException( "Incremental SQL does not have clause `_hoodie_commit_time` > '%s', which " + "means its not pulling incrementally"); @@ -157,14 +154,14 @@ public void saveDelta() throws IOException { try { if (config.fromCommitTime == null) { config.fromCommitTime = inferCommitTime(fs); - LOG.info("FromCommitTime inferred as " + config.fromCommitTime); + log.info("FromCommitTime inferred as {}", config.fromCommitTime); } - LOG.info("FromCommitTime - " + config.fromCommitTime); + log.info("FromCommitTime - {}", config.fromCommitTime); String sourceTableLocation = getTableLocation(config.sourceDb, config.sourceTable); String lastCommitTime = getLastCommitTimePulled(fs, sourceTableLocation); if (lastCommitTime == null) { - LOG.info("Nothing to pull. However we will continue to create a empty table"); + log.info("Nothing to pull. However we will continue to create a empty table"); lastCommitTime = config.fromCommitTime; } @@ -182,9 +179,9 @@ public void saveDelta() throws IOException { initHiveBeelineProperties(stmt); executeIncrementalSQL(tempDbTable, tempDbTablePath, stmt); - LOG.info("Finished HoodieReader execution"); + log.info("Finished HoodieReader execution"); } catch (SQLException e) { - LOG.error("Exception when executing SQL", e); + log.error("Exception when executing SQL", e); throw new IOException("Could not scan " + config.sourceTable + " incrementally", e); } finally { try { @@ -192,14 +189,14 @@ public void saveDelta() throws IOException { stmt.close(); } } catch (SQLException e) { - LOG.error("Could not close the resultSet opened ", e); + log.error("Could not close the resultSet opened ", e); } try { if (this.connection != null) { this.connection.close(); } } catch (SQLException e) { - LOG.error("Could not close the JDBC connection", e); + log.error("Could not close the JDBC connection", e); } finally { this.connection = null; } @@ -229,7 +226,7 @@ private String getStoredAsClause() { } private void initHiveBeelineProperties(Statement stmt) throws SQLException { - LOG.info("Setting up Hive JDBC Session with properties"); + log.info("Setting up Hive JDBC Session with properties"); // set the queue executeStatement("set mapred.job.queue.name=" + config.yarnQueueName, stmt); // Set the inputFormat to HoodieCombineHiveInputFormat @@ -247,18 +244,18 @@ private void initHiveBeelineProperties(Statement stmt) throws SQLException { } private boolean deleteHDFSPath(FileSystem fs, String path) throws IOException { - LOG.info("Deleting path " + path); + log.info("Deleting path {}", path); return fs.delete(new Path(path), true); } private void executeStatement(String sql, Statement stmt) throws SQLException { - LOG.info("Executing: " + sql); + log.info("Executing: {}", sql); stmt.execute(sql); } private String inferCommitTime(FileSystem fs) throws IOException { - LOG.info("FromCommitTime not specified. Trying to infer it from Hoodie table " + config.targetDb + "." - + config.targetTable); + log.info("FromCommitTime not specified. Trying to infer it from Hoodie table {}.{}", + config.targetDb, config.targetTable); String targetDataLocation = getTableLocation(config.targetDb, config.targetTable); return scanForCommitTime(fs, targetDataLocation); } @@ -272,7 +269,7 @@ private String getTableLocation(String db, String table) { resultSet = stmt.executeQuery("describe formatted `" + db + "." + table + "`"); while (resultSet.next()) { if (resultSet.getString(1).trim().equals("Location:")) { - LOG.info("Inferred table location for " + db + "." + table + " as " + resultSet.getString(2)); + log.info("Inferred table location for {}.{} as {}", db, table, resultSet.getString(2)); return resultSet.getString(2); } } @@ -287,7 +284,7 @@ private String getTableLocation(String db, String table) { resultSet.close(); } } catch (SQLException e) { - LOG.error("Could not close the resultSet opened ", e); + log.error("Could not close the resultSet opened ", e); } } return null; @@ -314,7 +311,7 @@ private String scanForCommitTime(FileSystem fs, String targetDataPath) throws IO private boolean ensureTempPathExists(FileSystem fs, String lastCommitTime) throws IOException { Path targetBaseDirPath = new Path(config.hoodieTmpDir, config.targetTable + "__" + config.sourceTable); if (!fs.exists(targetBaseDirPath)) { - LOG.info("Creating " + targetBaseDirPath + " with permission drwxrwxrwx"); + log.info("Creating {} with permission drwxrwxrwx", targetBaseDirPath); boolean result = FileSystem.mkdirs(fs, targetBaseDirPath, new FsPermission(FsAction.ALL, FsAction.ALL, FsAction.ALL)); if (!result) { @@ -329,7 +326,7 @@ private boolean ensureTempPathExists(FileSystem fs, String lastCommitTime) throw throw new HoodieException("Could not delete existing " + targetPath); } } - LOG.info("Creating " + targetPath + " with permission drwxrwxrwx"); + log.info("Creating {} with permission drwxrwxrwx", targetPath); return FileSystem.mkdirs(fs, targetBaseDirPath, new FsPermission(FsAction.ALL, FsAction.ALL, FsAction.ALL)); } @@ -341,17 +338,17 @@ private String getLastCommitTimePulled(FileSystem fs, String sourceTableLocation .findInstantsAfter(config.fromCommitTime, config.maxCommits).getInstantsAsStream().map(HoodieInstant::requestedTime) .collect(Collectors.toList()); if (commitsToSync.isEmpty()) { - LOG.info("Nothing to sync. All commits in {} are {} and from commit time is {}", config.sourceTable, metadata.getActiveTimeline().getCommitsTimeline() + log.info("Nothing to sync. All commits in {} are {} and from commit time is {}", config.sourceTable, metadata.getActiveTimeline().getCommitsTimeline() .filterCompletedInstants().getInstants(), config.fromCommitTime); return null; } - LOG.info("Syncing commits {}", commitsToSync); + log.info("Syncing commits {}", commitsToSync); return commitsToSync.get(commitsToSync.size() - 1); } private Connection getConnection() throws SQLException { if (connection == null) { - LOG.info("Getting Hive Connection to {}", config.hiveJDBCUrl); + log.info("Getting Hive Connection to {}", config.hiveJDBCUrl); this.connection = DriverManager.getConnection(config.hiveJDBCUrl, config.hiveUsername, config.hivePassword); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCleaner.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCleaner.java index 63ec3354410de..90b0c7dc1515c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCleaner.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCleaner.java @@ -27,19 +27,17 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; import java.util.List; +@Slf4j public class HoodieCleaner { - private static final Logger LOG = LoggerFactory.getLogger(HoodieCleaner.class); - /** * Config for Cleaner. */ @@ -63,7 +61,7 @@ public HoodieCleaner(Config cfg, JavaSparkContext jssc, TypedProperties props) { this.cfg = cfg; this.jssc = jssc; this.props = props; - LOG.info("Creating Cleaner with configs : " + props.toString()); + log.info("Creating Cleaner with configs : {}", props.toString()); } public void run() { @@ -124,6 +122,6 @@ public static void main(String[] args) { SparkAdapterSupport$.MODULE$.sparkAdapter().stopSparkContext(jssc, exitCode); } - LOG.info("Cleaner ran successfully"); + log.info("Cleaner ran successfully"); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieClusteringJob.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieClusteringJob.java index c378bce9026c3..67e35aea78311 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieClusteringJob.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieClusteringJob.java @@ -37,9 +37,8 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; @@ -53,9 +52,9 @@ import static org.apache.hudi.utilities.UtilHelpers.SCHEDULE; import static org.apache.hudi.utilities.UtilHelpers.SCHEDULE_AND_EXECUTE; +@Slf4j public class HoodieClusteringJob { - private static final Logger LOG = LoggerFactory.getLogger(HoodieClusteringJob.class); private final Config cfg; private final TypedProperties props; private final JavaSparkContext jsc; @@ -168,7 +167,7 @@ public static void main(String[] args) { if (result != 0) { throw new HoodieException(resultMsg + " failed"); } - LOG.info(resultMsg + " success"); + log.info("{} success", resultMsg); jsc.stop(); } @@ -187,28 +186,28 @@ public int cluster(int retry) { return UtilHelpers.retry(retry, () -> { switch (cfg.runningMode.toLowerCase()) { case SCHEDULE: { - LOG.info("Running Mode: [" + SCHEDULE + "]; Do schedule"); + log.info("Running Mode: [{}]; Do schedule", SCHEDULE); Option instantTime = doSchedule(jsc); int result = instantTime.isPresent() ? 0 : -1; if (result == 0) { - LOG.info("The schedule instant time is " + instantTime.get()); + log.info("The schedule instant time is {}", instantTime.get()); } return result; } case SCHEDULE_AND_EXECUTE: { - LOG.info("Running Mode: [" + SCHEDULE_AND_EXECUTE + "]"); + log.info("Running Mode: [{}]", SCHEDULE_AND_EXECUTE); return doScheduleAndCluster(jsc); } case EXECUTE: { - LOG.info("Running Mode: [" + EXECUTE + "]; Do cluster"); + log.info("Running Mode: [{}]; Do cluster", EXECUTE); return doCluster(jsc); } case PURGE_PENDING_INSTANT: { - LOG.info("Running Mode: [" + PURGE_PENDING_INSTANT + "];"); + log.info("Running Mode: [{}];", PURGE_PENDING_INSTANT); return doPurgePendingInstant(jsc); } default: { - LOG.error("Unsupported running mode [" + cfg.runningMode + "], quit the job directly"); + log.error("Unsupported running mode [{}], quit the job directly", cfg.runningMode); return -1; } } @@ -224,10 +223,9 @@ private int doCluster(JavaSparkContext jsc) throws Exception { metaClient.getActiveTimeline().getFirstPendingClusterInstant(); if (firstClusteringInstant.isPresent()) { cfg.clusteringInstantTime = firstClusteringInstant.get().requestedTime(); - LOG.info("Found the earliest scheduled clustering instant which will be executed: " - + cfg.clusteringInstantTime); + log.info("Found the earliest scheduled clustering instant which will be executed: {}", cfg.clusteringInstantTime); } else { - LOG.info("There is no scheduled clustering in the table."); + log.info("There is no scheduled clustering in the table."); return 0; } } @@ -255,7 +253,7 @@ private Option doSchedule(SparkRDDWriteClient clien } private int doScheduleAndCluster(JavaSparkContext jsc) throws Exception { - LOG.info("Step 1: Do schedule"); + log.info("Step 1: Do schedule"); metaClient = HoodieTableMetaClient.reload(metaClient); String schemaStr = UtilHelpers.getSchemaFromLatestInstant(metaClient); try (SparkRDDWriteClient client = UtilHelpers.createHoodieClient(jsc, cfg.basePath, schemaStr, cfg.parallelism, Option.empty(), props)) { @@ -265,19 +263,19 @@ private int doScheduleAndCluster(JavaSparkContext jsc) throws Exception { Option staleInstant = TableServiceUtils.findStaleInflightInstant( metaClient, HoodieTimeline.CLUSTERING_ACTION, cfg.maxProcessingTimeMs); if (staleInstant.isPresent()) { - LOG.info("Found failed clustering instant at : " + staleInstant.get() + "; Will rollback the failed clustering and re-trigger again."); + log.info("Found failed clustering instant at : {}; Will rollback the failed clustering and re-trigger again.", staleInstant.get()); instantTime = Option.of(staleInstant.get().requestedTime()); } } instantTime = instantTime.isPresent() ? instantTime : doSchedule(client); if (!instantTime.isPresent()) { - LOG.info("Couldn't generate cluster plan"); + log.info("Couldn't generate cluster plan"); return -1; } - LOG.info("The schedule instant time is " + instantTime.get()); - LOG.info("Step 2: Do cluster"); + log.info("The schedule instant time is {}", instantTime.get()); + log.info("Step 2: Do cluster"); Option metadata = client.cluster(instantTime.get()).getCommitMetadata(); clean(client); return UtilHelpers.handleErrors(metadata.get(), instantTime.get()); @@ -345,7 +343,7 @@ public static void rollbackFailedClusteringForPartitions( metaClient.reloadActiveTimeline(); if (metaClient.getActiveTimeline().filterInflightsAndRequested() .containsInstant(instant.requestedTime())) { - LOG.info("Rolling back expired clustering instant {}", instant.requestedTime()); + log.info("Rolling back expired clustering instant {}", instant.requestedTime()); client.rollback(instant.requestedTime()); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCompactor.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCompactor.java index 3ba8808dedac4..dcd60133e82a5 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCompactor.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieCompactor.java @@ -38,20 +38,19 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Objects; +@Slf4j public class HoodieCompactor { - private static final Logger LOG = LoggerFactory.getLogger(HoodieCompactor.class); public static final String EXECUTE = "execute"; public static final String SCHEDULE = "schedule"; public static final String SCHEDULE_AND_EXECUTE = "scheduleandexecute"; @@ -75,11 +74,12 @@ public HoodieCompactor(JavaSparkContext jsc, Config cfg, TypedProperties props, this.props.put(HoodieCleanConfig.ASYNC_CLEAN.key(), false); if (this.metaClient.getTableConfig().isMetadataTableAvailable()) { // add default lock config options if MDT is enabled. - UtilHelpers.addLockOptions(cfg.basePath, this.metaClient.getBasePath().toUri().getScheme(), this.props); + UtilHelpers.addLockOptions(cfg.basePath, this.metaClient.getBasePath().toUri().getScheme(), this.props); } } public static class Config implements Serializable { + @Parameter(names = {"--base-path", "-sp"}, description = "Base path for the table", required = true) public String basePath = null; @Parameter(names = {"--table-name", "-tn"}, description = "Table name", required = true) @@ -196,7 +196,7 @@ public static void main(String[] args) { if (ret != 0) { throw new HoodieException("Fail to run compaction for " + cfg.tableName + ", return code: " + ret); } - LOG.info("Success to run compaction for " + cfg.tableName); + log.info("Success to run compaction for {}", cfg.tableName); jsc.stop(); } @@ -204,29 +204,29 @@ public int compact(int retry) { this.fs = HadoopFSUtils.getFs(cfg.basePath, jsc.hadoopConfiguration()); // need to do validate in case that users call compact() directly without setting cfg.runningMode validateRunningMode(cfg); - LOG.info(cfg.toString()); + log.info(cfg.toString()); int ret = UtilHelpers.retry(retry, () -> { switch (cfg.runningMode.toLowerCase()) { case SCHEDULE: { - LOG.info("Running Mode: [" + SCHEDULE + "]; Do schedule"); + log.info("Running Mode: [{}] Do schedule", SCHEDULE); Option instantTime = doSchedule(jsc); int result = instantTime.isPresent() ? 0 : -1; if (result == 0) { - LOG.info("The schedule instant time is " + instantTime.get()); + log.info("The schedule instant time is {}", instantTime.get()); } return result; } case SCHEDULE_AND_EXECUTE: { - LOG.info("Running Mode: [" + SCHEDULE_AND_EXECUTE + "]"); + log.info("Running Mode: [{}]", SCHEDULE_AND_EXECUTE); return doScheduleAndCompact(jsc); } case EXECUTE: { - LOG.info("Running Mode: [" + EXECUTE + "]; Do compaction"); + log.info("Running Mode: [{}]; Do compaction", EXECUTE); return doCompact(jsc); } default: { - LOG.info("Unsupported running mode [" + cfg.runningMode + "], quit the job directly"); + log.info("Unsupported running mode [{}], quit the job directly", cfg.runningMode); return -1; } } @@ -235,7 +235,7 @@ public int compact(int retry) { } private Integer doScheduleAndCompact(JavaSparkContext jsc) throws Exception { - LOG.info("Step 1: Do schedule"); + log.info("Step 1: Do schedule"); metaClient = HoodieTableMetaClient.reload(metaClient); Option instantTime = Option.empty(); @@ -243,20 +243,20 @@ private Integer doScheduleAndCompact(JavaSparkContext jsc) throws Exception { Option staleInstant = TableServiceUtils.findStaleInflightInstant( metaClient, HoodieTimeline.COMPACTION_ACTION, cfg.maxProcessingTimeMs); if (staleInstant.isPresent()) { - LOG.info("Found failed compaction instant at : " + staleInstant.get() + "; Will rollback the failed compaction and re-trigger again."); + log.info("Found failed compaction instant at : {}; Will rollback the failed compaction and re-trigger again.", staleInstant.get()); instantTime = Option.of(staleInstant.get().requestedTime()); } } instantTime = instantTime.isPresent() ? instantTime : doSchedule(jsc); if (!instantTime.isPresent()) { - LOG.error("Couldn't do schedule"); + log.error("Couldn't do schedule"); return -1; } cfg.compactionInstantTime = instantTime.get(); - LOG.info("The schedule instant time is {}", instantTime.get()); - LOG.info("Step 2: Do compaction"); + log.info("The schedule instant time is {}", instantTime.get()); + log.info("Step 2: Do compaction"); return doCompact(jsc); } @@ -278,10 +278,10 @@ private int doCompact(JavaSparkContext jsc) throws Exception { } else { schemaStr = UtilHelpers.parseSchema(fs, cfg.schemaFile); } - LOG.info("Schema --> : " + schemaStr); + log.info("Schema --> : {}", schemaStr); try (SparkRDDWriteClient client = - UtilHelpers.createHoodieClient(jsc, cfg.basePath, schemaStr, cfg.parallelism, Option.empty(), props)) { + UtilHelpers.createHoodieClient(jsc, cfg.basePath, schemaStr, cfg.parallelism, Option.empty(), props)) { // If no compaction instant is provided by --instant-time, find the earliest scheduled compaction // instant from the active timeline if (StringUtils.isNullOrEmpty(cfg.compactionInstantTime)) { @@ -289,10 +289,9 @@ private int doCompact(JavaSparkContext jsc) throws Exception { Option firstCompactionInstant = metaClient.getActiveTimeline().filterPendingCompactionTimeline().firstInstant(); if (firstCompactionInstant.isPresent()) { cfg.compactionInstantTime = firstCompactionInstant.get().requestedTime(); - LOG.info("Found the earliest scheduled compaction instant which will be executed: " - + cfg.compactionInstantTime); + log.info("Found the earliest scheduled compaction instant which will be executed: {}", cfg.compactionInstantTime); } else { - LOG.info("There is no scheduled compaction in the table."); + log.info("There is no scheduled compaction in the table."); return 0; } } @@ -305,7 +304,7 @@ private int doCompact(JavaSparkContext jsc) throws Exception { private Option doSchedule(JavaSparkContext jsc) { try (SparkRDDWriteClient client = - UtilHelpers.createHoodieClient(jsc, cfg.basePath, "", cfg.parallelism, Option.of(cfg.strategyClassName), props)) { + UtilHelpers.createHoodieClient(jsc, cfg.basePath, "", cfg.parallelism, Option.of(cfg.strategyClassName), props)) { return client.scheduleCompaction(Option.empty()); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java index 521f94bbafc59..79ca3b6471940 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java @@ -38,10 +38,9 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; @@ -98,10 +97,10 @@ * --min-validate-interval-seconds 60 * ``` */ +@Slf4j public class HoodieDataTableValidator implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HoodieDataTableValidator.class); // Spark context private transient JavaSparkContext jsc; @@ -253,7 +252,7 @@ public static void main(String[] args) { try { validator.run(); } catch (Throwable throwable) { - LOG.error("Fail to do hoodie Data table validation for " + validator.cfg, throwable); + log.error("Fail to do hoodie Data table validation for {}", validator.cfg, throwable); } finally { jsc.stop(); } @@ -261,12 +260,12 @@ public static void main(String[] args) { public void run() { try { - LOG.info(cfg.toString()); + log.info(cfg.toString()); if (cfg.continuous) { - LOG.info(" ****** do hoodie data table validation in CONTINUOUS mode ******"); + log.info(" ****** do hoodie data table validation in CONTINUOUS mode ******"); doHoodieDataTableValidationContinuous(); } else { - LOG.info(" ****** do hoodie data table validation once ******"); + log.info(" ****** do hoodie data table validation once ******"); doHoodieDataTableValidationOnce(); } } catch (Exception e) { @@ -283,7 +282,7 @@ private void doHoodieDataTableValidationOnce() { try { doDataTableValidation(); } catch (HoodieValidationException e) { - LOG.error("Metadata table validation failed to HoodieValidationException", e); + log.error("Metadata table validation failed to HoodieValidationException", e); if (!cfg.ignoreFailed) { throw e; } @@ -320,9 +319,8 @@ public void doDataTableValidation() { }).collect(Collectors.toList()); if (!danglingFilePaths.isEmpty() && danglingFilePaths.size() > 0) { - LOG.error("Data table validation failed due to dangling files count " - + danglingFilePaths.size() + ", found before active timeline"); - danglingFilePaths.forEach(entry -> LOG.error("Dangling file: " + entry.toString())); + log.error("Data table validation failed due to dangling files count {}, found before active timeline", danglingFilePaths.size()); + danglingFilePaths.forEach(entry -> log.error("Dangling file: {}", entry)); finalResult = false; if (!cfg.ignoreFailed) { throw new HoodieValidationException( @@ -354,8 +352,8 @@ public void doDataTableValidation() { }, hoodieInstants.size()).stream().collect(Collectors.toList()); if (!danglingFiles.isEmpty()) { - LOG.error("Data table validation failed due to extra files found for completed commits {}", danglingFiles.size()); - danglingFiles.forEach(entry -> LOG.error("Dangling file: {}", entry)); + log.error("Data table validation failed due to extra files found for completed commits {}", danglingFiles.size()); + danglingFiles.forEach(entry -> log.error("Dangling file: {}", entry)); finalResult = false; if (!cfg.ignoreFailed) { throw new HoodieValidationException("Data table validation failed due to dangling files " + danglingFiles.size()); @@ -363,16 +361,16 @@ public void doDataTableValidation() { } } } catch (Exception e) { - LOG.error("Data table validation failed", e); + log.error("Data table validation failed", e); if (!cfg.ignoreFailed) { throw new HoodieValidationException("Data table validation failed due to " + e.getMessage(), e); } } if (finalResult) { - LOG.info("Data table validation succeeded."); + log.info("Data table validation succeeded."); } else { - LOG.error("Data table validation failed."); + log.error("Data table validation failed."); } } @@ -389,12 +387,12 @@ protected Pair startService() { long toSleepMs = cfg.minValidateIntervalSeconds * 1000 - (System.currentTimeMillis() - start); if (toSleepMs > 0) { - LOG.info("Last validate ran less than min validate interval: " + cfg.minValidateIntervalSeconds + " s, sleep: " - + toSleepMs + " ms."); + log.info("Last validate ran less than min validate interval: {} s, sleep: {} ms.", + cfg.minValidateIntervalSeconds, toSleepMs); Thread.sleep(toSleepMs); } } catch (HoodieValidationException e) { - LOG.error("Shutting down AsyncDataTableValidateService due to HoodieValidationException", e); + log.error("Shutting down AsyncDataTableValidateService due to HoodieValidationException", e); if (!cfg.ignoreFailed) { throw e; } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java index 9e2e7d035b9a2..b9dadae7d87a4 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java @@ -38,13 +38,12 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.hadoop.hive.conf.HiveConf; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; @@ -101,10 +100,10 @@ * * Also you can use --help to find more configs to use. */ +@Slf4j public class HoodieDropPartitionsTool implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HoodieDropPartitionsTool.class); // Spark context private final transient JavaSparkContext jsc; // config @@ -282,7 +281,7 @@ public static void main(String[] args) { try { tool.run(); } catch (Throwable throwable) { - LOG.error("Fail to run deleting table partitions for " + cfg, throwable); + log.error("Fail to run deleting table partitions for {}", cfg, throwable); } finally { jsc.stop(); } @@ -290,21 +289,21 @@ public static void main(String[] args) { public void run() { try { - LOG.info(cfg.toString()); + log.info(cfg.toString()); Mode mode = Mode.valueOf(cfg.runningMode.toUpperCase()); switch (mode) { case DELETE: - LOG.info(" ****** The Hoodie Drop Partitions Tool is in delete mode ****** "); + log.info(" ****** The Hoodie Drop Partitions Tool is in delete mode ****** "); doDeleteTablePartitions(); syncToHiveIfNecessary(); break; case DRY_RUN: - LOG.info(" ****** The Hoodie Drop Partitions Tool is in dry-run mode ****** "); + log.info(" ****** The Hoodie Drop Partitions Tool is in dry-run mode ****** "); dryRun(); break; default: - LOG.info("Unsupported running mode [" + cfg.runningMode + "], quit the job directly"); + log.info("Unsupported running mode [{}], quit the job directly", cfg.runningMode); } } catch (Exception e) { throw new HoodieException("Unable to delete table partitions in " + cfg.basePath, e); @@ -368,19 +367,17 @@ private void verifyHiveConfigs() { } private void syncHive(HiveSyncConfig hiveSyncConfig) { - LOG.info("Syncing target hoodie table with hive table(" - + hiveSyncConfig.getStringOrDefault(HoodieSyncConfig.META_SYNC_TABLE_NAME) - + "). Hive metastore URL :" - + hiveSyncConfig.getStringOrDefault(HiveSyncConfigHolder.HIVE_URL) - + ", basePath :" + cfg.basePath); - LOG.info("Hive Sync Conf => " + hiveSyncConfig); + log.info("Syncing target hoodie table with hive table({}). Hive metastore URL :{}, basePath :{}", + hiveSyncConfig.getStringOrDefault(HoodieSyncConfig.META_SYNC_TABLE_NAME), + hiveSyncConfig.getStringOrDefault(HiveSyncConfigHolder.HIVE_URL), cfg.basePath); + log.info("Hive Sync Conf => {}", hiveSyncConfig); FileSystem fs = HadoopFSUtils.getFs(cfg.basePath, jsc.hadoopConfiguration()); HiveConf hiveConf = new HiveConf(); if (!StringUtils.isNullOrEmpty(cfg.hiveHMSUris)) { hiveConf.set("hive.metastore.uris", cfg.hiveHMSUris); } hiveConf.addResource(fs.getConf()); - LOG.info("Hive Conf => " + hiveConf.getAllProperties().toString()); + log.info("Hive Conf => {}", hiveConf.getAllProperties().toString()); try (HiveSyncTool hiveSyncTool = new HiveSyncTool(hiveSyncConfig.getProps(), hiveConf)) { hiveSyncTool.syncHoodieTable(); } @@ -392,9 +389,9 @@ private void syncHive(HiveSyncConfig hiveSyncConfig) { * @param partitionToReplaceFileIds */ private void printDeleteFilesInfo(Map> partitionToReplaceFileIds) { - LOG.info("Data files and partitions to delete : "); + log.info("Data files and partitions to delete : "); for (Map.Entry> entry : partitionToReplaceFileIds.entrySet()) { - LOG.info(String.format("Partitions : %s, corresponding data file IDs : %s", entry.getKey(), entry.getValue())); + log.info("Partitions : {}, corresponding data file IDs : {}", entry.getKey(), entry.getValue()); } } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieIndexer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieIndexer.java index b3d743693eec0..2c7bab98900fb 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieIndexer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieIndexer.java @@ -35,10 +35,9 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; @@ -87,9 +86,9 @@ * hoodie.write.concurrency.mode=optimistic_concurrency_control * hoodie.write.lock.provider=org.apache.hudi.client.transaction.lock.ZookeeperBasedLockProvider */ +@Slf4j public class HoodieIndexer { - private static final Logger LOG = LoggerFactory.getLogger(HoodieIndexer.class); static final String DROP_INDEX = "dropindex"; private final HoodieIndexer.Config cfg; @@ -165,21 +164,21 @@ public static void main(String[] args) { if (result != 0) { throw new HoodieException(resultMsg + " failed"); } - LOG.info(resultMsg + " success"); + log.info("{} success", resultMsg); jsc.stop(); } public int start(int retry) { // indexing should be done only if metadata is enabled if (!props.getBoolean(HoodieMetadataConfig.ENABLE.key())) { - LOG.error(String.format("Metadata is not enabled. Please set %s to true.", HoodieMetadataConfig.ENABLE.key())); + log.error("Metadata is not enabled. Please set {} to true.", HoodieMetadataConfig.ENABLE.key()); return -1; } // all inflight or completed metadata partitions have already been initialized // so enable corresponding indexes in the props so that they're not deleted Set initializedMetadataPartitions = getInflightAndCompletedMetadataPartitions(metaClient.getTableConfig()); - LOG.info("Setting props for: " + initializedMetadataPartitions); + log.info("Setting props for: {}", initializedMetadataPartitions); initializedMetadataPartitions.forEach(p -> { if (PARTITION_NAME_COLUMN_STATS.equals(p)) { props.setProperty(ENABLE_METADATA_INDEX_COLUMN_STATS.key(), "true"); @@ -195,28 +194,28 @@ public int start(int retry) { return UtilHelpers.retry(retry, () -> { switch (cfg.runningMode.toLowerCase()) { case SCHEDULE: { - LOG.info("Running Mode: [" + SCHEDULE + "]; Do schedule"); + log.info("Running Mode: [{}]; Do schedule", SCHEDULE); Option instantTime = scheduleIndexing(jsc); int result = instantTime.isPresent() ? 0 : -1; if (result == 0) { - LOG.info("The schedule instant time is " + instantTime.get()); + log.info("The schedule instant time is {}", instantTime.get()); } return result; } case SCHEDULE_AND_EXECUTE: { - LOG.info("Running Mode: [" + SCHEDULE_AND_EXECUTE + "]"); + log.info("Running Mode: [{}]", SCHEDULE_AND_EXECUTE); return scheduleAndRunIndexing(jsc); } case EXECUTE: { - LOG.info("Running Mode: [" + EXECUTE + "];"); + log.info("Running Mode: [{}];", EXECUTE); return runIndexing(jsc); } case DROP_INDEX: { - LOG.info("Running Mode: [" + DROP_INDEX + "];"); + log.info("Running Mode: [{}];", DROP_INDEX); return dropIndex(jsc); } default: { - LOG.info("Unsupported running mode [" + cfg.runningMode + "], quit the job directly"); + log.info("Unsupported running mode [{}], quit the job directly", cfg.runningMode); return -1; } } @@ -248,7 +247,7 @@ private Option doSchedule(SparkRDDWriteClient clien Option indexingInstant = client.scheduleIndexing(partitionTypes, Collections.emptyList()); if (!indexingInstant.isPresent()) { - LOG.error("Scheduling of index action did not return any instant."); + log.error("Scheduling of index action did not return any instant."); } return indexingInstant; } @@ -264,7 +263,7 @@ private boolean indexExists(List partitionTypes) { Set requestedIndexPartitionPaths = partitionTypes.stream().map(MetadataPartitionType::getPartitionPath).collect(Collectors.toSet()); requestedIndexPartitionPaths.retainAll(indexedMetadataPartitions); if (!requestedIndexPartitionPaths.isEmpty()) { - LOG.error("Following indexes already built: " + requestedIndexPartitionPaths); + log.error("Following indexes already built: {}", requestedIndexPartitionPaths); return true; } return false; @@ -286,8 +285,7 @@ private int runIndexing(JavaSparkContext jsc) throws Exception { .firstInstant(); if (earliestPendingIndexInstant.isPresent()) { cfg.indexInstantTime = earliestPendingIndexInstant.get().requestedTime(); - LOG.info("Found the earliest scheduled indexing instant which will be executed: " - + cfg.indexInstantTime); + log.info("Found the earliest scheduled indexing instant which will be executed: {}", cfg.indexInstantTime); } else { throw new HoodieIndexException("There is no scheduled indexing in the table."); } @@ -316,18 +314,18 @@ private int dropIndex(JavaSparkContext jsc) throws Exception { client.dropIndex(partitionTypes); return 0; } catch (Exception e) { - LOG.error("Failed to drop index. ", e); + log.error("Failed to drop index. ", e); return -1; } } private boolean handleResponse(Option commitMetadata) { if (!commitMetadata.isPresent()) { - LOG.error("Indexing failed as no commit metadata present."); + log.error("Indexing failed as no commit metadata present."); return false; } List indexPartitionInfos = commitMetadata.get().getIndexPartitionInfos(); - LOG.info("Indexing complete for partitions: {}", indexPartitionInfos.stream().map(HoodieIndexPartitionInfo::getMetadataPartitionPath).collect(Collectors.toList())); + log.info("Indexing complete for partitions: {}", indexPartitionInfos.stream().map(HoodieIndexPartitionInfo::getMetadataPartitionPath).collect(Collectors.toList())); return isIndexBuiltForAllRequestedTypes(indexPartitionInfos); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java index 778cd0090bc7e..8c5558930247b 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java @@ -61,6 +61,7 @@ import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.InstantComparison; +import org.apache.hudi.common.table.timeline.TimelineUtils; import org.apache.hudi.common.table.view.FileSystemViewManager; import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; import org.apache.hudi.common.table.view.FileSystemViewStorageType; @@ -75,6 +76,7 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.data.HoodieJavaRDD; import org.apache.hudi.data.HoodieSparkRDDUtils; +import org.apache.hudi.exception.ExceptionUtil; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.HoodieValidationException; @@ -96,6 +98,8 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.SparkException; import org.apache.spark.api.java.JavaPairRDD; @@ -104,17 +108,17 @@ import org.apache.spark.api.java.Optional; import org.apache.spark.sql.functions; import org.apache.spark.storage.StorageLevel; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; import java.nio.ByteBuffer; +import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -203,10 +207,14 @@ * --min-validate-interval-seconds 60 * ``` */ +@Slf4j public class HoodieMetadataTableValidator implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HoodieMetadataTableValidator.class); + + // Advance the metadata table query instant by this much so that instants derived from a data table + // instant, which carry a three-digit suffix, fall inside the queried window. See #metadataTableInstantFor. + private static final long METADATA_INSTANT_LOOKAHEAD_MS = 1; // Spark context private transient JavaSparkContext jsc; @@ -221,6 +229,7 @@ public class HoodieMetadataTableValidator implements Serializable { private final String taskLabels; + @Getter private final List throwables = new ArrayList<>(); public HoodieMetadataTableValidator(JavaSparkContext jsc, Config cfg) { @@ -239,21 +248,13 @@ public HoodieMetadataTableValidator(JavaSparkContext jsc, Config cfg) { .build()); } catch (TableNotFoundException tbe) { // Suppress the TableNotFound exception, table not yet created for a new stream - LOG.warn("Data table is not found. Skip current validation for: {}", cfg.basePath); + log.warn("Data table is not found. Skip current validation for: {}", cfg.basePath); } this.asyncMetadataTableValidateService = cfg.continuous ? Option.of(new AsyncMetadataTableValidateService()) : Option.empty(); this.taskLabels = generateValidationTaskLabels(); } - /** - * Returns list of Throwable which were encountered during validation. This method is useful - * when ignoreFailed parameter is set to true. - */ - public List getThrowables() { - return throwables; - } - /** * Returns true if there is a validation failure encountered during validation. * This method is useful when ignoreFailed parameter is set to true. @@ -513,7 +514,7 @@ public static void main(String[] args) { HoodieMetadataTableValidator validator = new HoodieMetadataTableValidator(jsc, cfg); validator.run(); } catch (Throwable throwable) { - LOG.error("Fail to do hoodie metadata table validation for {}", cfg, throwable); + log.error("Fail to do hoodie metadata table validation for {}", cfg, throwable); } finally { jsc.stop(); } @@ -521,17 +522,17 @@ public static void main(String[] args) { public boolean run() { if (!metaClientOpt.isPresent()) { - LOG.warn("Data table is not available to read for now, skip current validation for: {}", cfg.basePath); + log.warn("Data table is not available to read for now, skip current validation for: {}", cfg.basePath); return true; } boolean result = false; try { - LOG.info(cfg.toString()); + log.info(cfg.toString()); if (cfg.continuous) { - LOG.info(" ****** do hoodie metadata table validation in CONTINUOUS mode - {} ******", taskLabels); + log.info(" ****** do hoodie metadata table validation in CONTINUOUS mode - {} ******", taskLabels); doHoodieMetadataTableValidationContinuous(); } else { - LOG.info(" ****** do hoodie metadata table validation once - {} ******", taskLabels); + log.info(" ****** do hoodie metadata table validation once - {} ******", taskLabels); result = doHoodieMetadataTableValidationOnce(); } return result; @@ -553,7 +554,7 @@ private boolean doHoodieMetadataTableValidationOnce() { try { return doMetadataTableValidation(); } catch (Throwable e) { - LOG.error("Metadata table validation failed to HoodieValidationException {}", taskLabels, e); + log.error("Metadata table validation failed to HoodieValidationException {}", taskLabels, e); if (!cfg.ignoreFailed) { throw e; } @@ -576,7 +577,7 @@ private void doHoodieMetadataTableValidationContinuous() { public boolean doMetadataTableValidation() { boolean finalResult = true; if (!metaClientOpt.isPresent()) { - LOG.warn("Data table is not available to read for now, skip current validation."); + log.warn("Data table is not available to read for now, skip current validation."); return true; } HoodieTableMetaClient metaClient = this.metaClientOpt.get(); @@ -618,7 +619,7 @@ public boolean doMetadataTableValidation() { List allPartitions = validatePartitions(engineContext, basePath, metaClient); if (allPartitions.isEmpty()) { - LOG.warn("The result of getting all partitions is null or empty, skip current validation. {}", taskLabels); + log.warn("The result of getting all partitions is null or empty, skip current validation. {}", taskLabels); return true; } @@ -631,10 +632,10 @@ public boolean doMetadataTableValidation() { engineContext.parallelize(allPartitions, allPartitions.size()).map(partitionPath -> { try { validateFilesInPartition(metadataTableBasedContext, fsBasedContext, partitionPath, finalBaseFilesForCleaning); - LOG.info("Metadata table validation succeeded for partition {} (partition {})", partitionPath, taskLabels); + log.info("Metadata table validation succeeded for partition {} (partition {})", partitionPath, taskLabels); return Pair.of(true, null); } catch (HoodieValidationException e) { - LOG.error("Metadata table validation failed for partition {} due to HoodieValidationException (partition {})", + log.error("Metadata table validation failed for partition {} due to HoodieValidationException (partition {})", partitionPath, taskLabels, e); if (!cfg.ignoreFailed) { throw e; @@ -671,7 +672,7 @@ public boolean doMetadataTableValidation() { for (Pair res : result) { finalResult &= res.getKey(); if (res.getKey().equals(false)) { - LOG.error("Metadata Validation failed for table: {}", cfg.basePath, res.getValue()); + log.error("Metadata Validation failed for table: {}", cfg.basePath, res.getValue()); if (res.getRight() != null) { throwables.add(res.getRight()); } @@ -679,10 +680,10 @@ public boolean doMetadataTableValidation() { } if (finalResult) { - LOG.info("Metadata table validation succeeded ({}).", taskLabels); + log.info("Metadata table validation succeeded ({}).", taskLabels); return true; } else { - LOG.error("Metadata table validation failed ({}).", taskLabels); + log.error("Metadata table validation failed ({}).", taskLabels); return false; } } catch (HoodieValidationException validationException) { @@ -690,18 +691,20 @@ public boolean doMetadataTableValidation() { } catch (SparkException sparkException) { if (sparkException.getCause() instanceof HoodieValidationException) { throw (HoodieValidationException) sparkException.getCause(); + } else if (ExceptionUtil.validateErrorMsg(sparkException, "cancelled because SparkContext was shut down")) { + throw new HoodieException(sparkException); } else { throw new HoodieValidationException("Unexpected spark failure", sparkException); } } catch (Exception e) { - LOG.warn("Error closing HoodieMetadataValidationContext, " + log.warn("Error closing HoodieMetadataValidationContext, " + "ignoring the error as the validation is successful.", e); return true; } } private void handleValidationException(HoodieValidationException e, List> result, String errorMsg) { - LOG.error("{} for table: {} ", errorMsg, cfg.basePath, e); + log.error("{} for table: {} ", errorMsg, cfg.basePath, e); if (!cfg.ignoreFailed) { throw e; } @@ -722,7 +725,7 @@ private boolean checkMetadataTableIsAvailable() { int finishedInstants = mdtMetaClient.getCommitsTimeline().filterCompletedInstants().countInstants(); if (finishedInstants == 0) { if (metaClientOpt.get().getCommitsTimeline().filterCompletedInstants().countInstants() == 0) { - LOG.info("There is no completed commit in both metadata table and corresponding data table: {}", taskLabels); + log.info("There is no completed commit in both metadata table and corresponding data table: {}", taskLabels); return false; } else { throw new HoodieValidationException("There is no completed instant for metadata table: " + cfg.basePath); @@ -731,10 +734,10 @@ private boolean checkMetadataTableIsAvailable() { return true; } catch (TableNotFoundException tbe) { // Suppress the TableNotFound exception if Metadata table is not available to read for now - LOG.warn("Metadata table is not found for table: {}. Skip current validation.", cfg.basePath); + log.warn("Metadata table is not found for table: {}. Skip current validation.", cfg.basePath); return false; } catch (Exception ex) { - LOG.warn("Metadata table is not available to read for now for table: {}, ", cfg.basePath, ex); + log.warn("Metadata table is not available to read for now for table: {}, ", cfg.basePath, ex); return false; } } @@ -773,7 +776,7 @@ List validatePartitions(HoodieSparkEngineContext engineContext, StorageP Option lastInstant = completedTimeline.lastInstant(); if (lastInstant.isPresent() && InstantComparison.compareTimestamps(partitionCreationTimeOpt.get(), GREATER_THAN, lastInstant.get().requestedTime())) { - LOG.info("Ignoring additional partition {}, as it was deduced to be part of a " + log.info("Ignoring additional partition {}, as it was deduced to be part of a " + "latest completed commit which was inflight when FS based listing was polled.", partitionFromMDT); actualAdditionalPartitionsInMDT.remove(partitionFromMDT); } @@ -801,7 +804,7 @@ List validatePartitions(HoodieSparkEngineContext engineContext, StorageP }).collect(Collectors.toList()); additionalFromFS.removeAll(emptyPartitions); if (additionalFromFS.isEmpty()) { - LOG.info("All out of sync partitions turned out to be empty {}", emptyPartitions); + log.info("All out of sync partitions turned out to be empty {}", emptyPartitions); misMatch.set(false); } else { misMatch.set(true); @@ -816,7 +819,7 @@ List validatePartitions(HoodieSparkEngineContext engineContext, StorageP + toStringWithThreshold(actualAdditionalPartitionsInMDT, cfg.logDetailMaxLength) + "\".\n All " + allPartitionPathsFromFS.size() + " partitions from FS listing " + toStringWithThreshold(allPartitionPathsFromFS, cfg.logDetailMaxLength); - LOG.error(message); + log.error(message); throw new HoodieValidationException(message); } } @@ -1240,7 +1243,7 @@ private void validateRecordIndexCount(HoodieSparkEngineContext sparkEngineContex .select(RECORD_KEY_METADATA_FIELD) .count(); long countKeyFromRecordIndex = sparkEngineContext.getSqlContext().read().format("hudi") - .option(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT().key(),latestCompletedCommit) + .option(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT().key(), metadataTableInstantFor(latestCompletedCommit)) .load(getMetadataTableBasePath(basePath)) .select("key") .filter("type = 5") @@ -1249,10 +1252,10 @@ private void validateRecordIndexCount(HoodieSparkEngineContext sparkEngineContex if (countKeyFromTable != countKeyFromRecordIndex) { String message = String.format("Validation of record index count failed: %s entries from record index metadata, %s keys from the data table: %s", countKeyFromRecordIndex, countKeyFromTable, cfg.basePath); - LOG.error(message); + log.error(message); throw new HoodieValidationException(message); } else { - LOG.info("Validation of record index count succeeded: {} entries. Table: {}", countKeyFromRecordIndex, cfg.basePath); + log.info("Validation of record index count succeeded: {} entries. Table: {}", countKeyFromRecordIndex, cfg.basePath); } } @@ -1335,10 +1338,41 @@ private void validateRecordIndexContent(HoodieSparkEngineContext sparkEngineCont + "%s keys (total %s) from the data table have wrong location in record index " + "metadata. Table: %s Sample mismatches: %s", diffCount, countKey, cfg.basePath, String.join(";", result.getRight())); - LOG.error(message); + log.error(message); throw new HoodieValidationException(message); } else { - LOG.info("Validation of record index content succeeded: {} entries. Table: {}", countKey, cfg.basePath); + log.info("Validation of record index content succeeded: {} entries. Table: {}", countKey, cfg.basePath); + } + } + + /** + * Returns the instant to query the metadata table with, so that the snapshot reflects the data + * table as of {@code dataTableInstant}. + *

    + * Metadata table instants derived from a data table instant carry a three-digit numeric suffix: + * partition initialization appends 010 and up (see + * {@code HoodieTableMetadataUtil#createIndexInitTimestamp}), and metadata-table-internal + * compaction, clean, restore, indexing, log compaction and rollback append 001 to 006. Hudi + * compares instants as strings, so every one of those derived instants sorts AFTER the bare data + * instant, and a snapshot taken as of the data instant itself excludes them - leaving, for + * instance, the record index unreadable until the data table receives another commit. + *

    + * The bound is therefore advanced by a single millisecond. That is strictly greater than any + * {@code } (which shares the whole 17-character prefix and so compares + * lower), while still being a valid {@code yyyyMMddHHmmssSSS} instant - the time travel option + * rejects anything else, see {@code HoodieSqlCommonUtils#formatQueryInstant}. Instants that are + * not timestamps (legacy or test instants such as "100") are returned unchanged; they have no + * metadata table counterpart to include. + */ + @VisibleForTesting + static String metadataTableInstantFor(String dataTableInstant) { + try { + Date dataTableInstantDate = TimelineUtils.parseDateFromInstantTime(dataTableInstant); + return TimelineUtils.formatDate(new Date(dataTableInstantDate.getTime() + METADATA_INSTANT_LOOKAHEAD_MS)); + } catch (ParseException e) { + log.warn("Cannot parse instant {} as a timestamp; querying the metadata table as of it verbatim", + dataTableInstant); + return dataTableInstant; } } @@ -1362,7 +1396,7 @@ JavaPairRDD> getRecordLocationsFromRLI(HoodieSparkE String basePath, String latestCompletedCommit) { return sparkEngineContext.getSqlContext().read().format("hudi") - .option(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT().key(), latestCompletedCommit) + .option(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT().key(), metadataTableInstantFor(latestCompletedCommit)) .load(getMetadataTableBasePath(basePath)) .filter("type = 5") .select(functions.col("key"), @@ -1466,10 +1500,10 @@ void validate( if (mismatch) { String message = String.format("Validation of %s for partition %s failed for table: %s. %s", label, partitionPath, cfg.basePath, errorDetails); - LOG.error(message); + log.error(message); throw new HoodieValidationException(message); } else { - LOG.info("Validation of {} succeeded for partition {} for table: {}", label, partitionPath, cfg.basePath); + log.info("Validation of {} succeeded for partition {} for table: {}", label, partitionPath, cfg.basePath); } } @@ -1519,7 +1553,7 @@ void validateFileSlices( mismatch = true; break; } else { - LOG.info("There are uncommitted log files in the latest file slices but the committed log files match: {} {}", fileSlice1, fileSlice2); + log.info("There are uncommitted log files in the latest file slices but the committed log files match: {} {}", fileSlice1, fileSlice2); } } } @@ -1527,10 +1561,10 @@ void validateFileSlices( if (mismatch) { String message = String.format("Validation of %s for partition %s failed for table: %s. %s", label, partitionPath, cfg.basePath, errorDetails); - LOG.error(message); + log.error(message); throw new HoodieValidationException(message); } else { - LOG.info("Validation of {} succeeded for partition {} for table: {}", label, partitionPath, cfg.basePath); + log.info("Validation of {} succeeded for partition {} for table: {}", label, partitionPath, cfg.basePath); } } @@ -1562,16 +1596,16 @@ static String computeDiffSummary(List fileSliceListFromMetadataTable, // truncate start instant since range is not Set missingCommits = nonActiveInstantTimes.stream().filter(instant -> !archivedInstants.contains(instant)).collect(Collectors.toSet()); if (!missingCommits.isEmpty()) { - LOG.warn("File slices in file system belong to missing commits: {}", String.join(",", missingCommits)); + log.warn("File slices in file system belong to missing commits: {}", String.join(",", missingCommits)); activeTimeline.getRollbackTimeline().getInstantsAsStream().forEach(instant -> { HoodieInstant requestedInstant = metaClient.getInstantGenerator().getRollbackRequestedInstant(instant); try { HoodieRollbackPlan rollbackPlan = activeTimeline.readInstantContent(requestedInstant, HoodieRollbackPlan.class); if (missingCommits.contains(rollbackPlan.getInstantToRollback().getCommitTime())) { - LOG.warn("Missing commit ({}) is part of rollback plan: {}", rollbackPlan.getInstantToRollback().getCommitTime(), rollbackPlan); + log.warn("Missing commit ({}) is part of rollback plan: {}", rollbackPlan.getInstantToRollback().getCommitTime(), rollbackPlan); } } catch (IOException ex) { - LOG.warn("Failed to deserialize rollback plan for instant: {}", requestedInstant, ex); + log.warn("Failed to deserialize rollback plan for instant: {}", requestedInstant, ex); } }); } @@ -1660,7 +1694,7 @@ Pair hasCommittedLogFiles( try { HoodieSchema readerSchema = TableSchemaResolver.readSchemaFromLogFile(storage, new StoragePath(logFilePathStr)); if (readerSchema == null) { - LOG.warn("Cannot read schema from log file {}. Skip the check as it's likely being written by an inflight instant.", logFilePathStr); + log.warn("Cannot read schema from log file {}. Skip the check as it's likely being written by an inflight instant.", logFilePathStr); continue; } reader = @@ -1697,7 +1731,7 @@ Pair hasCommittedLogFiles( "Log file is committed in an instant in active timeline: instantTime=%s %s", instantTime, logFilePathStr)); } else { - LOG.warn("Log file is uncommitted in a completed instant, likely due to retry: instantTime={} {}", instantTime, logFilePathStr); + log.warn("Log file is uncommitted in a completed instant, likely due to retry: instantTime={} {}", instantTime, logFilePathStr); } } else if (completedInstantsTimeline.isBeforeTimelineStarts(instantTime)) { // The instant is in archived timeline @@ -1707,18 +1741,18 @@ Pair hasCommittedLogFiles( } else if (inflightInstantsTimeline.containsInstant(instantTime)) { // The instant is inflight in active timeline // hit an uncommitted block possibly from a failed write - LOG.warn("Log file is uncommitted because of an inflight instant: instantTime={} {}", instantTime, logFilePathStr); + log.warn("Log file is uncommitted because of an inflight instant: instantTime={} {}", instantTime, logFilePathStr); } else { // The instant is after the start of the active timeline, // but it cannot be found in the active timeline - LOG.warn("Log file is uncommitted because the instant is after the start of the active timeline but absent or in requested in the active timeline: instantTime={} {}", + log.warn("Log file is uncommitted because the instant is after the start of the active timeline but absent or in requested in the active timeline: instantTime={} {}", instantTime, logFilePathStr); } } else { - LOG.warn("There is no log block in {}", logFilePathStr); + log.warn("There is no log block in {}", logFilePathStr); } } catch (IOException e) { - LOG.warn("Cannot read log file {}. Skip the check as it's likely being written by an inflight instant.", + log.warn("Cannot read log file {}. Skip the check as it's likely being written by an inflight instant.", logFilePathStr, e); } finally { FileIOUtils.closeQuietly(reader); @@ -1753,11 +1787,11 @@ protected Pair startService() { long toSleepMs = cfg.minValidateIntervalSeconds * 1000 - (System.currentTimeMillis() - start); if (toSleepMs > 0) { - LOG.info("Last validate ran less than min validate interval: {} s, sleep: {} ms.", cfg.minValidateIntervalSeconds, toSleepMs); + log.info("Last validate ran less than min validate interval: {} s, sleep: {} ms.", cfg.minValidateIntervalSeconds, toSleepMs); Thread.sleep(toSleepMs); } } catch (HoodieValidationException e) { - LOG.error("Shutting down AsyncMetadataTableValidateService due to HoodieValidationException", e); + log.error("Shutting down AsyncMetadataTableValidateService due to HoodieValidationException", e); if (!cfg.ignoreFailed) { throw e; } @@ -1813,15 +1847,18 @@ public int compare(HoodieColumnRangeMetadata o1, HoodieColumnRangeMe * the same information regardless of whether metadata table is enabled, which is * verified in the {@link HoodieMetadataTableValidator}. */ + @Slf4j private static class HoodieMetadataValidationContext implements AutoCloseable, Serializable { - private static final Logger LOG = LoggerFactory.getLogger(HoodieMetadataValidationContext.class); - private final Properties props; + @Getter private final HoodieTableMetaClient metaClient; + @Getter private final HoodieMetadataConfig metadataConfig; + @Getter private final HoodieSchema schema; private final HoodieTableFileSystemView fileSystemView; + @Getter private final HoodieTableMetadata tableMetadata; private final boolean enableMetadataTable; private List allColumnNameList; @@ -1862,10 +1899,10 @@ private HoodieTableFileSystemView getFileSystemView(HoodieEngineContext context, FileSystemViewStorageConfig viewConf, HoodieCommonConfig commonConfig) { switch (viewConf.getStorageType()) { case SPILLABLE_DISK: - LOG.debug("Creating Spillable Disk based Table View"); + log.debug("Creating Spillable Disk based Table View"); break; case MEMORY: - LOG.debug("Creating in-memory based Table View"); + log.debug("Creating in-memory based Table View"); break; default: throw new HoodieException("Unsupported storage type " + viewConf.getStorageType() + ", used with HoodieMetadataTableValidator"); @@ -1873,22 +1910,6 @@ private HoodieTableFileSystemView getFileSystemView(HoodieEngineContext context, return (HoodieTableFileSystemView) FileSystemViewManager.createViewManager(context, metadataConfig, viewConf, commonConfig, unused -> tableMetadata).getFileSystemView(metaClient); } - public HoodieTableMetaClient getMetaClient() { - return metaClient; - } - - public HoodieMetadataConfig getMetadataConfig() { - return metadataConfig; - } - - public HoodieSchema getSchema() { - return schema; - } - - public HoodieTableMetadata getTableMetadata() { - return tableMetadata; - } - public List getSortedLatestBaseFileList(String partitionPath) { return fileSystemView.getLatestBaseFiles(partitionPath) .sorted(new HoodieBaseFileComparator()).collect(Collectors.toList()); @@ -1906,7 +1927,7 @@ public List getSortedAllFileGroupList(String partitionPath) { @SuppressWarnings({"rawtypes", "unchecked"}) public List> getSortedColumnStatsList(String partitionPath, List fileNames, HoodieSchema readerSchema) { - LOG.info("All column names for getting column stats: {}", allColumnNameList); + log.info("All column names for getting column stats: {}", allColumnNameList); if (enableMetadataTable) { List> partitionFileNameList = fileNames.stream() .map(filename -> Pair.of(partitionPath, filename)).collect(Collectors.toList()); @@ -1990,11 +2011,11 @@ private Option readBloomFilterFromFile(String partitionPath, St .getFileReader(new HoodieConfig(), path)) { bloomFilter = fileReader.readBloomFilter(); if (bloomFilter == null) { - LOG.error("Failed to read bloom filter for {}", path); + log.error("Failed to read bloom filter for {}", path); return Option.empty(); } } catch (IOException e) { - LOG.error("Failed to get file reader for {} {}", path, e); + log.error("Failed to get file reader for {} {}", path, e); return Option.empty(); } return Option.of(BloomFilterData.builder() diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieRepairTool.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieRepairTool.java index 88c2b33454e9c..6ea90039cc36a 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieRepairTool.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieRepairTool.java @@ -41,11 +41,10 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -139,9 +138,9 @@ * --backup-path backup_path * ``` */ +@Slf4j public class HoodieRepairTool { - private static final Logger LOG = LoggerFactory.getLogger(HoodieRepairTool.class); private static final String BACKUP_DIR_PREFIX = "hoodie_repair_backup_"; // Repair config private final Config cfg; @@ -175,41 +174,39 @@ public boolean run() { Option endingInstantOption = Option.ofNullable(cfg.endingInstantTime); if (startingInstantOption.isPresent() && endingInstantOption.isPresent()) { - LOG.info(String.format("Start repairing completed instants between %s and %s (inclusive)", - startingInstantOption.get(), endingInstantOption.get())); + log.info("Start repairing completed instants between {} and {} (inclusive)", + startingInstantOption.get(), endingInstantOption.get()); } else if (startingInstantOption.isPresent()) { - LOG.info(String.format("Start repairing completed instants from %s (inclusive)", - startingInstantOption.get())); + log.info("Start repairing completed instants from {} (inclusive)", startingInstantOption.get()); } else if (endingInstantOption.isPresent()) { - LOG.info(String.format("Start repairing completed instants till %s (inclusive)", - endingInstantOption.get())); + log.info("Start repairing completed instants till {} (inclusive)", endingInstantOption.get()); } else { - LOG.info("Start repairing all completed instants"); + log.info("Start repairing all completed instants"); } try { Mode mode = Mode.valueOf(cfg.runningMode.toUpperCase()); switch (mode) { case REPAIR: - LOG.info(" ****** The repair tool is in REPAIR mode, dangling data and logs files " + log.info(" ****** The repair tool is in REPAIR mode, dangling data and logs files " + "not belonging to any commit are going to be DELETED from the table ******"); if (checkBackupPathForRepair() < 0) { - LOG.error("Backup path check failed."); + log.error("Backup path check failed."); return false; } return doRepair(startingInstantOption, endingInstantOption, false); case DRY_RUN: - LOG.info(" ****** The repair tool is in DRY_RUN mode, " + log.info(" ****** The repair tool is in DRY_RUN mode, " + "only LOOKING FOR dangling data and log files from the table ******"); return doRepair(startingInstantOption, endingInstantOption, true); case UNDO: if (checkBackupPathAgainstBasePath() < 0) { - LOG.error("Backup path check failed."); + log.error("Backup path check failed."); return false; } return undoRepair(); default: - LOG.info("Unsupported running mode [" + cfg.runningMode + "], quit the job directly"); + log.info("Unsupported running mode [{}], quit the job directly", cfg.runningMode); return false; } } catch (IOException e) { @@ -229,7 +226,7 @@ public static void main(String[] args) { try { new HoodieRepairTool(jsc, cfg).run(); } catch (Throwable throwable) { - LOG.error("Fail to run table repair for " + cfg.basePath, throwable); + log.error("Fail to run table repair for {}", cfg.basePath, throwable); } finally { jsc.stop(); } @@ -264,8 +261,7 @@ static boolean copyFiles( } } catch (IOException e) { // Copy Fail - LOG.error(String.format("Copying file fails: source [%s], destination [%s]", - sourcePath, destPath)); + log.error("Copying file fails: source [{}], destination [{}]", sourcePath, destPath); } finally { results.add(success); } @@ -321,7 +317,7 @@ static boolean deleteFiles( try { success = fs.delete(new Path(basePath, relativeFilePath), false); } catch (IOException e) { - LOG.error("Failed to delete file {}", relativeFilePath); + log.error("Failed to delete file {}", relativeFilePath); } finally { results.add(success); } @@ -378,12 +374,12 @@ boolean doRepair( .collect(Collectors.toList()); if (relativeFilePathsToDelete.size() > 0) { if (!backupFiles(relativeFilePathsToDelete)) { - LOG.error("Error backing up dangling files. Exiting..."); + log.error("Error backing up dangling files. Exiting..."); return false; } return deleteFiles(context, cfg.basePath, relativeFilePathsToDelete); } - LOG.info(String.format("Table repair on %s is successful", cfg.basePath)); + log.info("Table repair on {} is successful", cfg.basePath); } return true; } @@ -398,14 +394,14 @@ boolean undoRepair() throws IOException { String backupPathStr = cfg.backupPath; StoragePath backupPath = new StoragePath(backupPathStr); if (!storage.exists(backupPath)) { - LOG.error("Cannot find backup path: " + backupPath); + log.error("Cannot find backup path: {}", backupPath); return false; } List allPartitionPaths = tableMetadata.getAllPartitionPaths(); if (allPartitionPaths.isEmpty()) { - LOG.error("Cannot get one partition path since there is no partition available"); + log.error("Cannot get one partition path since there is no partition available"); return false; } @@ -446,7 +442,7 @@ int checkBackupPathForRepair() throws IOException { StoragePath backupPath = new StoragePath(cfg.backupPath); if (metaClient.getStorage().exists(backupPath) && metaClient.getStorage().listDirectEntries(backupPath).size() > 0) { - LOG.error(String.format("Cannot use backup path %s: it is not empty", cfg.backupPath)); + log.error("Cannot use backup path {}: it is not empty", cfg.backupPath); return -1; } @@ -461,13 +457,12 @@ int checkBackupPathForRepair() throws IOException { */ int checkBackupPathAgainstBasePath() { if (cfg.backupPath == null) { - LOG.error("Backup path is not configured"); + log.error("Backup path is not configured"); return -1; } if (cfg.backupPath.contains(cfg.basePath)) { - LOG.error(String.format("Cannot use backup path %s: it resides in the base path %s", - cfg.backupPath, cfg.basePath)); + log.error("Cannot use backup path {}: it resides in the base path {}", cfg.backupPath, cfg.basePath); return -1; } return 0; @@ -502,11 +497,11 @@ boolean restoreFiles(List relativeFilePaths) { private void printRepairInfo( List instantTimesToRepair, List>> instantsWithDanglingFiles) { int numInstantsToRepair = instantsWithDanglingFiles.size(); - LOG.info("Number of instants verified based on the base and log files: {}", instantTimesToRepair.size()); - LOG.info("Instant timestamps: {}", instantTimesToRepair); - LOG.info("Number of instants to repair: {}", numInstantsToRepair); + log.info("Number of instants verified based on the base and log files: {}", instantTimesToRepair.size()); + log.info("Instant timestamps: {}", instantTimesToRepair); + log.info("Number of instants to repair: {}", numInstantsToRepair); if (numInstantsToRepair > 0) { - instantsWithDanglingFiles.forEach(e -> LOG.info(" ** Removing files: {}", e.getValue())); + instantsWithDanglingFiles.forEach(e -> log.info(" ** Removing files: {}", e.getValue())); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java index 38794c5345fde..78e72ee31c3bb 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java @@ -49,6 +49,7 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; import com.beust.jcommander.ParameterException; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileStatus; import org.apache.hadoop.fs.FileSystem; @@ -62,8 +63,6 @@ import org.apache.spark.sql.SQLContext; import org.apache.spark.sql.SaveMode; import org.apache.spark.sql.SparkSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -79,6 +78,7 @@ /** * Export the latest records of Hudi dataset to a set of external files (e.g., plain parquet files). */ +@Slf4j public class HoodieSnapshotExporter { @FunctionalInterface @@ -88,8 +88,6 @@ public interface Partitioner { } - private static final Logger LOG = LoggerFactory.getLogger(HoodieSnapshotExporter.class); - public static class OutputFormatValidator implements IValueValidator { public static final String HUDI = "hudi"; @@ -159,8 +157,7 @@ public void export(JavaSparkContext jsc, Config cfg) throws IOException { .orElseThrow(() -> { throw new HoodieSnapshotExporterException("No commits present. Nothing to snapshot."); }); - LOG.info(String.format("Starting to snapshot latest version files which are also no-late-than %s.", - latestCommitTimestamp)); + log.info("Starting to snapshot latest version files which are also no-late-than {}.", latestCommitTimestamp); final HoodieSparkEngineContext engineContext = new HoodieSparkEngineContext(jsc); final List partitions = getPartitions(engineContext, cfg, HoodieStorageUtils.getStorage( @@ -169,7 +166,7 @@ public void export(JavaSparkContext jsc, Config cfg) throws IOException { if (partitions.isEmpty()) { throw new HoodieSnapshotExporterException("The source dataset has 0 partition to snapshot."); } - LOG.info(String.format("The job needs to export %d partitions.", partitions.size())); + log.info("The job needs to export {} partitions.", partitions.size()); if (cfg.outputFormat.equals(OutputFormatValidator.HUDI)) { exportAsHudi(jsc, sourceFs, cfg, partitions, latestCommitTimestamp, tableMetadata); @@ -194,7 +191,7 @@ private List getPartitions(HoodieEngineContext engineContext, Config cfg private void createSuccessTag(FileSystem fs, Config cfg) throws IOException { Path successTagPath = new Path(cfg.targetOutputPath + "/_SUCCESS"); if (!fs.exists(successTagPath)) { - LOG.info(String.format("Creating _SUCCESS under target output path: %s", cfg.targetOutputPath)); + log.info("Creating _SUCCESS under target output path: {}", cfg.targetOutputPath); fs.createNewFile(successTagPath); } } @@ -285,7 +282,7 @@ private void exportAsHudi(JavaSparkContext jsc, FileSystem sourceFs, }, parallelism); // Also copy the .commit files - LOG.info(String.format("Copying .commit files which are no-late-than %s.", latestCommitTimestamp)); + log.info("Copying .commit files which are no-late-than {}.", latestCommitTimestamp); List commitFilesListToCopy = Arrays.stream(sourceFs.listStatus(new Path(cfg.sourceBasePath + "/" + HoodieTableMetaClient.METAFOLDER_NAME + "/" + HoodieTableMetaClient.TIMELINEFOLDER_NAME))) .filter(fileStatus -> { @@ -344,7 +341,7 @@ public static void main(String[] args) throws IOException { } JavaSparkContext jsc = UtilHelpers.buildSparkContext("Hoodie-snapshot-exporter", "local[*]", cfg.enableHiveSupport); - LOG.info("Initializing spark job."); + log.info("Initializing spark job."); try { new HoodieSnapshotExporter().export(jsc, cfg); @@ -359,13 +356,13 @@ public static boolean areTransformerOptionsValid(Config config) { switch (config.transformerClassName) { case "org.apache.hudi.utilities.transform.SqlQueryBasedTransformer": if (StringUtils.isNullOrEmpty(config.transformerSql)) { - LOG.error("--transformer-sql is required when using SqlQueryBasedTransformer"); + log.error("--transformer-sql is required when using SqlQueryBasedTransformer"); valid = false; } break; case "org.apache.hudi.utilities.transform.SqlFileBasedTransformer": if (StringUtils.isNullOrEmpty(config.transformerSqlFile)) { - LOG.error("--transformer-sql-file is required when using SqlFileBasedTransformer"); + log.error("--transformer-sql-file is required when using SqlFileBasedTransformer"); valid = false; } break; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTTLJob.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTTLJob.java index 519ce482d6eca..3d61449760286 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTTLJob.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTTLJob.java @@ -31,10 +31,9 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Serializable; import java.util.ArrayList; @@ -43,9 +42,9 @@ /** * Utility class to run TTL management. */ +@Slf4j public class HoodieTTLJob { - private static final Logger LOG = LoggerFactory.getLogger(HoodieTTLJob.class); private final Config cfg; private final TypedProperties props; private final JavaSparkContext jsc; @@ -61,7 +60,7 @@ public HoodieTTLJob(JavaSparkContext jsc, Config cfg, TypedProperties props, Hoo this.jsc = jsc; this.props = props; this.metaClient = metaClient; - LOG.info("Creating TTL job with configs : " + props.toString()); + log.info("Creating TTL job with configs : {}", props.toString()); // Disable async cleaning, will trigger synchronous cleaning manually. this.props.put(HoodieCleanConfig.ASYNC_CLEAN.key(), false); if (this.metaClient.getTableConfig().isMetadataTableAvailable()) { @@ -129,7 +128,7 @@ public static void main(String[] args) { SparkAdapterSupport$.MODULE$.sparkAdapter().stopSparkContext(jssc, exitCode); } - LOG.info("Hoodie TTL job ran successfully"); + log.info("Hoodie TTL job ran successfully"); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java index 2f7636bfa5b3d..29eab4b472056 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java @@ -41,12 +41,11 @@ import com.codahale.metrics.Histogram; import com.codahale.metrics.Snapshot; import com.codahale.metrics.UniformReservoir; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import javax.annotation.Nullable; @@ -97,10 +96,10 @@ * --base-path \ * --num-days */ +@Slf4j public class TableSizeStats implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(TableSizeStats.class); // Date formatter for parsing partition dates (example: 2023/5/5/ or 2023-5-5). private static final DateTimeFormatter DATE_FORMATTER = @@ -138,6 +137,7 @@ private TypedProperties readConfigFromFileSystem(JavaSparkContext jsc, Config cf } public static class Config implements Serializable { + @Parameter(names = {"--base-path", "-bp"}, description = "Base path for the table", required = false) public String basePath = null; @@ -241,9 +241,9 @@ public static void main(String[] args) { TableSizeStats tableSizeStats = new TableSizeStats(jsc, cfg); tableSizeStats.run(); } catch (TableNotFoundException e) { - LOG.warn("The Hudi data table is not found: [{}].", cfg.basePath, e); + log.warn("The Hudi data table is not found: [{}].", cfg.basePath, e); } catch (Throwable throwable) { - LOG.error("Failed to get table size stats for {}", cfg, throwable); + log.error("Failed to get table size stats for {}", cfg, throwable); } finally { jsc.stop(); } @@ -251,8 +251,8 @@ public static void main(String[] args) { public void run() { try { - LOG.info(cfg.toString()); - LOG.info(" ****** Fetching table size stats ******"); + log.info(cfg.toString()); + log.info(" ****** Fetching table size stats ******"); // Determine starting and ending date intervals for filtering data files. LocalDate[] dateInterval = getUserSpecifiedDateInterval(cfg); @@ -276,7 +276,7 @@ public void run() { private void logTableStats(String basePath, LocalDate[] dateInterval) throws IOException { - LOG.info("Processing table {}", basePath); + log.info("Processing table {}", basePath); HoodieMetadataConfig metadataConfig = HoodieMetadataConfig.newBuilder() .enable(isMetadataEnabled(basePath, jsc)) .build(); @@ -351,7 +351,7 @@ private void logTableStats(String basePath, LocalDate[] dateInterval) throws IOE logStats("Table stats [path: " + basePath + "]", tableHistogram); } else { // Display only total talbe size - LOG.info("Total size: {}", getFileSizeUnit(Arrays.stream(tableHistogram.getSnapshot().getValues()).sum())); + log.info("Total size: {}", getFileSizeUnit(Arrays.stream(tableHistogram.getSnapshot().getValues()).sum())); } } @@ -378,7 +378,7 @@ private static List getFilePaths(String propsPath, Configuration hadoopC line = reader.readLine(); } } catch (IOException ioe) { - LOG.error("Error reading in properties from dfs from file." + propsPath); + log.error("Error reading in properties from dfs from file. {}", propsPath); throw new HoodieIOException("Cannot read properties from dfs from file " + propsPath, ioe); } return filePaths; @@ -390,12 +390,12 @@ private static LocalDate[] getUserSpecifiedDateInterval(Config cfg) { if (cfg.endDate != null) { try { endDate = LocalDate.parse(cfg.endDate, DATE_FORMATTER); - LOG.info("Setting ending date to {}. ", endDate); + log.info("Setting ending date to {}.", endDate); } catch (DateTimeParseException dtpe) { throw new HoodieException("Unable to parse --end-date. ", dtpe); } } else { - LOG.info("End date is not specified: {}.", endDate); + log.info("End date is not specified: {}.", endDate); } // Set startDate to null by default. @@ -404,14 +404,14 @@ private static LocalDate[] getUserSpecifiedDateInterval(Config cfg) { // Set startDate to cfg.startDate if specified. cfg.startDate takes priority over cfg.numDays if both are specified. if (cfg.startDate != null) { startDate = LocalDate.parse(cfg.startDate, DATE_FORMATTER); - LOG.info("Setting starting date to {}.", startDate); + log.info("Setting starting date to {}.", startDate); } else { if (cfg.numDays == 0) { - LOG.info("Start date not specified: {}.", startDate); + log.info("Start date not specified: {}.", startDate); } else if (cfg.numDays > 0) { endDate = LocalDate.now(); startDate = endDate.minusDays(cfg.numDays); - LOG.info("Setting starting date to {} ({} - {} days). ", startDate, endDate, cfg.numDays); + log.info("Setting starting date to {} ({} - {} days). ", startDate, endDate, cfg.numDays); } else { throw new HoodieException("--num-days must specify a positive value."); } @@ -422,7 +422,7 @@ private static LocalDate[] getUserSpecifiedDateInterval(Config cfg) { throw new HoodieException("Starting date must be before ending date. Start Date: " + startDate + ", End Date: " + endDate); } - return startDate == null && endDate == null ? null : new LocalDate[]{startDate, endDate}; + return startDate == null && endDate == null ? null : new LocalDate[] {startDate, endDate}; } @Nullable @@ -442,7 +442,7 @@ private static LocalDate getPartitionDate(String partition) { try { return LocalDate.parse(dateString, DATE_FORMATTER); } catch (DateTimeParseException dtpe) { - LOG.error("Partition name {} must conform to date format if --start-date, --end-date, or --num-days are specified. ", partition, dtpe); + log.error("Partition name {} must conform to date format if --start-date, --end-date, or --num-days are specified. ", partition, dtpe); } return partitionDate; } @@ -458,17 +458,17 @@ private static String getFileSizeUnit(double size) { } private static void logStats(String header, Histogram histogram) { - LOG.info(header); + log.info(header); Snapshot snapshot = histogram.getSnapshot(); - LOG.info("Number of files: {}", snapshot.size()); - LOG.info("Total size: {}", getFileSizeUnit(Arrays.stream(snapshot.getValues()).sum())); - LOG.info("Minimum file size: {}", getFileSizeUnit(snapshot.getMin())); - LOG.info("Maximum file size: {}", getFileSizeUnit(snapshot.getMax())); - LOG.info("Average file size: {}", getFileSizeUnit(snapshot.getMean())); - LOG.info("Median file size: {}", getFileSizeUnit(snapshot.getMedian())); - LOG.info("P50 file size: {}", getFileSizeUnit(snapshot.getValue(0.5))); - LOG.info("P90 file size: {}", getFileSizeUnit(snapshot.getValue(0.9))); - LOG.info("P95 file size: {}", getFileSizeUnit(snapshot.getValue(0.95))); - LOG.info("P99 file size: {}", getFileSizeUnit(snapshot.getValue(0.99))); + log.info("Number of files: {}", snapshot.size()); + log.info("Total size: {}", getFileSizeUnit(Arrays.stream(snapshot.getValues()).sum())); + log.info("Minimum file size: {}", getFileSizeUnit(snapshot.getMin())); + log.info("Maximum file size: {}", getFileSizeUnit(snapshot.getMax())); + log.info("Average file size: {}", getFileSizeUnit(snapshot.getMean())); + log.info("Median file size: {}", getFileSizeUnit(snapshot.getMedian())); + log.info("P50 file size: {}", getFileSizeUnit(snapshot.getValue(0.5))); + log.info("P90 file size: {}", getFileSizeUnit(snapshot.getValue(0.9))); + log.info("P95 file size: {}", getFileSizeUnit(snapshot.getValue(0.95))); + log.info("P99 file size: {}", getFileSizeUnit(snapshot.getValue(0.99))); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/UtilHelpers.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/UtilHelpers.java index 9d04641f71b49..22bf3f4b4dcb2 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/UtilHelpers.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/UtilHelpers.java @@ -76,6 +76,7 @@ import org.apache.hudi.utilities.transform.ErrorTableAwareChainedTransformer; import org.apache.hudi.utilities.transform.Transformer; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FSDataInputStream; import org.apache.hadoop.fs.FileSystem; @@ -93,8 +94,6 @@ import org.apache.spark.sql.jdbc.JdbcDialects; import org.apache.spark.sql.types.StructType; import org.apache.spark.util.LongAccumulator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; @@ -126,6 +125,7 @@ /** * Bunch of helper methods. */ +@Slf4j public class UtilHelpers { public static final String EXECUTE = "execute"; @@ -133,8 +133,6 @@ public class UtilHelpers { public static final String SCHEDULE_AND_EXECUTE = "scheduleandexecute"; public static final String PURGE_PENDING_INSTANT = "purge_pending_instant"; - private static final Logger LOG = LoggerFactory.getLogger(UtilHelpers.class); - public static HoodieRecordMerger createRecordMerger(Properties props) { return HoodieRecordUtils.createRecordMerger(null, EngineType.SPARK, StringUtils.split(ConfigUtils.getStringWithAltKeys(props, HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES, null), ","), @@ -142,7 +140,7 @@ public static HoodieRecordMerger createRecordMerger(Properties props) { } public static Source createSource(String sourceClass, TypedProperties cfg, JavaSparkContext jssc, - SparkSession sparkSession, HoodieIngestionMetrics metrics, StreamContext streamContext) throws IOException { + SparkSession sparkSession, HoodieIngestionMetrics metrics, StreamContext streamContext) throws IOException { // All possible constructors. Class[] constructorArgsStreamContextMetrics = new Class[] {TypedProperties.class, JavaSparkContext.class, SparkSession.class, HoodieIngestionMetrics.class, StreamContext.class}; Class[] constructorArgsStreamContext = new Class[] {TypedProperties.class, JavaSparkContext.class, SparkSession.class, StreamContext.class}; @@ -168,7 +166,7 @@ public static Source createSource(String sourceClass, TypedProperties cfg, JavaS String constructorSignature = Arrays.stream(constructor.getLeft()) .map(Class::getSimpleName) .collect(Collectors.joining(", ", "[", "]")); - LOG.error("Unexpected error while loading source class {} with constructor signature {}", sourceClass, constructorSignature, e); + log.error("Unexpected error while loading source class {} with constructor signature {}", sourceClass, constructorSignature, e); } catch (Throwable t) { throw new IOException("Could not load source class due to unexpected error " + sourceClass, t); } @@ -197,7 +195,7 @@ public static JsonKafkaSourcePostProcessor createJsonKafkaSourcePostProcessor(St } public static SchemaProvider createSchemaProvider(String schemaProviderClass, TypedProperties cfg, - JavaSparkContext jssc) throws IOException { + JavaSparkContext jssc) throws IOException { try { return StringUtils.isNullOrEmpty(schemaProviderClass) ? null : (SchemaProvider) ReflectionUtils.loadClass(schemaProviderClass, cfg, jssc); @@ -233,7 +231,7 @@ public static StructType getSourceSchema(SchemaProvider schemaProvider) { } public static Option createTransformer(Option> classNamesOpt, Supplier> sourceSchemaSupplier, - boolean isErrorTableWriterEnabled) throws IOException { + boolean isErrorTableWriterEnabled) throws IOException { try { Function, Transformer> chainedTransformerFunction = classNames -> @@ -255,13 +253,13 @@ public static InitialCheckPointProvider createInitialCheckpointProvider( } public static DFSPropertiesConfiguration readConfig(Configuration hadoopConfig, - Path cfgPath, - List overriddenProps) { + Path cfgPath, + List overriddenProps) { StoragePath storagePath = convertToStoragePath(cfgPath); DFSPropertiesConfiguration conf = new DFSPropertiesConfiguration(hadoopConfig, storagePath); try { if (!overriddenProps.isEmpty()) { - LOG.info("Adding overridden properties to file properties."); + log.info("Adding overridden properties to file properties."); conf.addPropsFromStream(new BufferedReader(new StringReader(String.join("\n", overriddenProps))), storagePath); } } catch (IOException ioe) { @@ -275,7 +273,7 @@ public static DFSPropertiesConfiguration getConfig(List overriddenProps) DFSPropertiesConfiguration conf = new DFSPropertiesConfiguration(); try { if (!overriddenProps.isEmpty()) { - LOG.info("Adding overridden properties to file properties."); + log.info("Adding overridden properties to file properties."); conf.addPropsFromStream(new BufferedReader(new StringReader(String.join("\n", overriddenProps))), null); } } catch (IOException ioe) { @@ -289,7 +287,7 @@ public static TypedProperties buildProperties(Configuration hadoopConf, String p return StringUtils.isNullOrEmpty(propsFilePath) ? UtilHelpers.buildProperties(props) : UtilHelpers.readConfig(hadoopConf, new Path(propsFilePath), props) - .getProps(true); + .getProps(true); } public static TypedProperties buildProperties(List props) { @@ -310,7 +308,7 @@ public static void validateAndAddProperties(String[] configs, SparkLauncher spar /** * Parse Schema from file. * - * @param fs File System + * @param fs File System * @param schemaFile Schema File */ public static String parseSchema(FileSystem fs, String schemaFile) throws Exception { @@ -412,9 +410,9 @@ public static JavaSparkContext getJavaSparkContextFromSparkConf(SparkConf sparkC /** * Build Hoodie write client. * - * @param jsc Java Spark Context - * @param basePath Base Path - * @param schemaStr Schema + * @param jsc Java Spark Context + * @param basePath Base Path + * @param schemaStr Schema * @param parallelism Parallelism */ public static SparkRDDWriteClient createHoodieClient(JavaSparkContext jsc, String basePath, String schemaStr, @@ -440,14 +438,14 @@ public static int handleErrors(JavaSparkContext jsc, String instantTime, JavaRDD writeResponse.foreach(writeStatus -> { if (writeStatus.hasErrors()) { errors.add(1); - LOG.error("Error processing records :writeStatus:{}", writeStatus.getStat().toString()); + log.error("Error processing records :writeStatus:{}", writeStatus.getStat().toString()); } }); if (errors.value() == 0) { - LOG.info("Table imported into hoodie with {} instant time.", instantTime); + log.info("Table imported into hoodie with {} instant time.", instantTime); return 0; } - LOG.error("Import failed with {} errors.", errors.value()); + log.error("Import failed with {} errors.", errors.value()); return -1; } @@ -455,11 +453,11 @@ public static int handleErrors(HoodieCommitMetadata metadata, String instantTime List writeStats = metadata.getWriteStats(); long errorsCount = writeStats.stream().mapToLong(HoodieWriteStat::getTotalWriteErrors).sum(); if (errorsCount == 0) { - LOG.info("Finish job with {} instant time.", instantTime); + log.info("Finish job with {} instant time.", instantTime); return 0; } - LOG.error("Job failed with {} errors.", errorsCount); + log.error("Job failed with {} errors.", errorsCount); return -1; } @@ -571,7 +569,7 @@ public static SchemaProvider getOriginalSchemaProvider(SchemaProvider schemaProv } public static SchemaProvider wrapSchemaProviderWithPostProcessor(SchemaProvider provider, - TypedProperties cfg, JavaSparkContext jssc, List transformerClassNames) { + TypedProperties cfg, JavaSparkContext jssc, List transformerClassNames) { if (provider == null) { return null; @@ -601,16 +599,16 @@ public static SchemaProvider getSchemaProviderForKafkaSource(SchemaProvider prov } public static SchemaProvider createRowBasedSchemaProvider(StructType structType, - TypedProperties cfg, - JavaSparkContext jssc) { + TypedProperties cfg, + JavaSparkContext jssc) { SchemaProvider rowSchemaProvider = new RowBasedSchemaProvider(structType); return wrapSchemaProviderWithPostProcessor(rowSchemaProvider, cfg, jssc, null); } public static Option getLatestTableSchema(JavaSparkContext jssc, - HoodieStorage storage, - String basePath, - HoodieTableMetaClient tableMetaClient) { + HoodieStorage storage, + String basePath, + HoodieTableMetaClient tableMetaClient) { try { if (FSUtils.isTableExists(basePath, storage)) { TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(tableMetaClient); @@ -618,7 +616,7 @@ public static Option getLatestTableSchema(JavaSparkContext jssc, return tableSchemaResolver.getTableSchemaFromLatestCommit(false); } } catch (Exception e) { - LOG.warn("Failed to fetch latest table's schema", e); + log.warn("Failed to fetch latest table's schema", e); } return Option.empty(); @@ -655,6 +653,7 @@ public static StructType extractSchemaFromDataset(Dataset dataset, TypedProperti @FunctionalInterface public interface CheckedSupplier { + T get() throws Throwable; } @@ -665,7 +664,7 @@ public static int retry(int maxRetryCount, CheckedSupplier supplier, St ret = supplier.get(); } while (ret != 0 && maxRetryCount-- > 0); } catch (Throwable t) { - LOG.error(errorMessage, t); + log.error(errorMessage, t); throw new RuntimeException("Failed in retry", t); } return ret; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/kafka/HoodieWriteCommitKafkaCallback.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/kafka/HoodieWriteCommitKafkaCallback.java index df466a0a7dd6d..382fe27b5d1d5 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/kafka/HoodieWriteCommitKafkaCallback.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/kafka/HoodieWriteCommitKafkaCallback.java @@ -25,13 +25,12 @@ import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.config.HoodieWriteConfig; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.producer.Callback; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.Properties; @@ -45,10 +44,9 @@ /** * Kafka implementation of {@link HoodieWriteCommitCallback}. */ +@Slf4j public class HoodieWriteCommitKafkaCallback implements HoodieWriteCommitCallback { - private static final Logger LOG = LoggerFactory.getLogger(HoodieWriteCommitKafkaCallback.class); - private final HoodieConfig hoodieConfig; private final String bootstrapServers; private final String topic; @@ -66,9 +64,9 @@ public void call(HoodieWriteCommitCallbackMessage callbackMessage) { try (KafkaProducer producer = createProducer(hoodieConfig)) { ProducerRecord record = buildProducerRecord(hoodieConfig, callbackMsg); producer.send(record).get(); - LOG.info("Send callback message succeed"); + log.info("Send callback message succeed"); } catch (Exception e) { - LOG.error("Send kafka callback msg failed : ", e); + log.error("Send kafka callback msg failed : ", e); } } @@ -94,8 +92,8 @@ public KafkaProducer createProducer(HoodieConfig hoodieConfig) { kafkaProducerProps.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); - LOG.debug("Callback kafka producer init with configs: " - + HoodieWriteCommitCallbackUtil.convertToJsonString(kafkaProducerProps)); + log.debug("Callback kafka producer init with configs: {}", + HoodieWriteCommitCallbackUtil.convertToJsonString(kafkaProducerProps)); return new KafkaProducer(kafkaProducerProps); } @@ -138,11 +136,11 @@ private static class ProducerSendCallback implements Callback { @Override public void onCompletion(RecordMetadata metadata, Exception exception) { if (null != metadata) { - LOG.info("message offset={} partition={} timestamp={} topic={}", + log.info("message offset={} partition={} timestamp={} topic={}", metadata.offset(), metadata.partition(), metadata.timestamp(), metadata.topic()); } if (null != exception) { - LOG.error("Send kafka callback msg failed : ", exception); + log.error("Send kafka callback msg failed: ", exception); } } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/pulsar/HoodieWriteCommitPulsarCallback.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/pulsar/HoodieWriteCommitPulsarCallback.java index af4bbbf49e46f..379aba2d19ecd 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/pulsar/HoodieWriteCommitPulsarCallback.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/callback/pulsar/HoodieWriteCommitPulsarCallback.java @@ -25,6 +25,7 @@ import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.config.HoodieWriteConfig; +import lombok.extern.slf4j.Slf4j; import org.apache.pulsar.client.api.MessageRoutingMode; import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.PulsarClient; @@ -32,8 +33,6 @@ import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.impl.PulsarClientImpl; import org.apache.pulsar.client.impl.conf.ClientConfigurationData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.IOException; @@ -56,10 +55,9 @@ /** * Pulsar implementation of {@link HoodieWriteCommitCallback}. */ +@Slf4j public class HoodieWriteCommitPulsarCallback implements HoodieWriteCommitCallback, Closeable { - private static final Logger LOG = LoggerFactory.getLogger(HoodieWriteCommitPulsarCallback.class); - private final String serviceUrl; private final String topic; @@ -85,9 +83,9 @@ public void call(HoodieWriteCommitCallbackMessage callbackMessage) { String callbackMsg = HoodieWriteCommitCallbackUtil.convertToJsonString(callbackMessage); try { producer.newMessage().key(callbackMessage.getTableName()).value(callbackMsg).send(); - LOG.info("Send callback message succeed"); + log.info("Send callback message succeed"); } catch (Exception e) { - LOG.error("Send pulsar callback msg failed : ", e); + log.error("Send pulsar callback msg failed: ", e); } } @@ -165,7 +163,7 @@ public void close() throws IOException { try { producer.close(); } catch (Throwable t) { - LOG.warn("Could not properly close the producer.", t); + log.warn("Could not properly close the producer.", t); } } @@ -173,7 +171,7 @@ public void close() throws IOException { try { client.close(); } catch (Throwable t) { - LOG.warn("Could not properly close the client.", t); + log.warn("Could not properly close the client.", t); } } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deser/KafkaAvroSchemaDeserializer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deser/KafkaAvroSchemaDeserializer.java index 365a01b604094..b1d0589150c2e 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deser/KafkaAvroSchemaDeserializer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deser/KafkaAvroSchemaDeserializer.java @@ -23,8 +23,10 @@ import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.serializers.KafkaAvroDeserializer; +import lombok.NoArgsConstructor; import org.apache.avro.Schema; import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.header.Headers; import java.util.Map; import java.util.Map.Entry; @@ -35,13 +37,11 @@ /** * Extending {@link KafkaAvroSchemaDeserializer} as we need to be able to inject reader schema during deserialization. */ +@NoArgsConstructor public class KafkaAvroSchemaDeserializer extends KafkaAvroDeserializer { private Schema sourceSchema; - public KafkaAvroSchemaDeserializer() { - } - public KafkaAvroSchemaDeserializer(SchemaRegistryClient client, Map props) { super(client, props); } @@ -58,6 +58,21 @@ public void configure(Map configs, boolean isKey) { } } + @Override + public Object deserialize(String topic, byte[] bytes) { + return this.deserialize(topic, false, bytes, sourceSchema); + } + + @Override + public Object deserialize(String topic, byte[] bytes, Schema readerSchema) { + return this.deserialize(topic, false, bytes, sourceSchema); + } + + @Override + public Object deserialize(String topic, Headers headers, byte[] bytes) { + return super.deserialize(topic, false, bytes, sourceSchema); + } + /** * We need to inject sourceSchema instead of reader schema during deserialization or later stages of the pipeline. * diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionException.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionException.java index 04174f740767d..57f3e7910c8e2 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionException.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionException.java @@ -20,14 +20,14 @@ import org.apache.hudi.exception.HoodieException; +import lombok.NoArgsConstructor; + /** * The root exception class for any failure with {@link HoodieIngestionService}. */ +@NoArgsConstructor public class HoodieIngestionException extends HoodieException { - public HoodieIngestionException() { - } - public HoodieIngestionException(String message) { super(message); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionMetrics.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionMetrics.java index d58e7877f5e3b..44871f8c45fb9 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionMetrics.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionMetrics.java @@ -48,6 +48,10 @@ public HoodieIngestionMetrics(HoodieMetricsConfig writeConfig) { public abstract void updateStreamerMetrics(long durationNanos); + public abstract void emitStreamerJobSuccessMetrics(); + + public abstract void emitStreamerJobFailedMetrics(); + public abstract void updateStreamerMetaSyncMetrics(String syncClassShortName, long syncTimeNanos); public abstract void updateStreamerSyncMetrics(long syncEpochTimeMs); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionService.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionService.java index 5f2c15f090c44..2769d851b107c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionService.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/ingestion/HoodieIngestionService.java @@ -27,8 +27,7 @@ import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.utilities.streamer.PostWriteTerminationStrategy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -40,10 +39,9 @@ /** * A generic service to facilitate running data ingestion. */ +@Slf4j public abstract class HoodieIngestionService extends HoodieAsyncService { - private static final Logger LOG = LoggerFactory.getLogger(HoodieIngestionService.class); - protected HoodieIngestionConfig ingestionConfig; public HoodieIngestionService(HoodieIngestionConfig ingestionConfig) { @@ -59,18 +57,18 @@ public HoodieIngestionService(HoodieIngestionConfig ingestionConfig) { */ public void startIngestion() { if (ingestionConfig.getBoolean(INGESTION_IS_CONTINUOUS)) { - LOG.info("Ingestion service starts running in continuous mode"); + log.info("Ingestion service starts running in continuous mode"); start(this::onIngestionCompletes); try { waitForShutdown(); } catch (Exception e) { throw new HoodieIngestionException("Ingestion service was shut down with exception.", e); } - LOG.info("Ingestion service (continuous mode) has been shut down."); + log.info("Ingestion service (continuous mode) has been shut down."); } else { - LOG.info("Ingestion service starts running in run-once mode"); + log.info("Ingestion service starts running in run-once mode"); ingestOnce(); - LOG.info("Ingestion service (run-once mode) has been shut down."); + log.info("Ingestion service (run-once mode) has been shut down."); } } @@ -121,8 +119,8 @@ protected void sleepBeforeNextIngestion(long ingestionStartEpochMillis) { long minSyncInternalSeconds = ingestionConfig.getLongOrDefault(INGESTION_MIN_SYNC_INTERNAL_SECONDS); long sleepMs = minSyncInternalSeconds * 1000 - (System.currentTimeMillis() - ingestionStartEpochMillis); if (sleepMs > 0) { - LOG.info(String.format("Last ingestion took less than min sync interval: %d s; sleep for %.2f s", - minSyncInternalSeconds, sleepMs / 1000.0)); + log.info("Last ingestion took less than min sync interval: {} s; sleep for {} s", + minSyncInternalSeconds, sleepMs / 1000.0); Thread.sleep(sleepMs); } } catch (InterruptedException e) { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ArchiveTask.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ArchiveTask.java index fe5decd7005c1..03d67dd2d9694 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ArchiveTask.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ArchiveTask.java @@ -25,21 +25,20 @@ import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.utilities.UtilHelpers; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Archive task to run in TableServicePipeline. * * @see HoodieMultiTableServicesMain */ +@Slf4j class ArchiveTask extends TableServiceTask { - private static final Logger LOG = LoggerFactory.getLogger(ArchiveTask.class); @Override void run() { - LOG.info("Run Archive with props: " + props); + log.info("Run Archive with props: {}", props); HoodieWriteConfig hoodieCfg = HoodieWriteConfig.newBuilder().withPath(basePath).withProps(props).build(); try (SparkRDDWriteClient client = new SparkRDDWriteClient<>(new HoodieSparkEngineContext(jsc), hoodieCfg)) { UtilHelpers.retry(retry, () -> { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ClusteringTask.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ClusteringTask.java index 66efbd475dc47..34d585b903d61 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ClusteringTask.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/ClusteringTask.java @@ -23,6 +23,8 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.utilities.HoodieClusteringJob; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import org.apache.spark.api.java.JavaSparkContext; /** @@ -71,6 +73,7 @@ public static Builder newBuilder() { /** * Builder class for {@link ClusteringTask}. */ + @NoArgsConstructor(access = AccessLevel.PRIVATE) public static final class Builder { /** @@ -110,9 +113,6 @@ public static final class Builder { */ private HoodieTableMetaClient metaClient; - private Builder() { - } - public Builder withProps(TypedProperties props) { this.props = props; return this; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/HoodieMultiTableServicesMain.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/HoodieMultiTableServicesMain.java index aade3d0a48546..9d4af77d3f422 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/HoodieMultiTableServicesMain.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/HoodieMultiTableServicesMain.java @@ -28,11 +28,10 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -51,8 +50,9 @@ /** * Main function for executing multi-table services. */ +@Slf4j public class HoodieMultiTableServicesMain { - private static final Logger LOG = LoggerFactory.getLogger(HoodieMultiTableServicesMain.class); + final Config cfg; final TypedProperties props; @@ -107,7 +107,7 @@ public HoodieMultiTableServicesMain(JavaSparkContext jsc, Config cfg) { } public void startServices() throws ExecutionException, InterruptedException { - LOG.info("StartServices Config: " + cfg); + log.info("StartServices Config: {}", cfg); List tablePaths; if (cfg.autoDiscovery) { // We support defining multi base paths @@ -118,7 +118,7 @@ public void startServices() throws ExecutionException, InterruptedException { } else { tablePaths = MultiTableServiceUtils.getTablesToBeServedFromProps(jsc, props); } - LOG.info("All table paths: " + String.join(",", tablePaths)); + log.info("All table paths: {}", String.join(",", tablePaths)); if (cfg.batch) { batchRunTableServices(tablePaths); } else { @@ -253,7 +253,7 @@ public static void main(String[] args) { try { new HoodieMultiTableServicesMain(jsc, cfg).startServices(); } catch (Throwable throwable) { - LOG.error("Fail to run table services, ", throwable); + log.error("Fail to run table services, ", throwable); } finally { jsc.stop(); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/MultiTableServiceUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/MultiTableServiceUtils.java index 41bd7248f0f3b..3c1f1453f54eb 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/MultiTableServiceUtils.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/multitable/MultiTableServiceUtils.java @@ -29,13 +29,12 @@ import org.apache.hudi.storage.StorageConfiguration; import org.apache.hudi.utilities.UtilHelpers; +import lombok.extern.slf4j.Slf4j; 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.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; @@ -49,8 +48,8 @@ /** * Utils for executing multi-table services. */ +@Slf4j public class MultiTableServiceUtils { - private static final Logger LOG = LoggerFactory.getLogger(MultiTableServiceUtils.class); public static class Constants { public static final String TABLES_TO_BE_SERVED_PROP = "hoodie.tableservice.tablesToServe"; @@ -79,7 +78,7 @@ public static List getTablesToBeServedFromProps(JavaSparkContext jsc, Ty return true; } else { // Log the wrong path in console. - LOG.info("Hoodie table not found in path {}, skip", tablePath); + log.info("Hoodie table not found in path {}, skip", tablePath); return false; } }).collect(Collectors.toList()); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/perf/TimelineServerPerf.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/perf/TimelineServerPerf.java index 330eece72e9ab..39efad86c3d35 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/perf/TimelineServerPerf.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/perf/TimelineServerPerf.java @@ -40,10 +40,9 @@ import com.codahale.metrics.Histogram; import com.codahale.metrics.Snapshot; import com.codahale.metrics.UniformReservoir; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.OutputStream; @@ -62,10 +61,10 @@ import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes; +@Slf4j public class TimelineServerPerf implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(TimelineServerPerf.class); private final Config cfg; private transient TimelineService timelineServer; private final boolean useExternalTimelineServer; @@ -84,10 +83,10 @@ public TimelineServerPerf(Config cfg) { private void setHostAddrFromSparkConf(SparkConf sparkConf) { String hostAddr = sparkConf.get("spark.driver.host", null); if (hostAddr != null) { - LOG.info("Overriding hostIp to ({}) found in spark-conf. It was {}", hostAddr, this.hostAddr); + log.info("Overriding hostIp to ({}) found in spark-conf. It was {}", hostAddr, this.hostAddr); this.hostAddr = hostAddr; } else { - LOG.warn("Unable to find driver bind address from spark config"); + log.warn("Unable to find driver bind address from spark config"); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/DelegatingSchemaProvider.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/DelegatingSchemaProvider.java index 5662c46169ce1..dd7924a62a567 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/DelegatingSchemaProvider.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/DelegatingSchemaProvider.java @@ -21,11 +21,13 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.schema.HoodieSchema; +import lombok.Getter; import org.apache.spark.api.java.JavaSparkContext; /** * SchemaProvider which uses separate Schema Providers for source and target. */ +@Getter public final class DelegatingSchemaProvider extends SchemaProvider { private final SchemaProvider sourceSchemaProvider; @@ -48,12 +50,4 @@ public HoodieSchema getSourceHoodieSchema() { public HoodieSchema getTargetHoodieSchema() { return targetSchemaProvider.getTargetHoodieSchema(); } - - public SchemaProvider getSourceSchemaProvider() { - return sourceSchemaProvider; - } - - public SchemaProvider getTargetSchemaProvider() { - return targetSchemaProvider; - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/FilebasedSchemaProvider.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/FilebasedSchemaProvider.java index 23c9e76c67e17..542c983ba1677 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/FilebasedSchemaProvider.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/FilebasedSchemaProvider.java @@ -31,6 +31,7 @@ import io.confluent.kafka.schemaregistry.ParsedSchema; import io.confluent.kafka.schemaregistry.json.JsonSchema; +import lombok.Getter; import org.apache.avro.Schema; import org.apache.hadoop.fs.FSDataInputStream; import org.apache.hadoop.fs.FileSystem; @@ -54,6 +55,7 @@ public class FilebasedSchemaProvider extends SchemaProvider { private final String sourceFile; private final String targetFile; + @Getter protected Schema sourceSchema; protected Schema targetSchema; @@ -74,11 +76,6 @@ private Schema parseSchema(String schemaFile) { return readSchemaFromFile(schemaFile, this.fs, config); } - @Override - public Schema getSourceSchema() { - return sourceSchema; - } - @Override public Schema getTargetSchema() { if (targetSchema != null) { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/HiveSchemaProvider.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/HiveSchemaProvider.java index 6c58fb2739dc4..7ec566b33db3c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/HiveSchemaProvider.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/HiveSchemaProvider.java @@ -25,6 +25,7 @@ import org.apache.hudi.utilities.config.HiveSchemaProviderConfig; import org.apache.hudi.utilities.exception.HoodieSchemaFetchException; +import lombok.Getter; import org.apache.avro.Schema; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.SparkSession; @@ -42,10 +43,11 @@ /** * A schema provider to get data schema through user specified hive table. */ +@Getter public class HiveSchemaProvider extends SchemaProvider { - private final HoodieSchema sourceSchema; - private HoodieSchema targetSchema; + private final HoodieSchema sourceHoodieSchema; + private HoodieSchema targetHoodieSchema; public HiveSchemaProvider(TypedProperties props, JavaSparkContext jssc) { super(props, jssc); @@ -58,7 +60,7 @@ public HiveSchemaProvider(TypedProperties props, JavaSparkContext jssc) { try { TableIdentifier sourceSchemaTable = new TableIdentifier(sourceSchemaTableName, scala.Option.apply(sourceSchemaDatabaseName)); StructType sourceSchema = spark.sessionState().catalog().getTableMetadata(sourceSchemaTable).schema(); - this.sourceSchema = HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema( + this.sourceHoodieSchema = HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema( sourceSchema, sourceSchemaTableName, "hoodie." + sourceSchemaDatabaseName); @@ -73,7 +75,7 @@ public HiveSchemaProvider(TypedProperties props, JavaSparkContext jssc) { try { TableIdentifier targetSchemaTable = new TableIdentifier(targetSchemaTableName, scala.Option.apply(targetSchemaDatabaseName)); StructType targetSchema = spark.sessionState().catalog().getTableMetadata(targetSchemaTable).schema(); - this.targetSchema = HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema( + this.targetHoodieSchema = HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema( targetSchema, targetSchemaTableName, "hoodie." + targetSchemaDatabaseName); @@ -84,14 +86,16 @@ public HiveSchemaProvider(TypedProperties props, JavaSparkContext jssc) { } @Override + @Deprecated public Schema getSourceSchema() { - return sourceSchema.toAvroSchema(); + return getSourceHoodieSchema().toAvroSchema(); } @Override + @Deprecated public Schema getTargetSchema() { - if (targetSchema != null) { - return targetSchema.toAvroSchema(); + if (getTargetHoodieSchema() != null) { + return getTargetHoodieSchema().toAvroSchema(); } else { return super.getTargetSchema(); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SchemaRegistryProvider.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SchemaRegistryProvider.java index b23f7d24fbc1f..159634c8818d0 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SchemaRegistryProvider.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SchemaRegistryProvider.java @@ -40,13 +40,12 @@ import io.confluent.kafka.schemaregistry.json.JsonSchemaProvider; import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchema; import io.confluent.kafka.schemaregistry.protobuf.ProtobufSchemaProvider; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.http.conn.ssl.TrustSelfSignedStrategy; import org.apache.http.ssl.SSLContextBuilder; import org.apache.http.ssl.SSLContexts; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; @@ -79,8 +78,9 @@ *

    * https://github.com/confluentinc/schema-registry */ +@Slf4j public class SchemaRegistryProvider extends SchemaProvider { - private static final Logger LOG = LoggerFactory.getLogger(SchemaRegistryProvider.class); + private static final Pattern URL_PATTERN = Pattern.compile("(.*/)subjects/(.*)/versions/(.*)"); private static final String LATEST = "latest"; @@ -195,7 +195,7 @@ public String fetchSchemaFromRegistry(String registryUrl) { } catch (IllegalAccessError error) { // If we're not processing Protobuf schema, fall back to the legacy method if (!ProtobufSchema.TYPE.equalsIgnoreCase(schemaType)) { - LOG.warn("Falling back to legacy schema retrieval due to IllegalAccessError", error); + log.warn("Falling back to legacy schema retrieval due to IllegalAccessError", error); return fetchSchemaUsingLegacyMethod(registryUrl); } // Otherwise, rethrow the error diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SimpleSchemaProvider.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SimpleSchemaProvider.java index 854486e0343b5..c9698feb9ad8d 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SimpleSchemaProvider.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/SimpleSchemaProvider.java @@ -21,9 +21,11 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.schema.HoodieSchema; +import lombok.Getter; import org.apache.avro.Schema; import org.apache.spark.api.java.JavaSparkContext; +@Getter public class SimpleSchemaProvider extends SchemaProvider { private final Schema sourceSchema; @@ -32,9 +34,4 @@ public SimpleSchemaProvider(JavaSparkContext jssc, HoodieSchema sourceSchema, Ty super(props, jssc); this.sourceSchema = sourceSchema.toAvroSchema(); } - - @Override - public Schema getSourceSchema() { - return sourceSchema; - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DeleteSupportSchemaPostProcessor.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DeleteSupportSchemaPostProcessor.java index acc9ad78cb2de..cb2079a9d25c0 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DeleteSupportSchemaPostProcessor.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DeleteSupportSchemaPostProcessor.java @@ -26,10 +26,9 @@ import org.apache.hudi.common.schema.HoodieSchemaUtils; import org.apache.hudi.utilities.schema.SchemaPostProcessor; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -38,10 +37,9 @@ * An implementation of {@link SchemaPostProcessor} which will add a column named "_hoodie_is_deleted" to the end of * a given schema. */ +@Slf4j public class DeleteSupportSchemaPostProcessor extends SchemaPostProcessor { - private static final Logger LOG = LoggerFactory.getLogger(DeleteSupportSchemaPostProcessor.class); - public DeleteSupportSchemaPostProcessor(TypedProperties props, JavaSparkContext jssc) { super(props, jssc); } @@ -55,7 +53,7 @@ public Schema processSchema(Schema schema) { @Override public HoodieSchema processSchema(HoodieSchema schema) { if (schema.getField(HoodieRecord.HOODIE_IS_DELETED_FIELD).isPresent()) { - LOG.warn("column {} already exists!", HoodieRecord.HOODIE_IS_DELETED_FIELD); + log.warn("column {} already exists!", HoodieRecord.HOODIE_IS_DELETED_FIELD); return schema; } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DropColumnSchemaPostProcessor.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DropColumnSchemaPostProcessor.java index 703dea14544fb..bb3de9344ea7e 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DropColumnSchemaPostProcessor.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/schema/postprocessor/DropColumnSchemaPostProcessor.java @@ -27,10 +27,9 @@ import org.apache.hudi.utilities.exception.HoodieSchemaPostProcessException; import org.apache.hudi.utilities.schema.SchemaPostProcessor; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.LinkedList; @@ -49,10 +48,9 @@ *

    * properties.put("hoodie.streamer.schemaprovider.schema_post_processor.delete.columns", "column1,column2"). */ +@Slf4j public class DropColumnSchemaPostProcessor extends SchemaPostProcessor { - private static final Logger LOG = LoggerFactory.getLogger(DropColumnSchemaPostProcessor.class); - public DropColumnSchemaPostProcessor(TypedProperties props, JavaSparkContext jssc) { super(props, jssc); } @@ -77,7 +75,7 @@ public HoodieSchema processSchema(HoodieSchema schema) { this.config, SchemaProviderPostProcessorConfig.DELETE_COLUMN_POST_PROCESSOR_COLUMN); if (StringUtils.isNullOrEmpty(columnToDeleteStr)) { - LOG.warn("Param {} is null or empty, return original schema", + log.warn("Param {} is null or empty, return original schema", SchemaProviderPostProcessorConfig.DELETE_COLUMN_POST_PROCESSOR_COLUMN.key()); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/GcsEventsSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/GcsEventsSource.java index 2625c7d1da492..a7e699db4e905 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/GcsEventsSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/GcsEventsSource.java @@ -20,7 +20,6 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieException; @@ -45,6 +44,7 @@ import java.util.ArrayList; import java.util.List; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getIntWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; @@ -112,7 +112,7 @@ public class GcsEventsSource extends RowSource { private final List messagesToAck = new ArrayList<>(); - private static final Checkpoint CHECKPOINT_VALUE_ZERO = new StreamerCheckpointV2("0"); + private static final String CHECKPOINT_VALUE_ZERO = "0"; public GcsEventsSource(TypedProperties props, JavaSparkContext jsc, SparkSession spark, SchemaProvider schemaProvider) { @@ -153,7 +153,7 @@ protected Pair>, Checkpoint> fetchNextBatch(Option>, Checkpoint> fetchNextBatch(Option findCommitToPull(Option latestTargetCommi if (!latestTargetCommit.isPresent()) { // start from the beginning - return Option.of(new StreamerCheckpointV2(commitTimes.get(0))); + return Option.of(createCheckpoint(commitTimes.get(0))); } for (String instantTime : commitTimes) { // TODO(vc): Add an option to delete consumed commits if (instantTime.compareTo(latestTargetCommit.get().getCheckpointKey()) > 0) { - return Option.of(new StreamerCheckpointV2(instantTime)); + return Option.of(createCheckpoint(instantTime)); } } return Option.empty(); @@ -123,7 +123,8 @@ protected InputBatch> readFromCheckpoint(Option commitToPull = findCommitToPull(lastCheckpoint); if (!commitToPull.isPresent()) { - return new InputBatch<>(Option.empty(), lastCheckpoint.isPresent() ? lastCheckpoint.get() : new StreamerCheckpointV2("")); + return new InputBatch<>(Option.empty(), + lastCheckpoint.isPresent() ? createCheckpoint(lastCheckpoint.get()) : createCheckpoint("")); } // read the files out. @@ -133,7 +134,7 @@ protected InputBatch> readFromCheckpoint(Option(Option.of(avroRDD.keys().map(r -> ((GenericRecord) r.datum()))), - String.valueOf(commitToPull.get())); + createCheckpoint(String.valueOf(commitToPull.get()))); } catch (Exception e) { throw new HoodieReadFromSourceException("Unable to read from source from checkpoint: " + lastCheckpoint, e); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/HoodieIncrSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/HoodieIncrSource.java index c09196d37c480..937219d1721c0 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/HoodieIncrSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/HoodieIncrSource.java @@ -25,7 +25,6 @@ import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.CheckpointUtils; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.table.checkpoint.UnresolvedStreamerCheckpointBasedOnCfg; @@ -49,6 +48,7 @@ import org.apache.hudi.utilities.streamer.SourceProfile; import org.apache.hudi.utilities.streamer.SourceProfileSupplier; import org.apache.hudi.utilities.streamer.StreamContext; +import org.apache.hudi.utilities.streamer.StreamerCheckpointUtils; import lombok.extern.slf4j.Slf4j; import org.apache.spark.api.java.JavaSparkContext; @@ -203,7 +203,7 @@ public Pair>, Checkpoint> fetchNextBatch(Option String srcPath = getStringWithAltKeys(props, HoodieIncrSourceConfig.HOODIE_SRC_BASE_PATH); HoodieTableVersion sourceTableVersion = HoodieTableConfig.loadFromHoodieProps( HoodieStorageUtils.getStorage(srcPath, HadoopFSUtils.getStorageConf(sparkContext.hadoopConfiguration())), srcPath).getTableVersion(); - if (sourceTableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) && CheckpointUtils.shouldTargetCheckpointV2(writeTableVersion, getClass().getName())) { + if (sourceTableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT) && StreamerCheckpointUtils.shouldTargetCheckpointV2(writeTableVersion, getClass().getName())) { return fetchNextBatchBasedOnCompletionTime(lastCheckpoint, sourceLimit); } else { return fetchNextBatchBasedOnRequestedTime(lastCheckpoint, sourceLimit); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java index 9717ba726866b..94b5c22afe7e6 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java @@ -21,7 +21,6 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.schema.HoodieSchema; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.utilities.schema.SchemaProvider; @@ -40,14 +39,6 @@ public class InputBatch { @Getter(AccessLevel.NONE) private final SchemaProvider schemaProvider; - public InputBatch(Option batch, String checkpointForNextBatch, SchemaProvider schemaProvider) { - this(batch, new StreamerCheckpointV2(checkpointForNextBatch), schemaProvider); - } - - public InputBatch(Option batch, String checkpointForNextBatch) { - this(batch, checkpointForNextBatch, null); - } - public InputBatch(Option batch, Checkpoint checkpointForNextBatch) { this(batch, checkpointForNextBatch, null); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JdbcSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JdbcSource.java index b236b9587a4bc..0f5cd6fa68e9b 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JdbcSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/JdbcSource.java @@ -21,7 +21,6 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; @@ -52,6 +51,7 @@ import java.util.List; import java.util.Set; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.checkRequiredConfigProperties; import static org.apache.hudi.common.util.ConfigUtils.containsConfigProperty; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; @@ -263,12 +263,12 @@ private Checkpoint checkpoint(Dataset rowDataset, boolean isIncremental, Op final String max = rowDataset.agg(functions.max(incrementalColumn).cast(DataTypes.StringType)).first().getString(0); log.info("Checkpointing column {} with value: {}", incrementalColumn, max); if (max != null) { - return new StreamerCheckpointV2(max); + return createCheckpoint(max); } return lastCheckpoint.isPresent() && !StringUtils.isNullOrEmpty(lastCheckpoint.get().getCheckpointKey()) - ? lastCheckpoint.get() : new StreamerCheckpointV2(StringUtils.EMPTY_STRING); + ? createCheckpoint(lastCheckpoint.get()) : createCheckpoint(StringUtils.EMPTY_STRING); } else { - return new StreamerCheckpointV2(StringUtils.EMPTY_STRING); + return createCheckpoint(StringUtils.EMPTY_STRING); } } catch (Exception e) { log.error("Failed to checkpoint"); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KafkaSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KafkaSource.java index 943be5f30a9dc..215a9c5b88333 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KafkaSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KafkaSource.java @@ -49,6 +49,7 @@ import java.util.HashMap; import java.util.Map; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getLongWithAltKeys; @@ -130,11 +131,13 @@ private InputBatch toInputBatch(OffsetRange[] offsetRanges) { totalNewMsgs, offsetGen.getTopicName(), Arrays.toString(offsetRanges)); if (totalNewMsgs <= 0) { metrics.updateStreamerSourceNewMessageCount(METRIC_NAME_KAFKA_MESSAGE_IN_COUNT, 0); - return new InputBatch<>(Option.empty(), KafkaOffsetGen.CheckpointUtils.offsetsToStr(offsetRanges)); + return new InputBatch<>( + Option.empty(), createCheckpoint(KafkaOffsetGen.CheckpointUtils.offsetsToStr(offsetRanges))); } metrics.updateStreamerSourceNewMessageCount(METRIC_NAME_KAFKA_MESSAGE_IN_COUNT, totalNewMsgs); T newBatch = toBatch(offsetRanges); - return new InputBatch<>(Option.of(newBatch), KafkaOffsetGen.CheckpointUtils.offsetsToStr(offsetRanges)); + return new InputBatch<>( + Option.of(newBatch), createCheckpoint(KafkaOffsetGen.CheckpointUtils.offsetsToStr(offsetRanges))); } /** diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KinesisSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KinesisSource.java index a5ec35994d8e0..91b52567fb077 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KinesisSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/KinesisSource.java @@ -51,6 +51,7 @@ import java.util.NoSuchElementException; import java.util.concurrent.ThreadLocalRandom; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; @Slf4j @@ -97,7 +98,7 @@ protected InputBatch readFromCheckpoint(Option lastCheckpoint, lo if (shardRangesWithUnreadRecords.length == 0) { metrics.updateStreamerSourceNewMessageCount(METRIC_NAME_KINESIS_MESSAGE_IN_COUNT, 0); String checkpointStr = lastCheckpoint.isPresent() ? lastCheckpoint.get().getCheckpointKey() : ""; - return new InputBatch<>(Option.empty(), checkpointStr); + return new InputBatch<>(Option.empty(), createCheckpoint(checkpointStr)); } // STEP 3: Otherwise, do the read. T batch = toBatch(shardRangesWithUnreadRecords, sourceLimit); @@ -111,7 +112,7 @@ protected InputBatch readFromCheckpoint(Option lastCheckpoint, lo log.info("Read {} records from Kinesis stream {} with {} shards, checkpoint: {}", totalMsgs, offsetGen.getStreamName(), shardRangesWithUnreadRecords.length, checkpointStr); - return new InputBatch<>(Option.of(batch), checkpointStr); + return new InputBatch<>(Option.of(batch), createCheckpoint(checkpointStr)); } /** Upper bound on consecutive empty GetRecords responses before giving up on a shard. */ diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/PulsarSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/PulsarSource.java index d7696efdbf6c9..cf6cd4093438d 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/PulsarSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/PulsarSource.java @@ -21,7 +21,6 @@ import org.apache.hudi.HoodieConversionUtils; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieException; @@ -54,6 +53,7 @@ import java.util.Collections; import java.util.concurrent.TimeUnit; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.checkRequiredConfigProperties; import static org.apache.hudi.common.util.ConfigUtils.getLongWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; @@ -130,7 +130,7 @@ protected Pair>, Checkpoint> fetchNextBatch(Option processedMessages = new ArrayList<>(); @@ -109,6 +113,8 @@ public void close() throws IOException { @Override public void onCommit(String lastCkptStr) { + LOG.info("Deleting {} processed messages from SQS queue, checkpoint={}.", + processedMessages.size(), lastCkptStr); pathSelector.deleteProcessedMessages(sqs, pathSelector.queueUrl, processedMessages); processedMessages.clear(); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/Source.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/Source.java index 9313fe508b5e4..de2ce192401ea 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/Source.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/Source.java @@ -23,12 +23,12 @@ import org.apache.hudi.PublicAPIMethod; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.CheckpointUtils; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.ConfigUtils; import org.apache.hudi.common.util.Either; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.VisibleForTesting; import org.apache.hudi.utilities.callback.SourceCommitCallback; import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.streamer.DefaultStreamContext; @@ -47,7 +47,6 @@ import java.io.Serializable; -import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.shouldTargetCheckpointV2; import static org.apache.hudi.config.HoodieErrorTableConfig.ERROR_TABLE_PERSIST_SOURCE_RDD; import static org.apache.hudi.config.HoodieWriteConfig.TAGGED_RECORD_STORAGE_LEVEL_VALUE; import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; @@ -121,57 +120,24 @@ protected InputBatch readFromCheckpoint(Option lastCheckpoint, lo * After the checkpoint value is decided based on the existing configurations at * org.apache.hudi.utilities.streamer.StreamerCheckpointUtils#resolveWhatCheckpointToResume, * - * For most of the data sources the there is no difference between checkpoint V1 and V2, it's - * merely changing the wrapper class. + * Non-incremental sources always operate on V1 checkpoints regardless of the write table version, + * so any V2 input read from older commit metadata is normalized to V1 here. * - * Check child class method overrides to see special case handling. + * Hudi incremental sources have their own V1/V2 semantics (requested time vs completion time) + * and override this method. * */ @PublicAPIMethod(maturity = ApiMaturityLevel.EVOLVING) protected Option translateCheckpoint(Option lastCheckpoint) { if (lastCheckpoint.isEmpty()) { return Option.empty(); } - if (CheckpointUtils.shouldTargetCheckpointV2(writeTableVersion, getClass().getName())) { - // V2 -> V2 - if (lastCheckpoint.get() instanceof StreamerCheckpointV2) { - return lastCheckpoint; - } - // V1 -> V2 - if (lastCheckpoint.get() instanceof StreamerCheckpointV1) { - StreamerCheckpointV2 newCheckpoint = new StreamerCheckpointV2(lastCheckpoint.get()); - newCheckpoint.addV1Props(); - return Option.of(newCheckpoint); - } - } else { - // V2 -> V1 - if (lastCheckpoint.get() instanceof StreamerCheckpointV2) { - return Option.of(new StreamerCheckpointV1(lastCheckpoint.get())); - } - // V1 -> V1 - if (lastCheckpoint.get() instanceof StreamerCheckpointV1) { - return lastCheckpoint; - } + if (lastCheckpoint.get() instanceof StreamerCheckpointV1) { + return lastCheckpoint; } - throw new UnsupportedOperationException("Unsupported checkpoint type: " + lastCheckpoint.get()); - } - - public void assertCheckpointVersion(Option lastCheckpoint, Option lastCheckpointTranslated, Checkpoint checkpoint) { - if (checkpoint != null) { - boolean shouldBeV2Checkpoint = shouldTargetCheckpointV2(writeTableVersion, getClass().getName()); - String errorMessage = String.format( - "Data source should return checkpoint version V%s. The checkpoint resumed in the iteration is %s, whose translated version is %s. " - + "The checkpoint returned after the iteration %s.", - shouldBeV2Checkpoint ? "2" : "1", - lastCheckpoint.isEmpty() ? "null" : lastCheckpointTranslated.get(), - lastCheckpointTranslated.isEmpty() ? "null" : lastCheckpointTranslated.get(), - checkpoint); - if (shouldBeV2Checkpoint && !(checkpoint instanceof StreamerCheckpointV2)) { - throw new IllegalStateException(errorMessage); - } - if (!shouldBeV2Checkpoint && !(checkpoint instanceof StreamerCheckpointV1)) { - throw new IllegalStateException(errorMessage); - } + if (lastCheckpoint.get() instanceof StreamerCheckpointV2) { + return Option.of(new StreamerCheckpointV1(lastCheckpoint.get())); } + throw new UnsupportedOperationException("Unsupported checkpoint type: " + lastCheckpoint.get()); } /** @@ -207,6 +173,17 @@ private synchronized void persist(T data) { @Override public void releaseResources() { + // Cleanup runs after the write/commit; a transient Spark failure while unpersisting + // must not fail an already-successful round. + try { + unpersistCachedSourceRdd(); + } catch (Exception e) { + log.warn("Failed to unpersist cached source RDD during releaseResources; ignoring", e); + } + } + + @VisibleForTesting + protected void unpersistCachedSourceRdd() { if (cachedSourceRdd != null && cachedSourceRdd.isLeft()) { cachedSourceRdd.asLeft().unpersist(); } else if (cachedSourceRdd != null && cachedSourceRdd.isRight()) { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlFileBasedSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlFileBasedSource.java index 60cac125a0293..5ca569136e7ce 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlFileBasedSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlFileBasedSource.java @@ -20,11 +20,11 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.hadoop.fs.HadoopFSUtils; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; import org.apache.hudi.utilities.schema.SchemaProvider; import lombok.extern.slf4j.Slf4j; @@ -39,6 +39,7 @@ import java.util.Collections; import java.util.Scanner; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.checkRequiredConfigProperties; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; @@ -65,16 +66,19 @@ public class SqlFileBasedSource extends RowSource { private final String sourceSqlFile; private final boolean shouldEmitCheckPoint; + private HoodieIngestionMetrics metrics; public SqlFileBasedSource( TypedProperties props, JavaSparkContext sparkContext, SparkSession sparkSession, - SchemaProvider schemaProvider) { + SchemaProvider schemaProvider, + HoodieIngestionMetrics metrics) { super(props, sparkContext, sparkSession, schemaProvider); checkRequiredConfigProperties(props, Collections.singletonList(SOURCE_SQL_FILE)); sourceSqlFile = getStringWithAltKeys(props, SOURCE_SQL_FILE); shouldEmitCheckPoint = getBooleanWithAltKeys(props, EMIT_EPOCH_CHECKPOINT); + this.metrics = metrics; } @Override @@ -92,7 +96,7 @@ protected Pair>, Checkpoint> fetchNextBatch( rows = sparkSession.sql(sqlStr); } } - return Pair.of(Option.of(rows), shouldEmitCheckPoint ? new StreamerCheckpointV2(String.valueOf(System.currentTimeMillis())) : null); + return Pair.of(Option.of(rows), shouldEmitCheckPoint ? createCheckpoint(String.valueOf(System.currentTimeMillis())) : null); } catch (IOException ioe) { throw new HoodieIOException("Error reading source SQL file.", ioe); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlSource.java index a345e937003f1..21f1230db4697 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/SqlSource.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.utilities.config.SqlSourceConfig; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; import org.apache.hudi.utilities.schema.SchemaProvider; import lombok.extern.slf4j.Slf4j; @@ -61,17 +62,20 @@ public class SqlSource extends RowSource { private static final long serialVersionUID = 1L; private final String sourceSql; private final SparkSession spark; + private final HoodieIngestionMetrics metrics; public SqlSource( TypedProperties props, JavaSparkContext sparkContext, SparkSession sparkSession, - SchemaProvider schemaProvider) { + SchemaProvider schemaProvider, + HoodieIngestionMetrics metrics) { super(props, sparkContext, sparkSession, schemaProvider); checkRequiredConfigProperties( props, Collections.singletonList(SqlSourceConfig.SOURCE_SQL)); - sourceSql = getStringWithAltKeys(props, SqlSourceConfig.SOURCE_SQL); - spark = sparkSession; + this.sourceSql = getStringWithAltKeys(props, SqlSourceConfig.SOURCE_SQL); + this.spark = sparkSession; + this.metrics = metrics; } @Override diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/debezium/DebeziumSource.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/debezium/DebeziumSource.java index 030f5e0e671b7..d45cac3922ff9 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/debezium/DebeziumSource.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/debezium/DebeziumSource.java @@ -21,7 +21,6 @@ import org.apache.hudi.AvroConversionUtils; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.utilities.config.HoodieSchemaProviderConfig; @@ -59,6 +58,7 @@ import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; import static org.apache.hudi.utilities.config.KafkaSourceConfig.KAFKA_AVRO_VALUE_DESERIALIZER_CLASS; @@ -128,7 +128,8 @@ protected Pair>, Checkpoint> fetchNextBatch(Option[] {TypedProperties.class, Configuration.class}, props, conf); - log.info("Using path selector " + selector.getClass().getName()); + log.info("Using path selector {}", selector.getClass().getName()); return selector; } catch (Exception e) { throw new HoodieException("Could not load source selector class " + sourceSelectorClass, e); @@ -125,8 +125,7 @@ public Pair, Checkpoint> getNextFilePathsAndMaxModificationTime(O long sourceLimit) { try { // obtain all eligible files under root folder. - log.info("Root path => " + getStringWithAltKeys(props, DFSPathSelectorConfig.ROOT_INPUT_PATH) - + " source limit => " + sourceLimit); + log.info("Root path => {} source limit => {}", getStringWithAltKeys(props, DFSPathSelectorConfig.ROOT_INPUT_PATH), sourceLimit); long lastCheckpointTime = lastCheckpointStr.map(e -> Long.parseLong(e.getCheckpointKey())).orElse(Long.MIN_VALUE); List eligibleFiles = listEligibleFiles( fs, new Path(getStringWithAltKeys(props, DFSPathSelectorConfig.ROOT_INPUT_PATH)), lastCheckpointTime); @@ -151,13 +150,13 @@ public Pair, Checkpoint> getNextFilePathsAndMaxModificationTime(O // no data to read if (filteredFiles.isEmpty()) { - return new ImmutablePair<>(Option.empty(), new StreamerCheckpointV2(String.valueOf(newCheckpointTime))); + return new ImmutablePair<>(Option.empty(), createCheckpoint(String.valueOf(newCheckpointTime))); } // read the files out. String pathStr = filteredFiles.stream().map(f -> f.getPath().toString()).collect(Collectors.joining(",")); - return new ImmutablePair<>(Option.ofNullable(pathStr), new StreamerCheckpointV2(String.valueOf(newCheckpointTime))); + return new ImmutablePair<>(Option.ofNullable(pathStr), createCheckpoint(String.valueOf(newCheckpointTime))); } catch (IOException ioe) { throw new HoodieIOException("Unable to read from source from checkpoint: " + lastCheckpointStr, ioe); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/DatePartitionPathSelector.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/DatePartitionPathSelector.java index 989be9163da15..0892ca4d35648 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/DatePartitionPathSelector.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/DatePartitionPathSelector.java @@ -21,7 +21,6 @@ import org.apache.hudi.client.common.HoodieSparkEngineContext; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.ImmutablePair; @@ -44,6 +43,7 @@ import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getIntWithAltKeys; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; import static org.apache.hudi.utilities.config.DFSPathSelectorConfig.ROOT_INPUT_PATH; @@ -159,13 +159,13 @@ public Pair, Checkpoint> getNextFilePathsAndMaxModificationTime(J // no data to read if (filteredFiles.isEmpty()) { - return new ImmutablePair<>(Option.empty(), new StreamerCheckpointV2(String.valueOf(newCheckpointTime))); + return new ImmutablePair<>(Option.empty(), createCheckpoint(String.valueOf(newCheckpointTime))); } // read the files out. String pathStr = filteredFiles.stream().map(f -> f.getPath().toString()).collect(Collectors.joining(",")); - return new ImmutablePair<>(Option.ofNullable(pathStr), new StreamerCheckpointV2(String.valueOf(newCheckpointTime))); + return new ImmutablePair<>(Option.ofNullable(pathStr), createCheckpoint(String.valueOf(newCheckpointTime))); } /** diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/S3EventsMetaSelector.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/S3EventsMetaSelector.java index 3cd073e721095..e3ec3bdc57f86 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/S3EventsMetaSelector.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/S3EventsMetaSelector.java @@ -20,7 +20,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.table.checkpoint.CheckpointUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.collection.ImmutablePair; @@ -42,6 +42,7 @@ import java.util.List; import java.util.Map; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys; /** @@ -72,7 +73,7 @@ public static S3EventsMetaSelector createSourceSelector(TypedProperties props) { ReflectionUtils.loadClass( sourceSelectorClass, new Class[] {TypedProperties.class}, props); - log.info("Using path selector " + selector.getClass().getName()); + log.info("Using path selector {}", selector.getClass().getName()); return selector; } catch (Exception e) { throw new HoodieException("Could not load source selector class " + sourceSelectorClass, e); @@ -155,8 +156,10 @@ public Pair, Checkpoint> getNextEventsFromQueue(SqsClient sqs, for (Map eventRecord : eventRecords) { filteredEventRecords.add(SdkHttpUtils.urlDecode(MAPPER.writeValueAsString(eventRecord))); } - // Return the old checkpoint if no messages to consume from queue. - Checkpoint newCheckpoint = newCheckpointTime == 0 ? lastCheckpoint.orElse(null) : new StreamerCheckpointV2(String.valueOf(newCheckpointTime)); + // Re-wrap a prior V2 checkpoint as V1 to avoid leaking it back to commit metadata. + Checkpoint newCheckpoint = newCheckpointTime == 0 + ? lastCheckpoint.map(CheckpointUtils::createCheckpoint).orElse(null) + : createCheckpoint(String.valueOf(newCheckpointTime)); return new ImmutablePair<>(filteredEventRecords, newCheckpoint); } catch (JSONException | IOException e) { throw new HoodieException("Unable to read from SQS: ", e); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/BootstrapExecutor.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/BootstrapExecutor.java index c9328cfc3bffe..24ce507b34a7c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/BootstrapExecutor.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/BootstrapExecutor.java @@ -44,12 +44,12 @@ import org.apache.hudi.utilities.UtilHelpers; import org.apache.hudi.utilities.schema.SchemaProvider; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -71,10 +71,9 @@ /** * Performs bootstrap from a non-hudi source. */ +@Slf4j public class BootstrapExecutor implements Serializable { - private static final Logger LOG = LoggerFactory.getLogger(BootstrapExecutor.class); - /** * Config. */ @@ -103,6 +102,7 @@ public class BootstrapExecutor implements Serializable { /** * Bootstrap Configuration. */ + @Getter private final HoodieWriteConfig bootstrapConfig; /** @@ -146,7 +146,7 @@ public BootstrapExecutor(HoodieStreamer.Config cfg, JavaSparkContext jssc, FileS builder = builder.withSchema(schemaProvider.getTargetHoodieSchema().toString()); } this.bootstrapConfig = builder.build(); - LOG.info("Created bootstrap executor with configs : " + bootstrapConfig.getProps()); + log.info("Created bootstrap executor with configs: {}", bootstrapConfig.getProps()); } /** @@ -189,7 +189,7 @@ private void initializeTable() throws IOException { Path basePath = new Path(cfg.targetBasePath); if (fs.exists(basePath)) { if (cfg.bootstrapOverwrite) { - LOG.info("Target base path already exists, overwrite it"); + log.info("Target base path already exists, overwrite it"); fs.delete(basePath, true); } else { throw new HoodieException("target base path already exists at " + cfg.targetBasePath @@ -244,8 +244,4 @@ private void initializeTable() throws IOException { builder.initTable(HadoopFSUtils.getStorageConfWithCopy(jssc.hadoopConfiguration()), cfg.targetBasePath); } - - public HoodieWriteConfig getBootstrapConfig() { - return bootstrapConfig; - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/DefaultStreamContext.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/DefaultStreamContext.java index f8dabeb89c96c..46972fe353cf7 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/DefaultStreamContext.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/DefaultStreamContext.java @@ -21,28 +21,18 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.utilities.schema.SchemaProvider; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * The default implementation for the StreamContext interface, * composes SchemaProvider and SourceProfileSupplier currently, * can be extended for other arguments in the future. */ +@AllArgsConstructor +@Getter public class DefaultStreamContext implements StreamContext { private final SchemaProvider schemaProvider; private final Option sourceProfileSupplier; - - public DefaultStreamContext(SchemaProvider schemaProvider, Option sourceProfileSupplier) { - this.schemaProvider = schemaProvider; - this.sourceProfileSupplier = sourceProfileSupplier; - } - - @Override - public SchemaProvider getSchemaProvider() { - return schemaProvider; - } - - @Override - public Option getSourceProfileSupplier() { - return sourceProfileSupplier; - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorEvent.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorEvent.java index a2f1cb277ec60..5bbba78e3921c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorEvent.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorEvent.java @@ -19,45 +19,16 @@ package org.apache.hudi.utilities.streamer; -import java.util.Objects; +import lombok.Value; /** * Error event is an event triggered during write or processing failure of a record. */ +@Value public class ErrorEvent { - private final ErrorReason reason; - private final T payload; - - public ErrorEvent(T payload, ErrorReason reason) { - this.payload = payload; - this.reason = reason; - } - - public T getPayload() { - return payload; - } - - public ErrorReason getReason() { - return reason; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ErrorEvent that = (ErrorEvent) o; - return reason == that.reason && Objects.equals(payload, that.payload); - } - - @Override - public int hashCode() { - return Objects.hash(reason, payload); - } + T payload; + ErrorReason reason; /** * The reason behind write or processing failure of a record diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableCommitter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableCommitter.java new file mode 100644 index 0000000000000..1986e911a06b3 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableCommitter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.util.Option; + +import org.apache.spark.api.java.JavaRDD; + +import java.util.Objects; + +/** + * Commits the error-table side of a HoodieStreamer commit. + * + *

    Two paths exist, mirroring the original {@code HoodieStreamerWriteStatusValidator} behavior:

    + *
      + *
    • Unified write path ({@code isErrorTableWriteUnificationEnabled=true}): commit the + * error-table write statuses produced alongside the data-table write.
    • + *
    • Legacy path: invoke {@link BaseErrorTableWriter#upsertAndCommit(String, Option)} + * which performs both the upsert and the commit internally.
    • + *
    + * + *

    This helper performs the commit and reports success/failure. It deliberately does not + * handle the {@code ROLLBACK_COMMIT} / {@code LOG_ERROR} failure strategies — that policy decision + * lives in {@code StreamSync.writeToSinkAndDoMetaSync()}, which understands the surrounding + * orchestration. Extracted from {@code HoodieStreamerWriteStatusValidator} as part of #18750.

    + */ +public final class ErrorTableCommitter { + + private ErrorTableCommitter() { + } + + /** + * Commit the error-table writes for the given instant. + * + * @param errorTableWriter The configured error-table writer. Must not be null. + * @param errorTableWriteStatusRDDOpt Optional error-table write status RDD, populated when + * unification is enabled. Must not be null + * ({@link Option#empty()} if no RDD). + * @param isErrorTableWriteUnificationEnabled Whether unified-write mode is enabled. + * @param instantTime Instant being committed. + * @param latestCommittedInstant Optional latest completed instant, passed to legacy + * {@code upsertAndCommit}. Must not be null + * ({@link Option#empty()} if none). + * @return {@code true} if the error-table commit succeeded (or was a no-op); + * {@code false} if it failed and the caller must apply a failure-policy action. + */ + public static boolean commit(BaseErrorTableWriter errorTableWriter, + Option> errorTableWriteStatusRDDOpt, + boolean isErrorTableWriteUnificationEnabled, + String instantTime, + Option latestCommittedInstant) { + Objects.requireNonNull(errorTableWriter, "errorTableWriter"); + Objects.requireNonNull(errorTableWriteStatusRDDOpt, "errorTableWriteStatusRDDOpt"); + Objects.requireNonNull(instantTime, "instantTime"); + Objects.requireNonNull(latestCommittedInstant, "latestCommittedInstant"); + + if (isErrorTableWriteUnificationEnabled) { + // In unification mode the error-table writes were produced upstream by the unified write + // path. Commit them here; nothing to do when the optional RDD is absent (true no-op). + if (errorTableWriteStatusRDDOpt.isPresent()) { + return errorTableWriter.commit(errorTableWriteStatusRDDOpt.get()); + } + return true; + } + // Legacy path: writer performs both upsert and commit internally. + return errorTableWriter.upsertAndCommit(instantTime, latestCommittedInstant); + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieMultiTableStreamer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieMultiTableStreamer.java index 90f8f6558ded9..56101e9ae93ad 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieMultiTableStreamer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieMultiTableStreamer.java @@ -39,12 +39,13 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.FileUtil; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -69,11 +70,12 @@ * Helps with ingesting incremental data into hoodie datasets for multiple tables. * Supports COPY_ON_WRITE and MERGE_ON_READ storage types. */ +@Getter +@Slf4j public class HoodieMultiTableStreamer { - private static final Logger LOG = LoggerFactory.getLogger(HoodieMultiTableStreamer.class); - private final List tableExecutionContexts; + @Getter(AccessLevel.NONE) private transient JavaSparkContext jssc; private final Set successTables; private final Set failedTables; @@ -120,7 +122,7 @@ private void checkIfTableConfigFileExists(String configFolder, FileSystem fs, St //commonProps are passed as parameter which contain table to config file mapping private void populateTableExecutionContextList(TypedProperties properties, String configFolder, FileSystem fs, Config config) throws IOException { List tablesToBeIngested = getTablesToBeIngested(properties); - LOG.info("tables to be ingested via MultiTableDeltaStreamer : " + tablesToBeIngested); + log.info("tables to be ingested via MultiTableDeltaStreamer : {}", tablesToBeIngested); TableExecutionContext executionContext; for (String table : tablesToBeIngested) { String[] tableWithDatabase = table.split("\\."); @@ -271,11 +273,11 @@ public static void main(String[] args) throws IOException { } if (config.enableHiveSync) { - LOG.warn("--enable-hive-sync will be deprecated in a future release; please use --enable-sync instead for Hive syncing"); + log.warn("--enable-hive-sync will be deprecated in a future release; please use --enable-sync instead for Hive syncing"); } if (config.targetTableName != null) { - LOG.warn("--target-table is deprecated and will be removed in a future release due to it's useless;" + log.warn("--target-table is deprecated and will be removed in a future release due to it's useless;" + " please use {} to configure multiple target tables", HoodieStreamerConfig.TABLES_TO_BE_INGESTED.key()); } @@ -469,7 +471,7 @@ public void sync() { successTables.add(Helpers.getTableWithDatabase(context)); streamer.shutdownGracefully(); } catch (Exception e) { - LOG.error("error while running MultiTableDeltaStreamer for table: " + context.getTableName(), e); + log.error("error while running MultiTableDeltaStreamer for table: {}", context.getTableName(), e); failedTables.add(Helpers.getTableWithDatabase(context)); } finally { if (streamer != null) { @@ -478,9 +480,9 @@ public void sync() { } } - LOG.info("Ingestion was successful for topics: " + successTables); + log.info("Ingestion was successful for topics: {}", successTables); if (!failedTables.isEmpty()) { - LOG.info("Ingestion failed for topics: " + failedTables); + log.info("Ingestion failed for topics: {}", failedTables); } } @@ -496,16 +498,4 @@ public static class Constants { private static final String UNDERSCORE = "_"; private static final String COMMA_SEPARATOR = ","; } - - public Set getSuccessTables() { - return successTables; - } - - public Set getFailedTables() { - return failedTables; - } - - public List getTableExecutionContexts() { - return this.tableExecutionContexts; - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java index 84929ca695569..9fc9dbff66a9d 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java @@ -74,14 +74,14 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.SparkSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Serializable; @@ -96,7 +96,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static java.lang.String.format; import static org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1.STREAMER_CHECKPOINT_RESET_KEY_V1; import static org.apache.hudi.common.util.ValidationUtils.checkArgument; import static org.apache.hudi.utilities.UtilHelpers.buildProperties; @@ -111,10 +110,10 @@ * write-to-sink (c) Schedule Compactions if needed (d) Conditionally Sync to Hive each cycle. For MOR table with * continuous mode enabled, a separate compactor thread is allocated to execute compactions */ +@Slf4j public class HoodieStreamer implements Serializable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(HoodieStreamer.class); private static final List DEFAULT_SENSITIVE_CONFIG_KEYS = Arrays.asList( HoodieWriteConfig.SENSITIVE_CONFIG_KEYS_FILTER.defaultValue().split(",")); private static final String SENSITIVE_VALUES_MASKED = "SENSITIVE_INFO_MASKED"; @@ -214,9 +213,9 @@ public static TypedProperties combineProperties(Config cfg, Option { - LOG.info("Shutting down DeltaStreamer"); + log.info("Shutting down DeltaStreamer"); ds.shutdown(false); - LOG.info("Async service shutdown complete. Closing DeltaSync "); + log.info("Async service shutdown complete. Closing DeltaSync "); ds.close(); }); } @@ -226,7 +225,7 @@ public void shutdownGracefully() { */ public void sync() throws Exception { if (bootstrapExecutor.isPresent()) { - LOG.info("Performing bootstrap. Source=" + bootstrapExecutor.get().getBootstrapConfig().getBootstrapSourceBasePath()); + log.info("Performing bootstrap. Source={}", bootstrapExecutor.get().getBootstrapConfig().getBootstrapSourceBasePath()); bootstrapExecutor.get().execute(); } else { ingestionService.ifPresent(HoodieIngestionService::startIngestion); @@ -608,7 +607,7 @@ public static String toSortedTruncatedString(TypedProperties props) { for (String key : allKeys) { String value = Option.ofNullable(props.get(key)).orElse("").toString(); // Truncate too long values. - if (value.length() > 255 && !LOG.isDebugEnabled()) { + if (value.length() > 255 && !log.isDebugEnabled()) { value = value.substring(0, 128) + "[...]"; } @@ -648,7 +647,7 @@ public static void main(String[] args) throws Exception { jssc = UtilHelpers.buildSparkContext(sparkAppName, cfg.sparkMaster, cfg.enableHiveSupport, additionalSparkConfigs); } if (cfg.enableHiveSync) { - LOG.warn("--enable-hive-sync will be deprecated in a future release; please use --enable-sync instead for Hive syncing"); + log.warn("--enable-hive-sync will be deprecated in a future release; please use --enable-sync instead for Hive syncing"); } int exitCode = 0; @@ -676,11 +675,13 @@ public static class StreamSyncService extends HoodieIngestionService { /** * Schema provider that supplies the command for reading the input and writing out the target table. */ + @Getter private transient SchemaProvider schemaProvider; /** * Spark Session. */ + @Getter private transient SparkSession sparkSession; /** @@ -696,6 +697,7 @@ public static class StreamSyncService extends HoodieIngestionService { /** * Bag of properties with source, hoodie client, key generator etc. */ + @Getter TypedProperties props; /** @@ -757,7 +759,7 @@ public StreamSyncService(Config cfg, HoodieSparkEngineContext hoodieSparkContext properties.get().forEach((k, v) -> propsToValidate.put(k.toString(), v.toString())); HoodieWriterUtils.validateTableConfig(this.sparkSession, org.apache.hudi.HoodieConversionUtils.mapAsScalaImmutableMap(propsToValidate), meta.getTableConfig()); } catch (HoodieIOException e) { - LOG.warn("Full exception msg {}, msg {}", e.getLocalizedMessage(), e.getMessage()); + log.warn("Full exception msg {}, msg {}", e.getLocalizedMessage(), e.getMessage()); if (e.getMessage().contains("Could not load Hoodie properties") && e.getMessage().contains(HoodieTableConfig.HOODIE_PROPERTIES_FILE)) { initializeTableTypeAndBaseFileFormat(); } else { @@ -772,7 +774,7 @@ public StreamSyncService(Config cfg, HoodieSparkEngineContext hoodieSparkContext "'--filter-dupes' needs to be disabled when '--op' is 'UPSERT' to ensure updates are not missed."); this.props = properties.get(); - LOG.info(toSortedTruncatedString(props)); + log.info(toSortedTruncatedString(props)); this.schemaProvider = UtilHelpers.wrapSchemaProviderWithPostProcessor( UtilHelpers.createSchemaProvider(cfg.schemaProviderClassName, props, hoodieSparkContext.jsc()), @@ -817,7 +819,7 @@ protected Pair startService() { boolean error = false; if (cfg.isAsyncCompactionEnabled()) { // set Scheduler Pool. - LOG.info("Setting Spark Pool name for delta-sync to " + STREAMSYNC_POOL_NAME); + log.info("Setting Spark Pool name for delta-sync to {}", STREAMSYNC_POOL_NAME); hoodieSparkContext.setProperty(EngineProperty.DELTASYNC_POOL_NAME, STREAMSYNC_POOL_NAME); } @@ -834,14 +836,14 @@ protected Pair startService() { if (newProps.isPresent()) { this.props = newProps.get(); // reinit the DeltaSync only when the props updated - LOG.info("Re-init delta sync with new config properties:"); - LOG.info(toSortedTruncatedString(props)); + log.info("Re-init delta sync with new config properties:"); + log.info(toSortedTruncatedString(props)); reInitDeltaSync(); } } Option, JavaRDD>> scheduledCompactionInstantAndRDD = Option.ofNullable(streamSync.syncOnce()); if (scheduledCompactionInstantAndRDD.isPresent() && scheduledCompactionInstantAndRDD.get().getLeft().isPresent()) { - LOG.info("Enqueuing new pending compaction instant (" + scheduledCompactionInstantAndRDD.get().getLeft() + ")"); + log.info("Enqueuing new pending compaction instant ({})", scheduledCompactionInstantAndRDD.get().getLeft()); asyncCompactService.get().enqueuePendingAsyncServiceInstant(scheduledCompactionInstantAndRDD.get().getLeft().get()); asyncCompactService.get().waitTillPendingAsyncServiceInstantsReducesTo(cfg.maxPendingCompactions); if (asyncCompactService.get().hasError()) { @@ -852,7 +854,7 @@ protected Pair startService() { if (clusteringConfig.isAsyncClusteringEnabled()) { Option clusteringInstant = streamSync.getClusteringInstantOpt(); if (clusteringInstant.isPresent()) { - LOG.info("Scheduled async clustering for instant: " + clusteringInstant.get()); + log.info("Scheduled async clustering for instant: {}", clusteringInstant.get()); asyncClusteringService.get().enqueuePendingAsyncServiceInstant(clusteringInstant.get()); asyncClusteringService.get().waitTillPendingAsyncServiceInstantsReducesTo(cfg.maxPendingClustering); if (asyncClusteringService.get().hasError()) { @@ -865,7 +867,7 @@ protected Pair startService() { Option> lastWriteStatuses = Option.ofNullable( scheduledCompactionInstantAndRDD.isPresent() ? HoodieJavaRDD.of(scheduledCompactionInstantAndRDD.get().getRight()) : null); if (requestShutdownIfNeeded(lastWriteStatuses)) { - LOG.info("Closing and shutting down ingestion service"); + log.info("Closing and shutting down ingestion service"); error = true; onIngestionCompletes(false); shutdown(true); @@ -875,7 +877,7 @@ protected Pair startService() { } catch (HoodieUpsertException ue) { handleUpsertException(ue); } catch (Exception e) { - LOG.error("Shutting down delta-sync due to exception", e); + log.error("Shutting down delta-sync due to exception", e); error = true; throw new HoodieException(e.getMessage(), e); } @@ -890,7 +892,7 @@ protected Pair startService() { private void handleUpsertException(HoodieUpsertException ue) { if (ue.getCause() instanceof HoodieClusteringUpdateException) { - LOG.warn("Write rejected due to conflicts with pending clustering operation. Going to retry after 1 min with the hope " + log.warn("Write rejected due to conflicts with pending clustering operation. Going to retry after 1 min with the hope " + "that clustering will complete by then.", ue); try { Thread.sleep(60000); // Intentionally not using cfg.minSyncIntervalSeconds, since it could be too high or it could be 0. @@ -907,13 +909,13 @@ private void handleUpsertException(HoodieUpsertException ue) { * Shutdown async services like compaction/clustering as DeltaSync is shutdown. */ private void shutdownAsyncServices(boolean error) { - LOG.info("Delta Sync shutdown. Error ?{}", error); + log.info("Delta Sync shutdown. Error ?{}", error); if (asyncCompactService.isPresent()) { - LOG.info("Gracefully shutting down compactor"); + log.info("Gracefully shutting down compactor"); asyncCompactService.get().shutdown(false); } if (asyncClusteringService.isPresent()) { - LOG.info("Gracefully shutting down clustering service"); + log.info("Gracefully shutting down clustering service"); asyncClusteringService.get().shutdown(false); } } @@ -922,7 +924,9 @@ private void shutdownAsyncServices(boolean error) { public void ingestOnce() { try { streamSync.syncOnce(); - } catch (IOException e) { + streamSync.reportSuccessMetrics(); + } catch (Exception e) { + streamSync.reportFailureMetrics(); throw new HoodieIngestionException(String.format("Ingestion via %s failed with exception.", this.getClass()), e); } finally { close(); @@ -979,7 +983,7 @@ protected Boolean onInitializingWriteClient(SparkRDDWriteClient writeClient) { .setBasePath(cfg.targetBasePath) .setLoadActiveTimelineOnLoad(true).build(); List pending = ClusteringUtils.getPendingClusteringInstantTimes(meta); - LOG.info(format("Found %d pending clustering instants ", pending.size())); + log.info("Found {} pending clustering instants ", pending.size()); pending.forEach(hoodieInstant -> asyncClusteringService.get().enqueuePendingAsyncServiceInstant(hoodieInstant.requestedTime())); asyncClusteringService.get().start(error -> true); try { @@ -997,7 +1001,7 @@ protected Boolean onInitializingWriteClient(SparkRDDWriteClient writeClient) { @Override protected boolean onIngestionCompletes(boolean hasError) { - LOG.info("Ingestion completed. Has error: " + hasError); + log.info("Ingestion completed. Has error: {}", hasError); close(); return true; } @@ -1014,18 +1018,6 @@ public void close() { } } - public SchemaProvider getSchemaProvider() { - return schemaProvider; - } - - public SparkSession getSparkSession() { - return sparkSession; - } - - public TypedProperties getProps() { - return props; - } - @VisibleForTesting public HoodieSparkEngineContext getHoodieSparkContext() { return hoodieSparkContext; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerMetrics.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerMetrics.java index 5813533e2184d..0e0db770012a3 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerMetrics.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerMetrics.java @@ -108,6 +108,20 @@ public void updateStreamerMetrics(long durationInNs) { } } + @Override + public void emitStreamerJobSuccessMetrics() { + if (writeConfig.isMetricsOn()) { + metrics.registerGauge(getMetricsName("deltastreamer", "success"), 1); + } + } + + @Override + public void emitStreamerJobFailedMetrics() { + if (writeConfig.isMetricsOn()) { + metrics.registerGauge(getMetricsName("deltastreamer", "failure"), 1); + } + } + @Override public void updateStreamerMetaSyncMetrics(String syncClassShortName, long syncNs) { if (writeConfig.isMetricsOn()) { @@ -198,6 +212,7 @@ public void updateStreamerSourceParallelism(int sourceParallelism) { } } + @Override public void updateStreamerSourceBytesToBeIngestedInSyncRound(long sourceBytesToBeIngested) { if (writeConfig.isMetricsOn()) { metrics.registerGauge(getMetricsName("deltastreamer", "sourceBytesToBeIngestedInSyncRound"), sourceBytesToBeIngested); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerUtils.java index 12e620156c494..9e455795e5923 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerUtils.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamerUtils.java @@ -54,6 +54,7 @@ import org.apache.hudi.util.SparkKeyGenUtils; import org.apache.hudi.utilities.schema.SchemaProvider; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; @@ -64,8 +65,6 @@ import org.apache.spark.sql.avro.HoodieAvroDeserializer; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.types.StructType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.Iterator; @@ -79,10 +78,9 @@ /** * Util class for HoodieStreamer. */ +@Slf4j public class HoodieStreamerUtils { - private static final Logger LOG = LoggerFactory.getLogger(HoodieStreamerUtils.class); - /** * Generates HoodieRecords for the avro data read from source. * Takes care of dropping columns, precombine, auto key generation. @@ -111,7 +109,7 @@ public static Option> createHoodieRecords(HoodieStreamer.C records = avroRDD.mapPartitions( (FlatMapFunction, Either>) genericRecordIterator -> { TaskContext taskContext = TaskContext.get(); - LOG.info("Creating HoodieRecords with stageId : {}, stage attempt no: {}, taskId : {}, task attempt no : {}, task attempt id : {} ", + log.info("Creating HoodieRecords with stageId : {}, stage attempt no: {}, taskId : {}, task attempt no : {}, task attempt id : {} ", taskContext.stageId(), taskContext.stageAttemptNumber(), taskContext.partitionId(), taskContext.attemptNumber(), taskContext.taskAttemptId()); if (autoGenerateRecordKeys) { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/NoNewDataTerminationStrategy.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/NoNewDataTerminationStrategy.java index 686bdb52e3c7b..08e4cf25dcbce 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/NoNewDataTerminationStrategy.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/NoNewDataTerminationStrategy.java @@ -23,17 +23,15 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.util.Option; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.api.java.JavaRDD; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Post writer termination strategy for deltastreamer in continuous mode. This strategy is based on no new data for consecutive number of times. */ +@Slf4j public class NoNewDataTerminationStrategy implements PostWriteTerminationStrategy { - private static final Logger LOG = LoggerFactory.getLogger(NoNewDataTerminationStrategy.class); - public static final String MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN = "max.rounds.without.new.data.to.shutdown"; public static final int DEFAULT_MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN = 3; @@ -48,7 +46,7 @@ public NoNewDataTerminationStrategy(TypedProperties properties) { public boolean shouldShutdown(Option> writeStatuses) { numTimesNoNewData = writeStatuses.isPresent() ? 0 : numTimesNoNewData + 1; if (numTimesNoNewData >= numTimesNoNewDataToShutdown) { - LOG.info("Shutting down on continuous mode as there is no new data for " + numTimesNoNewData); + log.info("Shutting down on continuous mode as there is no new data for {}", numTimesNoNewData); return true; } return false; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SchedulerConfGenerator.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SchedulerConfGenerator.java index 19df192aad8ef..5e1c6ff2692e4 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SchedulerConfGenerator.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SchedulerConfGenerator.java @@ -24,9 +24,8 @@ import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.util.Option; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.SparkConf; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.BufferedWriter; import java.io.File; @@ -42,10 +41,9 @@ * Utility Class to generate Spark Scheduling allocation file. This kicks in only when user sets * spark.scheduler.mode=FAIR at spark-submit time */ +@Slf4j public class SchedulerConfGenerator { - private static final Logger LOG = LoggerFactory.getLogger(SchedulerConfGenerator.class); - public static final String DELTASYNC_POOL_NAME = HoodieStreamer.STREAMSYNC_POOL_NAME; public static final String COMPACT_POOL_NAME = AsyncCompactService.COMPACT_POOL_NAME; public static final String SPARK_SCHEDULER_MODE_KEY = "spark.scheduler.mode"; @@ -106,10 +104,10 @@ public static Map getSparkSchedulingConfigs(HoodieStreamer.Confi String sparkSchedulingConfFile = generateAndStoreConfig(cfg.deltaSyncSchedulingWeight, cfg.compactSchedulingWeight, cfg.deltaSyncSchedulingMinShare, cfg.compactSchedulingMinShare, cfg.clusterSchedulingWeight, cfg.clusterSchedulingMinShare); - LOG.info("Spark scheduling config file {}", sparkSchedulingConfFile); + log.info("Spark scheduling config file {}", sparkSchedulingConfFile); additionalSparkConfigs.put(SparkConfigs.SPARK_SCHEDULER_ALLOCATION_FILE_KEY(), sparkSchedulingConfFile); } else { - LOG.warn("Job Scheduling Configs will not be in effect as spark.scheduler.mode " + log.warn("Job Scheduling Configs will not be in effect as spark.scheduler.mode " + "is not set to FAIR at instantiation time. Continuing without scheduling configs"); } return additionalSparkConfigs; @@ -135,7 +133,7 @@ private static String generateAndStoreConfig(Integer deltaSyncWeight, Integer co } // SPARK-35083 introduces remote scheduler pool files, so the file must include scheme since Spark 3.2 String path = tempConfigFile.toURI().toString(); - LOG.info("Configs written to file " + path); + log.info("Configs written to file {}", path); return path; } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SourceFormatAdapter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SourceFormatAdapter.java index 763caf8f54fec..0d63ffd6c0f45 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SourceFormatAdapter.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SourceFormatAdapter.java @@ -43,6 +43,8 @@ import org.apache.hudi.utilities.sources.helpers.SanitizationUtils; import com.google.protobuf.Message; +import lombok.AccessLevel; +import lombok.Getter; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.sql.Column; @@ -73,12 +75,15 @@ */ public class SourceFormatAdapter implements Closeable { + @Getter private final Source source; private boolean shouldSanitize = SANITIZE_SCHEMA_FIELD_NAMES.defaultValue(); private boolean wrapWithException = ROW_THROW_EXPLICIT_EXCEPTIONS.defaultValue(); + @Getter(AccessLevel.PRIVATE) private String invalidCharMask = SCHEMA_FIELD_NAME_INVALID_CHAR_MASK.defaultValue(); + @Getter(AccessLevel.PRIVATE) private boolean useJava8api = (boolean) SQLConf.DATETIME_JAVA8API_ENABLED().defaultValue().get(); @@ -110,18 +115,6 @@ private boolean isFieldNameSanitizingEnabled() { return shouldSanitize; } - /** - * Replacement mask for invalid characters encountered in avro names. - * @return sanitized value. - */ - private String getInvalidCharMask() { - return invalidCharMask; - } - - private boolean getUseJava8api() { - return useJava8api; - } - /** * transform input rdd of json string to generic records with support for adding error events to error table * @param inputBatch @@ -144,7 +137,7 @@ private JavaRDD transformJsonToGenericRdd(InputBatch transformJsonToRowRdd(InputBatch> inputBatch) { MercifulJsonConverter.clearCache(inputBatch.getSchemaProvider().getSourceHoodieSchema().getFullName()); - RowConverter convertor = new RowConverter(inputBatch.getSchemaProvider().getSourceHoodieSchema(), isFieldNameSanitizingEnabled(), getInvalidCharMask(), getUseJava8api()); + RowConverter convertor = new RowConverter(inputBatch.getSchemaProvider().getSourceHoodieSchema(), isFieldNameSanitizingEnabled(), getInvalidCharMask(), isUseJava8api()); return inputBatch.getBatch().map(rdd -> { if (errorTableWriter.isPresent()) { JavaRDD> javaRDD = rdd.map(convertor::fromJsonToRowWithError); @@ -323,10 +316,6 @@ public InputBatch> fetchNewDataInRowFormat(Option lastC } } - public Source getSource() { - return source; - } - @Override public void close() { source.releaseResources(); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SparkSampleWritesUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SparkSampleWritesUtils.java index 05aeca28067b0..c50ff3484cad2 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SparkSampleWritesUtils.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SparkSampleWritesUtils.java @@ -36,11 +36,10 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.StoragePath; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.List; @@ -58,31 +57,30 @@ *

    * TODO handle sample_writes sub-path clean-up w.r.t. rollback and insert overwrite. (HUDI-6044) */ +@Slf4j public class SparkSampleWritesUtils { - private static final Logger LOG = LoggerFactory.getLogger(SparkSampleWritesUtils.class); - public static Option getWriteConfigWithRecordSizeEstimate(JavaSparkContext jsc, Option> recordsOpt, HoodieWriteConfig writeConfig) { if (!writeConfig.getBoolean(SAMPLE_WRITES_ENABLED)) { - LOG.debug("Skip overwriting record size estimate as it's disabled."); + log.debug("Skip overwriting record size estimate as it's disabled."); return Option.empty(); } HoodieTableMetaClient metaClient = getMetaClient(jsc, writeConfig.getBasePath()); if (metaClient.isTimelineNonEmpty()) { - LOG.info("Skip overwriting record size estimate due to timeline is non-empty."); + log.info("Skip overwriting record size estimate due to timeline is non-empty."); return Option.empty(); } try { Pair result = doSampleWrites(jsc, recordsOpt, writeConfig); if (result.getLeft()) { long avgSize = getAvgSizeFromSampleWrites(jsc, result.getRight()); - LOG.info("Overwriting record size estimate to {}", avgSize); + log.info("Overwriting record size estimate to {}", avgSize); TypedProperties props = writeConfig.getProps(); props.put(COPY_ON_WRITE_RECORD_SIZE_ESTIMATE.key(), String.valueOf(avgSize)); return Option.of(HoodieWriteConfig.newBuilder().withProperties(props).build()); } } catch (IOException e) { - LOG.error(String.format("Not overwriting record size estimate for table %s due to error when doing sample writes.", writeConfig.getTableName()), e); + log.error("Not overwriting record size estimate for table {} due to error when doing sample writes.", writeConfig.getTableName(), e); } return Option.empty(); } @@ -117,13 +115,13 @@ private static Pair doSampleWrites(JavaSparkContext jsc, Option String instantTime = sampleWriteClient.startCommit(); JavaRDD writeStatusRDD = sampleWriteClient.bulkInsert(jsc.parallelize(samples, 1), instantTime); if (writeStatusRDD.filter(WriteStatus::hasErrors).count() > 0) { - LOG.error("sample writes for table {} failed with errors.", writeConfig.getTableName()); - if (LOG.isTraceEnabled()) { - LOG.trace("Printing out the top 100 errors"); + log.error("sample writes for table {} failed with errors.", writeConfig.getTableName()); + if (log.isTraceEnabled()) { + log.trace("Printing out the top 100 errors"); writeStatusRDD.filter(WriteStatus::hasErrors).take(100).forEach(ws -> { - LOG.trace("Global error :", ws.getGlobalError()); + log.trace("Global error :", ws.getGlobalError()); ws.getErrors().forEach((key, throwable) -> - LOG.trace(String.format("Error for key: %s", key), throwable)); + log.trace("Error for key: {}", key, throwable)); }); } return emptyRes; diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java index 600891c85dff6..e79dbece90b72 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java @@ -26,7 +26,6 @@ import org.apache.hudi.HoodieSchemaUtils; import org.apache.hudi.HoodieSparkSqlWriter; import org.apache.hudi.HoodieSparkUtils; -import org.apache.hudi.callback.common.WriteStatusValidator; import org.apache.hudi.client.HoodieWriteResult; import org.apache.hudi.client.SparkRDDWriteClient; import org.apache.hudi.client.WriteStatus; @@ -40,7 +39,6 @@ import org.apache.hudi.common.config.HoodieTimeGeneratorConfig; import org.apache.hudi.common.config.RecordMergeMode; import org.apache.hudi.common.config.TypedProperties; -import org.apache.hudi.common.data.HoodieData; import org.apache.hudi.common.model.DefaultHoodieRecordPayload; import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; @@ -57,6 +55,7 @@ import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; @@ -74,9 +73,9 @@ import org.apache.hudi.config.HoodieErrorTableConfig; import org.apache.hudi.config.HoodieIndexConfig; import org.apache.hudi.config.HoodiePayloadConfig; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.config.metrics.HoodieMetricsConfig; -import org.apache.hudi.data.HoodieJavaRDD; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.HoodieMetaSyncException; @@ -114,10 +113,13 @@ import org.apache.hudi.utilities.schema.SimpleSchemaProvider; import org.apache.hudi.utilities.sources.InputBatch; import org.apache.hudi.utilities.sources.Source; -import org.apache.hudi.utilities.streamer.HoodieStreamer.Config; +import org.apache.hudi.utilities.streamer.validator.SparkStreamerValidatorUtils; import org.apache.hudi.utilities.transform.Transformer; import com.codahale.metrics.Timer; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; @@ -128,8 +130,7 @@ 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; +import org.apache.spark.storage.StorageLevel; import java.io.Closeable; import java.io.IOException; @@ -153,7 +154,7 @@ import static org.apache.hudi.common.table.HoodieTableConfig.HIVE_STYLE_PARTITIONING_ENABLE; import static org.apache.hudi.common.table.HoodieTableConfig.TIMELINE_HISTORY_PATH; import static org.apache.hudi.common.table.HoodieTableConfig.URL_ENCODE_PARTITIONING; -import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.buildCheckpointFromGeneralSource; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys; import static org.apache.hudi.config.HoodieClusteringConfig.ASYNC_CLUSTERING_ENABLE; import static org.apache.hudi.config.HoodieClusteringConfig.INLINE_CLUSTERING; @@ -173,20 +174,22 @@ import static org.apache.hudi.utilities.schema.RowBasedSchemaProvider.HOODIE_RECORD_NAMESPACE; import static org.apache.hudi.utilities.schema.RowBasedSchemaProvider.HOODIE_RECORD_STRUCT_NAME; import static org.apache.hudi.utilities.streamer.StreamerCheckpointUtils.getLatestInstantWithValidCheckpointInfo; +import static org.apache.hudi.utilities.streamer.StreamerCheckpointUtils.shouldTargetCheckpointV2; /** * Sync's one batch of data to hoodie table. */ +@Slf4j public class StreamSync implements Serializable, Closeable { private static final long serialVersionUID = 1L; - private static final Logger LOG = LoggerFactory.getLogger(StreamSync.class); private static final String NULL_PLACEHOLDER = "[null]"; public static final String CHECKPOINT_IGNORE_KEY = "deltastreamer.checkpoint.ignore_key"; /** * Delta Sync Config. */ + @Getter private final HoodieStreamer.Config cfg; /** @@ -214,6 +217,7 @@ public class StreamSync implements Serializable, Closeable { /** * Filesystem used. */ + @Getter private transient HoodieStorage storage; /** @@ -236,6 +240,7 @@ public class StreamSync implements Serializable, Closeable { * * NOTE: These properties are already consolidated w/ CLI provided config-overrides */ + @Getter private final TypedProperties props; /** @@ -246,6 +251,7 @@ public class StreamSync implements Serializable, Closeable { /** * Timeline with completed commits, including both .commit and .deltacommit. */ + @Getter private transient Option commitsTimelineOpt; // all commits timeline, including all (commits, delta commits, compaction, clean, savepoint, rollback, replace commits, index) @@ -270,6 +276,7 @@ public class StreamSync implements Serializable, Closeable { private Option errorTableWriter = Option.empty(); private HoodieErrorTableConfig.ErrorWriteFailureStrategy errorWriteFailureStrategy; + @Getter private transient HoodieIngestionMetrics metrics; private transient HoodieMetrics hoodieMetrics; @@ -394,7 +401,7 @@ private HoodieTableMetaClient initializeMetaClient(boolean refreshTimeline) thro } return metaClient; } catch (HoodieIOException e) { - LOG.warn("Full exception msg {}", e.getMessage()); + log.warn("Full exception msg {}", e.getMessage()); if (e.getMessage().contains("Could not load Hoodie properties") && e.getMessage().contains(HoodieTableConfig.HOODIE_PROPERTIES_FILE)) { String basePathWithForwardSlash = cfg.targetBasePath.endsWith("/") ? cfg.targetBasePath : String.format("%s/", cfg.targetBasePath); @@ -409,7 +416,7 @@ private HoodieTableMetaClient initializeMetaClient(boolean refreshTimeline) thro && storage.exists(new StoragePath(pathToHoodiePropsBackup)); if (!hoodiePropertiesExists) { - LOG.warn("Base path exists, but table is not fully initialized. Re-initializing again"); + log.warn("Base path exists, but table is not fully initialized. Re-initializing again"); HoodieTableMetaClient metaClientToValidate = initializeEmptyTable(); // reload the timeline from metaClient and validate that its empty table. If there are any instants found, then we should fail the pipeline, bcoz hoodie.properties got deleted by mistake. if (metaClientToValidate.reloadActiveTimeline().countInstants() > 0) { @@ -552,7 +559,7 @@ private void initializeWriteClientAndRetryTableServices(InputBatch inputBatch, H || (newTargetSchema != null && !processedSchema.isSchemaPresent(newTargetSchema))) { String sourceStr = newSourceSchema == null ? NULL_PLACEHOLDER : newSourceSchema.toString(true); String targetStr = newTargetSchema == null ? NULL_PLACEHOLDER : newTargetSchema.toString(true); - LOG.info("Seeing new schema. Source: {}, Target: {}", sourceStr, targetStr); + log.info("Seeing new schema. Source: {}, Target: {}", sourceStr, targetStr); // We need to recreate write client with new schema and register them. reInitWriteClient(newSourceSchema, newTargetSchema, inputBatch.getBatch(), metaClient); if (newSourceSchema != null) { @@ -582,6 +589,14 @@ private void initializeWriteClientAndRetryTableServices(InputBatch inputBatch, H } } + public void reportSuccessMetrics() { + metrics.emitStreamerJobSuccessMetrics(); + } + + public void reportFailureMetrics() { + metrics.emitStreamerJobFailedMetrics(); + } + private Option getLastPendingClusteringInstant(Option commitTimelineOpt) { if (commitTimelineOpt.isPresent()) { Option pendingClusteringInstant = commitTimelineOpt.get().getLastPendingClusterInstant(); @@ -607,7 +622,7 @@ private Option getLastPendingCompactionInstant(Option co public Pair readFromSource(HoodieTableMetaClient metaClient) throws IOException { // Retrieve the previous round checkpoints, if any Option checkpointToResume = StreamerCheckpointUtils.resolveCheckpointToResumeFrom(commitsTimelineOpt, cfg, props, metaClient); - LOG.info("Checkpoint to resume from : {}", checkpointToResume); + log.info("Checkpoint to resume from : {}", checkpointToResume); int maxRetryCount = cfg.retryOnSourceFailures ? cfg.maxRetryCount : 1; int curRetryCount = 0; @@ -620,11 +635,11 @@ public Pair readFromSource(HoodieTableMetaClient metaClient throw e; } try { - LOG.error("Exception thrown while fetching data from source. Msg : " + e.getMessage() + ", class : " + e.getClass() + ", cause : " + e.getCause()); - LOG.error("Sleeping for " + (cfg.retryIntervalSecs) + " before retrying again. Current retry count " + curRetryCount + ", max retry count " + cfg.maxRetryCount); + log.error("Exception thrown while fetching data from source. Msg : {}, class : {}, cause : {}", e.getMessage(), e.getClass(), e.getCause()); + log.error("Sleeping for {} before retrying again. Current retry count {}, max retry count {}", cfg.retryIntervalSecs, curRetryCount, cfg.maxRetryCount); Thread.sleep(cfg.retryIntervalSecs * 1000); } catch (InterruptedException ex) { - LOG.error("Ignoring InterruptedException while waiting to retry on source failure " + e.getMessage()); + log.error("Ignoring InterruptedException while waiting to retry on source failure {}", e.getMessage()); } } } @@ -648,8 +663,8 @@ private Pair fetchFromSourceAndPrepareRecords(Option fetchNextBatchFromSource(Option resumeChec inputBatchForWriter = new InputBatch<>(inputBatchNeedsDeduceSchema.getBatch(), inputBatchNeedsDeduceSchema.getCheckpointForNextBatch(), getDeducedSchemaProvider(inputBatchNeedsDeduceSchema.getSchemaProvider().getTargetHoodieSchema(), inputBatchNeedsDeduceSchema.getSchemaProvider(), metaClient)); } else { - LOG.warn("Row-writer is enabled but cannot be used due to the target schema"); + log.warn("Row-writer is enabled but cannot be used due to the target schema"); } } // if row writer was enabled but the target schema prevents us from using it, do not use the row writer @@ -869,16 +884,121 @@ private Pair, JavaRDD> writeToSinkAndDoMetaSync(Hood Map checkpointCommitMetadata = extractCheckpointMetadata(inputBatch, props, writeClient.getConfig().getWriteVersion().versionCode(), cfg); AtomicLong totalSuccessfulRecords = new AtomicLong(0); Option latestCommittedInstant = getLatestCommittedInstant(); - WriteStatusValidator writeStatusValidator = new HoodieStreamerWriteStatusValidator(cfg.commitOnErrors, instantTime, - cfg, errorTableWriter, errorTableWriteStatusRDDOpt, errorWriteFailureStrategy, isErrorTableWriteUnificationEnabled, writeClient, latestCommittedInstant, - totalSuccessfulRecords); String commitActionType = CommitUtils.getCommitActionType(cfg.operation, HoodieTableType.valueOf(cfg.tableType)); - boolean success = writeClient.commit(instantTime, writeStatusRDD, Option.of(checkpointCommitMetadata), commitActionType, partitionToReplacedFileIds, Option.empty(), - Option.of(writeStatusValidator)); + // Pre-commit orchestration (issue #18750): the legacy HoodieStreamerWriteStatusValidator + // ran inside writeClient.commit() via the WriteStatusValidator callback and combined three + // concerns — count records, commit the error table, and gate on write errors. Each is now + // an explicit step here before writeClient.commit(), so the writer no longer receives a + // callback. Step order is deliberate (see comments below). + // + // The RDD is cached once and the write statuses are collected once on the driver. Both the + // count/error-logging steps and writeClient.commit() consume the materialized partitions + // rather than re-evaluating the upstream DAG. shouldUnpersist tracks whether we engaged the + // cache here so the finally block knows to release it. + boolean shouldUnpersist = writeStatusRDD.getStorageLevel().equals(StorageLevel.NONE()); + if (shouldUnpersist) { + writeStatusRDD.cache(); + } + boolean success; + try { + List writeStatuses = writeStatusRDD.collect(); + boolean validatorsConfigured = !StringUtils.isNullOrEmpty(props.getString( + HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.key(), + HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.defaultValue())); + + // Step 1: Commit the error table BEFORE running validators or the write-error gate. + // Error records captured here are a genuine artifact of the write attempt and should + // survive even when a validator later blocks the data-table commit (otherwise the + // operator loses the captured errors and the next run has nothing to triage against). + // Latent design quirk (preserved from HSWSV): if error-table commit succeeds and any + // subsequent step fails (Step 2 validator including the offset validator, Step 4 gate, + // or writeClient.commit), the error table will have a committed instant for a data-table + // instant that never lands. Downstream consumers of the error table should tolerate this + // divergence. + if (errorTableWriter.isPresent()) { + boolean errorTableSuccess = ErrorTableCommitter.commit(errorTableWriter.get(), + errorTableWriteStatusRDDOpt, isErrorTableWriteUnificationEnabled, instantTime, + latestCommittedInstant); + if (!errorTableSuccess) { + switch (errorWriteFailureStrategy) { + case ROLLBACK_COMMIT: + // Roll back the inflight data-table instant so it doesn't leak under LAZY + // failed-writes cleanup policy (preserves HSWSV behavior). + writeClient.rollback(instantTime); + throw new HoodieStreamerWriteException("Error table commit failed for instant " + instantTime); + case LOG_ERROR: + log.error("Error table write failed for instant {}", instantTime); + break; + default: + throw new HoodieStreamerWriteException("Write failure strategy not implemented for " + errorWriteFailureStrategy); + } + } + } + + // Step 2: Run user-configured pre-commit validators (offset, custom, and the opt-in + // SparkWriteErrorValidator). Validators are intentionally stronger than commitOnErrors + // — a failure here aborts the data-table commit regardless of the gate in Step 4. + // Roll back the inflight data-table instant on validation failure so it doesn't leak + // under LAZY failed-writes cleanup policy (consistent with Step 1 ROLLBACK_COMMIT and + // the Step 4 gate below). Error-table records already committed in Step 1 are preserved + // by design — see Step 1's latent-quirk note. + if (validatorsConfigured) { + try { + SparkStreamerValidatorUtils.runValidators(props, instantTime, writeStatuses, + checkpointCommitMetadata, metaClient); + } catch (HoodieValidationException e) { + log.error("Pre-commit validators failed for instant {}", instantTime, e); + writeClient.rollback(instantTime); + throw new HoodieStreamerWriteException("Pre-commit validators failed for instant " + instantTime, e); + } + } + + // Step 3: Count records. Drives the runMetaSync() decision below the try/finally. + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + writeStatuses, errorTableWriteStatusRDDOpt, isErrorTableWriteUnificationEnabled); + totalSuccessfulRecords.set(counts.getTotalSuccessfulRecords()); + log.info("instantTime={}, totalRecords={}, totalErrorRecords={}, totalSuccessfulRecords={}", + instantTime, counts.getTotalRecords(), counts.getTotalErrorRecords(), + counts.getTotalSuccessfulRecords()); + if (counts.getTotalRecords() == 0) { + log.info("No new data, perform empty commit."); + } + + // Step 4: Apply the legacy HSWSV write-error gate. + // commitOnErrors=false (default): any error -> log top N + fail. + // commitOnErrors=true: log a warning, proceed to commit. + // This gate is redundant with SparkWriteErrorValidator when that validator is configured + // with failure.policy=FAIL — both will reject the same commits. The redundancy is + // intentional: the gate preserves HSWSV's default behavior for users who do not configure + // any validators, while the validator gives users running multiple validators a unified + // failure-policy story. + if (counts.hasErrors()) { + if (cfg.commitOnErrors) { + log.warn("Some records failed to be merged but forcing commit since commitOnErrors set. Errors/Total={}/{}", + counts.getTotalErrorRecords(), counts.getTotalRecords()); + } else { + log.error("Delta Sync found errors when writing. Errors/Total={}/{}", + counts.getTotalErrorRecords(), counts.getTotalRecords()); + WriteErrorReporter.logTopErrors(writeStatuses); + // Roll back the inflight data-table instant so it doesn't leak under LAZY + // failed-writes cleanup policy (preserves HSWSV behavior). + writeClient.rollback(instantTime); + throw new HoodieStreamerWriteException("Commit " + instantTime + " has write errors and commitOnErrors=false"); + } + } + + // Step 5: Commit. No WriteStatusValidator callback — all checks are above. + success = writeClient.commit(instantTime, writeStatusRDD, Option.of(checkpointCommitMetadata), + commitActionType, partitionToReplacedFileIds, Option.empty()); + } finally { + if (shouldUnpersist) { + writeStatusRDD.unpersist(); + } + } releaseResourcesInvoked = true; if (success) { - LOG.info("Commit " + instantTime + " successful!"); + log.info("Commit {} successful!", instantTime); this.formatAdapter.getSource().onCommit(inputBatch.getCheckpointForNextBatch() != null ? inputBatch.getCheckpointForNextBatch().getCheckpointKey() : null); // Schedule compaction if needed @@ -889,10 +1009,10 @@ private Pair, JavaRDD> writeToSinkAndDoMetaSync(Hood if ((totalSuccessfulRecords.get() > 0) || cfg.forceEmptyMetaSync) { runMetaSync(); } else { - LOG.info(String.format("Not running metaSync totalSuccessfulRecords=%d", totalSuccessfulRecords.get())); + log.info("Not running metaSync totalSuccessfulRecords={}", totalSuccessfulRecords.get()); } } else { - LOG.info("Commit " + instantTime + " failed!"); + log.info("Commit {} failed!", instantTime); throw new HoodieStreamerWriteException("Commit " + instantTime + " failed!"); } @@ -921,7 +1041,8 @@ Map extractCheckpointMetadata(InputBatch inputBatch, TypedProper } // Otherwise create new checkpoint based on version - Checkpoint checkpoint = buildCheckpointFromGeneralSource(cfg.sourceClassName, versionCode, null); + Checkpoint checkpoint = shouldTargetCheckpointV2(versionCode, cfg.sourceClassName) + ? new StreamerCheckpointV2((String) null) : createCheckpoint((String) null); return checkpoint.getCheckpointCommitMetadata(cfg.checkpoint, cfg.ignoreCheckpoint); } @@ -946,7 +1067,7 @@ private String startCommit(HoodieTableMetaClient metaClient, boolean retryEnable if (!retryEnabled) { throw ie; } - LOG.error("Got error trying to start a new commit. Retrying after sleeping for a sec", ie); + log.error("Got error trying to start a new commit. Retrying after sleeping for a sec", ie); retryNum++; try { Thread.sleep(1000); @@ -1026,10 +1147,10 @@ public void runMetaSync() { if (cfg.enableHiveSync) { cfg.enableMetaSync = true; syncClientToolClasses.add(HiveSyncTool.class.getName()); - LOG.info("When set --enable-hive-sync will use HiveSyncTool for backward compatibility"); + log.info("When set --enable-hive-sync will use HiveSyncTool for backward compatibility"); } if (cfg.enableMetaSync && !syncClientToolClasses.isEmpty()) { - LOG.debug("[MetaSync] Starting sync"); + log.debug("[MetaSync] Starting sync"); HoodieTableMetaClient metaClient; try { metaClient = initializeMetaClient(); @@ -1053,7 +1174,7 @@ public void runMetaSync() { Map failedMetaSyncs = new HashMap<>(); for (String impl : syncClientToolClasses) { if (impl.trim().isEmpty()) { - LOG.warn("Cannot run MetaSync with empty class name"); + log.warn("Cannot run MetaSync with empty class name"); continue; } @@ -1078,10 +1199,10 @@ private void logMetaSync(String impl, Timer.Context syncContext, Map> recordsOpt, HoodieTa } private void reInitWriteClient(HoodieSchema sourceSchema, HoodieSchema targetSchema, Option> recordsOpt, HoodieTableMetaClient metaClient) throws IOException { - LOG.info("Setting up new Hoodie Write Client"); + log.info("Setting up new Hoodie Write Client"); if (HoodieStreamerUtils.isDropPartitionColumns(props)) { targetSchema = org.apache.hudi.common.schema.HoodieSchemaUtils.removeFields(targetSchema, HoodieStreamerUtils.getPartitionColumns(props)); } @@ -1245,7 +1366,7 @@ private HoodieSchema getSchemaForWriteConfig(HoodieSchema targetSchema, HoodieTa if (tableSchema.isPresent()) { newWriteSchema = tableSchema.get(); } else { - LOG.warn("Could not fetch schema from table. Falling back to using target schema from schema provider"); + log.warn("Could not fetch schema from table. Falling back to using target schema from schema provider"); } } } @@ -1271,7 +1392,7 @@ private void registerAvroSchemas(HoodieSchema sourceSchema, HoodieSchema targetS schemas.add(targetSchema); } if (!schemas.isEmpty()) { - LOG.debug("Registering Schema: {}", schemas); + log.debug("Registering Schema: {}", schemas); // Use the underlying spark context in case the java context is changed during runtime hoodieSparkContext.getJavaSparkContext().sc().getConf() .registerAvroSchemas(JavaScalaConverters.convertJavaListToScalaList(schemas.stream().map(HoodieSchema::toAvroSchema).collect(Collectors.toList())).toList()); @@ -1292,7 +1413,7 @@ public void close() { formatAdapter.close(); } - LOG.info("Shutting down embedded timeline server"); + log.info("Shutting down embedded timeline server"); if (embeddedTimelineService.isPresent()) { embeddedTimelineService.get().stopForBasePath(cfg.targetBasePath); } @@ -1303,26 +1424,6 @@ public void close() { } - public HoodieStorage getStorage() { - return storage; - } - - public TypedProperties getProps() { - return props; - } - - public Config getCfg() { - return cfg; - } - - public Option getCommitsTimelineOpt() { - return commitsTimelineOpt; - } - - public HoodieIngestionMetrics getMetrics() { - return metrics; - } - /** * Schedule clustering. * Called from {@link HoodieStreamer} when async clustering is enabled. @@ -1349,131 +1450,40 @@ private Option getLatestCommittedInstant() { } } + @Getter class WriteClientWriteResult { + + @Setter private Map> partitionToReplacedFileIds = Collections.emptyMap(); private final JavaRDD writeStatusRDD; public WriteClientWriteResult(JavaRDD writeStatusRDD) { this.writeStatusRDD = writeStatusRDD; } - - public Map> getPartitionToReplacedFileIds() { - return partitionToReplacedFileIds; - } - - public void setPartitionToReplacedFileIds(Map> partitionToReplacedFileIds) { - this.partitionToReplacedFileIds = partitionToReplacedFileIds; - } - - public JavaRDD getWriteStatusRDD() { - return writeStatusRDD; - } } /** - * WriteStatus Validator for commits to hoodie streamer data table. - * The writes to error table is taken care as well. + * Sums {@link WriteStatus#getTotalRecords()} and {@link WriteStatus#getTotalErrorRecords()} over the + * given RDD in a single Spark action, returned as a {@code (totalRecords, totalErroredRecords)} tuple. + * + *

    {@code aggregate} (not {@code reduce}) is used so a 0-partition RDD returns {@code (0L, 0L)} + * instead of raising {@code UnsupportedOperationException}; the mutable {@code long[2]} accumulator + * avoids per-record allocations. */ - static class HoodieStreamerWriteStatusValidator implements WriteStatusValidator { - - private final boolean commitOnErrors; - private final String instantTime; - private final HoodieStreamer.Config cfg; - private final Option errorTableWriter; - private final Option> errorTableWriteStatusRDDOpt; - private final HoodieErrorTableConfig.ErrorWriteFailureStrategy errorWriteFailureStrategy; - private final boolean isErrorTableWriteUnificationEnabled; - private final SparkRDDWriteClient writeClient; - private final Option latestCommittedInstant; - private final AtomicLong totalSuccessfulRecords; - - HoodieStreamerWriteStatusValidator(boolean commitOnErrors, - String instantTime, - HoodieStreamer.Config cfg, - Option errorTableWriter, - Option> errorTableWriteStatusRDDOpt, - HoodieErrorTableConfig.ErrorWriteFailureStrategy errorWriteFailureStrategy, - boolean isErrorTableWriteUnificationEnabled, - SparkRDDWriteClient writeClient, - Option latestCommittedInstant, - AtomicLong totalSuccessfulRecords) { - this.commitOnErrors = commitOnErrors; - this.instantTime = instantTime; - this.cfg = cfg; - this.errorTableWriter = errorTableWriter; - this.errorTableWriteStatusRDDOpt = errorTableWriteStatusRDDOpt; - this.errorWriteFailureStrategy = errorWriteFailureStrategy; - this.isErrorTableWriteUnificationEnabled = isErrorTableWriteUnificationEnabled; - this.writeClient = writeClient; - this.latestCommittedInstant = latestCommittedInstant; - this.totalSuccessfulRecords = totalSuccessfulRecords; - } - - @Override - public boolean validate(long tableTotalRecords, long tableTotalErroredRecords, Option> writeStatusesOpt) { - - long totalRecords = tableTotalRecords; - long totalErroredRecords = tableTotalErroredRecords; - if (isErrorTableWriteUnificationEnabled) { - totalRecords += errorTableWriteStatusRDDOpt.map(status -> status.mapToDouble(WriteStatus::getTotalRecords).sum().longValue()).orElse(0L); - totalErroredRecords += errorTableWriteStatusRDDOpt.map(status -> status.mapToDouble(WriteStatus::getTotalErrorRecords).sum().longValue()).orElse(0L); - } - long totalSuccessfulRecords = totalRecords - totalErroredRecords; - this.totalSuccessfulRecords.set(totalSuccessfulRecords); - LOG.info("instantTime={}, totalRecords={}, totalErrorRecords={}, totalSuccessfulRecords={}", - instantTime, totalRecords, totalErroredRecords, totalSuccessfulRecords); - if (totalRecords == 0) { - LOG.info("No new data, perform empty commit."); - } - boolean hasErrorRecords = totalErroredRecords > 0; - if (!hasErrorRecords || commitOnErrors) { - if (hasErrorRecords) { - LOG.warn("Some records failed to be merged but forcing commit since commitOnErrors set. Errors/Total={}/{}", - totalErroredRecords, totalRecords); - } - } - - if (errorTableWriter.isPresent()) { - boolean errorTableSuccess = true; - // Commit the error events triggered so far to the error table - if (isErrorTableWriteUnificationEnabled && errorTableWriteStatusRDDOpt.isPresent()) { - errorTableSuccess = errorTableWriter.get().commit(errorTableWriteStatusRDDOpt.get()); - } else if (!isErrorTableWriteUnificationEnabled) { - errorTableSuccess = errorTableWriter.get().upsertAndCommit(instantTime, latestCommittedInstant); - } - if (!errorTableSuccess) { - switch (errorWriteFailureStrategy) { - case ROLLBACK_COMMIT: - LOG.info("Commit " + instantTime + " failed!"); - writeClient.rollback(instantTime); - throw new HoodieStreamerWriteException("Error table commit failed"); - case LOG_ERROR: - LOG.error("Error Table write failed for instant " + instantTime); - break; - default: - throw new HoodieStreamerWriteException("Write failure strategy not implemented for " + errorWriteFailureStrategy); - } - } - } - boolean canProceed = !hasErrorRecords || commitOnErrors; - if (canProceed) { - return canProceed; - } else { - LOG.error("Delta Sync found errors when writing. Errors/Total=" + totalErroredRecords + "/" + totalRecords); - LOG.error("Printing out the top 100 errors"); - ValidationUtils.checkArgument(writeStatusesOpt.isPresent(), "RDD is expected to be present when there are errors "); - HoodieJavaRDD.getJavaRDD(writeStatusesOpt.get()).filter(WriteStatus::hasErrors).take(100).forEach(writeStatus -> { - LOG.error("Global error " + writeStatus.getGlobalError()); - if (!writeStatus.getErrors().isEmpty()) { - writeStatus.getErrors().forEach((k,v) -> { - LOG.trace("Error for key %s : %s ", k, v); - }); - } + @VisibleForTesting + static Tuple2 sumRecordAndErrorCounts(JavaRDD writeStatuses) { + long[] counts = writeStatuses.aggregate( + new long[]{0L, 0L}, + (acc, status) -> { + acc[0] += status.getTotalRecords(); + acc[1] += status.getTotalErrorRecords(); + return acc; + }, + (left, right) -> { + left[0] += right[0]; + left[1] += right[1]; + return left; }); - // Rolling back instant - writeClient.rollback(instantTime); - throw new HoodieStreamerWriteException("Commit " + instantTime + " failed and rolled-back !"); - } - } + return new Tuple2<>(counts[0], counts[1]); } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamerCheckpointUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamerCheckpointUtils.java index 03db67b9d03f0..2b28e09e3a2cb 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamerCheckpointUtils.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamerCheckpointUtils.java @@ -26,8 +26,11 @@ import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.checkpoint.Checkpoint; import org.apache.hudi.common.table.checkpoint.CheckpointUtils; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.table.checkpoint.UnresolvedStreamerCheckpointBasedOnCfg; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.TimelineUtils; import org.apache.hudi.common.util.ConfigUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.StringUtils; @@ -39,13 +42,12 @@ import org.apache.hudi.utilities.config.KafkaSourceConfig; import org.apache.hudi.utilities.exception.HoodieStreamerException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.DATASOURCES_NOT_SUPPORTED_WITH_CKPT_V2; import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.HOODIE_INCREMENTAL_SOURCES; -import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.buildCheckpointFromConfigOverride; import static org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2; import static org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2.STREAMER_CHECKPOINT_RESET_KEY_V2; import static org.apache.hudi.common.table.timeline.InstantComparison.LESSER_THAN; @@ -53,8 +55,30 @@ import static org.apache.hudi.common.util.ConfigUtils.removeConfigFromProps; import static org.apache.hudi.table.upgrade.UpgradeDowngrade.needsUpgradeOrDowngrade; +@Slf4j public class StreamerCheckpointUtils { - private static final Logger LOG = LoggerFactory.getLogger(StreamerCheckpointUtils.class); + + /** + * Wraps a user-supplied checkpoint override string. For HoodieIncrSource family on table version + * 8+, returns {@link UnresolvedStreamerCheckpointBasedOnCfg} so the source can resolve V1/V2 + * cursor semantics from the override's prefix; for everything else returns V1. + */ + public static Checkpoint buildCheckpointFromConfigOverride( + String sourceClassName, int writeTableVersion, String checkpointToResume) { + return shouldTargetCheckpointV2(writeTableVersion, sourceClassName) + ? new UnresolvedStreamerCheckpointBasedOnCfg(checkpointToResume) + : new StreamerCheckpointV1(checkpointToResume); + } + + /** + * True only for the HoodieIncrSource family on table version 8+, where V2 (completion-time) + * cursor semantics differ from V1 (requested-time). Every other source operates on V1 only. + */ + public static boolean shouldTargetCheckpointV2(int writeTableVersion, String sourceClassName) { + return writeTableVersion >= HoodieTableVersion.EIGHT.versionCode() + && HOODIE_INCREMENTAL_SOURCES.contains(sourceClassName) + && !DATASOURCES_NOT_SUPPORTED_WITH_CKPT_V2.contains(sourceClassName); + } /** * The first phase of checkpoint resolution - read the checkpoint configs from 2 sources and resolve @@ -115,7 +139,7 @@ static void assertNoCheckpointOverrideDuringUpgradeForHoodieIncSource(HoodieTabl private static Option useCkpFromOverrideConfigIfAny( HoodieStreamer.Config streamerConfig, TypedProperties props, Option checkpoint) { - LOG.debug("Checkpoint from config: {}", streamerConfig.checkpoint); + log.debug("Checkpoint from config: {}", streamerConfig.checkpoint); if (!checkpoint.isPresent() && streamerConfig.checkpoint != null) { int writeTableVersion = ConfigUtils.getIntWithAltKeys(props, HoodieWriteConfig.WRITE_TABLE_VERSION); checkpoint = Option.of(buildCheckpointFromConfigOverride(streamerConfig.sourceClassName, writeTableVersion, streamerConfig.checkpoint)); @@ -156,7 +180,7 @@ static Option resolveCheckpointBetweenConfigAndPrevCommit(HoodieTime if (commitMetadataOption.isPresent()) { HoodieCommitMetadata commitMetadata = commitMetadataOption.get(); Checkpoint checkpointFromCommit = CheckpointUtils.getCheckpoint(commitMetadata); - LOG.debug("Checkpoint reset from metadata: {}", checkpointFromCommit.getCheckpointResetKey()); + log.debug("Checkpoint reset from metadata: {}", checkpointFromCommit.getCheckpointResetKey()); if (ignoreCkpCfgPrevailsOverCkpFromPrevCommit(streamerConfig, checkpointFromCommit)) { // we ignore any existing checkpoint and start ingesting afresh resumeCheckpoint = Option.empty(); @@ -201,21 +225,12 @@ private static boolean ignoreCkpCfgPrevailsOverCkpFromPrevCommit(HoodieStreamer. public static Option> getLatestInstantAndCommitMetadataWithValidCheckpointInfo(HoodieTimeline timeline) throws IOException { - return (Option>) timeline.getReverseOrderedInstants().map(instant -> { - try { - HoodieCommitMetadata commitMetadata = timeline.readCommitMetadata(instant); - if (!StringUtils.isNullOrEmpty(commitMetadata.getMetadata(HoodieStreamer.CHECKPOINT_KEY)) - || !StringUtils.isNullOrEmpty(commitMetadata.getMetadata(HoodieStreamer.CHECKPOINT_RESET_KEY)) - || !StringUtils.isNullOrEmpty(commitMetadata.getMetadata(STREAMER_CHECKPOINT_KEY_V2)) - || !StringUtils.isNullOrEmpty(commitMetadata.getMetadata(STREAMER_CHECKPOINT_RESET_KEY_V2))) { - return Option.of(Pair.of(instant.toString(), commitMetadata)); - } else { - return Option.empty(); - } - } catch (IOException e) { - throw new HoodieIOException("Failed to parse HoodieCommitMetadata for " + instant.toString(), e); - } - }).filter(Option::isPresent).findFirst().orElse(Option.empty()); + return TimelineUtils.getLatestInstantAndCommitMetadataWithValidCheckpointInfo( + timeline, + HoodieStreamer.CHECKPOINT_KEY, + HoodieStreamer.CHECKPOINT_RESET_KEY, + STREAMER_CHECKPOINT_KEY_V2, + STREAMER_CHECKPOINT_RESET_KEY_V2); } public static Option getLatestCommitMetadataWithValidCheckpointInfo(HoodieTimeline timeline) throws IOException { diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SuccessfulRecordCounter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SuccessfulRecordCounter.java new file mode 100644 index 0000000000000..01439249d760d --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/SuccessfulRecordCounter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.util.Option; + +import org.apache.spark.api.java.JavaRDD; + +import java.util.List; +import java.util.Objects; + +/** + * Computes record counts for a HoodieStreamer commit, summing across the data-table + * write statuses and (optionally) the error-table write statuses when error-table + * write unification is enabled. + * + *

    Extracted from {@code HoodieStreamerWriteStatusValidator} (issue #18750) so the + * counting logic can be invoked from the explicit pre-commit orchestration in + * {@code StreamSync} without going through the {@code WriteStatusValidator} callback.

    + */ +public final class SuccessfulRecordCounter { + + private SuccessfulRecordCounter() { + } + + /** + * Compute total / errored / successful record counts from a pre-collected list of write statuses. + * + * @param dataTableWriteStatuses Pre-collected data-table write statuses. Must not be null. + * @param errorTableWriteStatusRDDOpt Optional error-table write status RDD; only consulted + * when unification is enabled. Must not be null + * ({@link Option#empty()} when no error table). + * @param isErrorTableWriteUnificationEnabled Whether error-table records contribute to the totals. + * @return immutable {@link Counts} snapshot. + */ + public static Counts compute(List dataTableWriteStatuses, + Option> errorTableWriteStatusRDDOpt, + boolean isErrorTableWriteUnificationEnabled) { + Objects.requireNonNull(dataTableWriteStatuses, "dataTableWriteStatuses"); + Objects.requireNonNull(errorTableWriteStatusRDDOpt, "errorTableWriteStatusRDDOpt"); + + long totalRecords = 0L; + long totalErrorRecords = 0L; + for (WriteStatus ws : dataTableWriteStatuses) { + totalRecords += ws.getTotalRecords(); + totalErrorRecords += ws.getTotalErrorRecords(); + } + if (isErrorTableWriteUnificationEnabled && errorTableWriteStatusRDDOpt.isPresent()) { + JavaRDD errorRdd = errorTableWriteStatusRDDOpt.get(); + long[] sums = errorRdd.aggregate( + new long[]{0L, 0L}, + (acc, ws) -> new long[]{acc[0] + ws.getTotalRecords(), acc[1] + ws.getTotalErrorRecords()}, + (a, b) -> new long[]{a[0] + b[0], a[1] + b[1]}); + totalRecords += sums[0]; + totalErrorRecords += sums[1]; + } + return new Counts(totalRecords, totalErrorRecords); + } + + /** Immutable count snapshot. */ + public static final class Counts { + public static final Counts ZERO = new Counts(0L, 0L); + + private final long totalRecords; + private final long totalErrorRecords; + + public Counts(long totalRecords, long totalErrorRecords) { + this.totalRecords = totalRecords; + this.totalErrorRecords = totalErrorRecords; + } + + public long getTotalRecords() { + return totalRecords; + } + + public long getTotalErrorRecords() { + return totalErrorRecords; + } + + public long getTotalSuccessfulRecords() { + return totalRecords - totalErrorRecords; + } + + public boolean hasErrors() { + return totalErrorRecords > 0; + } + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/TableExecutionContext.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/TableExecutionContext.java index f5f523a5c2135..e24269a6afa4b 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/TableExecutionContext.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/TableExecutionContext.java @@ -21,66 +21,22 @@ import org.apache.hudi.common.config.TypedProperties; -import java.util.Objects; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; /** * Wrapper over TableConfig objects. * Useful for incrementally syncing multiple tables one by one via HoodieMultiTableStreamer.java class. */ +@Getter +@Setter +@EqualsAndHashCode public class TableExecutionContext { private TypedProperties properties; + @EqualsAndHashCode.Exclude private HoodieStreamer.Config config; private String database; private String tableName; - - public HoodieStreamer.Config getConfig() { - return config; - } - - public void setConfig(HoodieStreamer.Config config) { - this.config = config; - } - - public String getDatabase() { - return database; - } - - public void setDatabase(String database) { - this.database = database; - } - - public String getTableName() { - return tableName; - } - - public void setTableName(String tableName) { - this.tableName = tableName; - } - - public TypedProperties getProperties() { - return properties; - } - - public void setProperties(TypedProperties properties) { - this.properties = properties; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - TableExecutionContext that = (TableExecutionContext) o; - return Objects.equals(properties, that.properties) && Objects.equals(database, that.database) && Objects.equals(tableName, that.tableName); - } - - @Override - public int hashCode() { - return Objects.hash(properties, database, tableName); - } } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/WriteErrorReporter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/WriteErrorReporter.java new file mode 100644 index 0000000000000..2cb7c2fe6050b --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/WriteErrorReporter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Logs the first N errored write statuses so the operator can triage a failed commit. + * + *

    Extracted from {@code HoodieStreamerWriteStatusValidator} (#18750).

    + */ +public final class WriteErrorReporter { + + private static final Logger LOG = LoggerFactory.getLogger(WriteErrorReporter.class); + + private static final int DEFAULT_MAX_ERRORS = 100; + + private WriteErrorReporter() { + } + + public static void logTopErrors(List writeStatuses) { + logTopErrors(writeStatuses, DEFAULT_MAX_ERRORS); + } + + /** + * Log up to {@code maxErrors} errored write statuses from a pre-collected list. Each errored + * status's global error is logged at ERROR; per-key errors are logged at TRACE. The header + * line is INFO. No-op when the list is null or {@code maxErrors <= 0}. + */ + public static void logTopErrors(List writeStatuses, int maxErrors) { + if (writeStatuses == null || maxErrors <= 0) { + return; + } + LOG.info("Printing out the top {} errored write statuses", maxErrors); + writeStatuses.stream() + .filter(WriteStatus::hasErrors) + .limit(maxErrors) + .forEach(WriteErrorReporter::logOne); + } + + private static void logOne(WriteStatus writeStatus) { + LOG.error("Global error: {}", writeStatus.getGlobalError()); + if (!writeStatus.getErrors().isEmpty()) { + writeStatus.getErrors().forEach((k, v) -> LOG.trace("Error for key {} : {}", k, v)); + } + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkKafkaOffsetValidator.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkKafkaOffsetValidator.java new file mode 100644 index 0000000000000..589e09e96de89 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkKafkaOffsetValidator.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.client.validator.StreamingOffsetValidator; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.CheckpointUtils.CheckpointFormat; + +/** + * Spark/HoodieStreamer-specific Kafka offset validator. + * + *

    Validates that the number of records written matches the Kafka offset difference + * between the current and previous HoodieStreamer checkpoints. The active checkpoint key + * (V1 {@code deltastreamer.checkpoint.key} or V2 {@code streamer.checkpoint.key.v2}) is + * resolved at validation time via {@link org.apache.hudi.common.table.checkpoint.CheckpointUtils#getCheckpoint}, + * so this validator works against tables written with either checkpoint key version.

    + * + *

    Configuration: + *

      + *
    • {@code hoodie.precommit.validators}: Include + * {@code org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator}
    • + *
    • {@code hoodie.precommit.validators.streaming.offset.tolerance.percentage}: + * Acceptable deviation (default: 0.0 = strict)
    • + *
    • {@code hoodie.precommit.validators.failure.policy}: + * FAIL (default) or WARN_LOG
    • + *

    + * + *

    This validator is primarily intended for append-only ingestion from Kafka via HoodieStreamer. + * For upsert workloads with deduplication, configure a higher tolerance or use WARN_LOG.

    + * + *

    Important: This class extends {@link org.apache.hudi.client.validator.BasePreCommitValidator} + * and is invoked by {@link SparkStreamerValidatorUtils}, NOT by {@code SparkValidatorUtils} + * (which expects {@code SparkPreCommitValidator} with a different constructor signature). + * Listing this class in {@code hoodie.precommit.validators} while also using the standard + * Spark table write-path validators will cause an instantiation failure in {@code SparkValidatorUtils}. + * Use this validator exclusively with HoodieStreamer pipelines.

    + */ +public class SparkKafkaOffsetValidator extends StreamingOffsetValidator { + + public SparkKafkaOffsetValidator(TypedProperties config) { + super(config, CheckpointFormat.SPARK_KAFKA); + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkStreamerValidatorUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkStreamerValidatorUtils.java new file mode 100644 index 0000000000000..474215ec0f3c1 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkStreamerValidatorUtils.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.client.validator.BasePreCommitValidator; +import org.apache.hudi.client.validator.ValidationContext; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; +import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.exception.HoodieValidationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Utility for running pre-commit validators in the HoodieStreamer commit flow. + * + *

    Instantiates and executes validators configured via + * {@code hoodie.precommit.validators}. Each validator must extend + * {@link BasePreCommitValidator} and have a constructor that accepts + * {@link TypedProperties}.

    + * + *

    Called from {@code StreamSync.writeToSinkAndDoMetaSync()} before + * the commit is finalized.

    + * + *

    Note on validator compatibility: This utility uses a different instantiation + * mechanism than {@code SparkValidatorUtils} (used by the Spark table write path). + * {@code SparkValidatorUtils} expects validators implementing {@code SparkPreCommitValidator} + * with a {@code (HoodieSparkTable, HoodieEngineContext, HoodieWriteConfig)} constructor. + * Validators registered here (e.g. {@link SparkKafkaOffsetValidator}) extend + * {@link BasePreCommitValidator} with a {@code (TypedProperties)} constructor and + * are NOT compatible with {@code SparkValidatorUtils}. Do not mix them under the same + * {@code hoodie.precommit.validators} config if both paths are active.

    + */ +public class SparkStreamerValidatorUtils { + + private static final Logger LOG = LoggerFactory.getLogger(SparkStreamerValidatorUtils.class); + + /** + * Run all configured pre-commit validators. + * + *

    The caller is responsible for caching and unpersisting the source RDD if needed. + * This method accepts pre-collected write statuses to avoid a second DAG evaluation — + * the caller should cache the RDD, collect to this list, call this method, then pass + * the same RDD to {@code writeClient.commit()}, and unpersist after commit completes.

    + * + * @param props Configuration properties containing validator class names + * @param instantTime Commit instant time + * @param writeStatuses Pre-collected write statuses from Spark write operations + * @param checkpointCommitMetadata Extra metadata being committed (contains checkpoint info) + * @param metaClient Table meta client for timeline access and previous commit lookup + * @throws HoodieValidationException if any validator fails with FAIL policy + */ + public static void runValidators(TypedProperties props, + String instantTime, + List writeStatuses, + Map checkpointCommitMetadata, + HoodieTableMetaClient metaClient) { + String validatorClassNames = props.getString( + HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.key(), + HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.defaultValue()); + + if (StringUtils.isNullOrEmpty(validatorClassNames)) { + return; + } + + HoodieCommitMetadata currentMetadata = buildCommitMetadata(writeStatuses, checkpointCommitMetadata); + List writeStats = writeStatuses.stream() + .map(WriteStatus::getStat) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Option previousCommitMetadata = loadPreviousCommitMetadata(metaClient); + + ValidationContext context = new SparkValidationContext( + instantTime, + Option.of(currentMetadata), + Option.of(writeStats), + previousCommitMetadata, + metaClient); + + List classNames = Arrays.stream(validatorClassNames.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + for (String className : classNames) { + try { + Class clazz = Class.forName(className); + if (!BasePreCommitValidator.class.isAssignableFrom(clazz)) { + LOG.warn("Skipping validator {} in HoodieStreamer path — it does not extend BasePreCommitValidator. " + + "If this is a SparkPreCommitValidator (e.g. SqlQueryEqualityPreCommitValidator), " + + "it must be invoked via SparkValidatorUtils in the standard Spark write path instead.", className); + continue; + } + BasePreCommitValidator validator = (BasePreCommitValidator) + ReflectionUtils.loadClass(className, new Class[] {TypedProperties.class}, props); + LOG.info("Running pre-commit validator: {} for instant: {}", className, instantTime); + validator.validateWithMetadata(context); + LOG.info("Pre-commit validator {} passed for instant: {}", className, instantTime); + } catch (HoodieValidationException e) { + LOG.error("Pre-commit validator {} failed for instant: {}", className, instantTime, e); + throw e; + } catch (Exception e) { + LOG.error("Failed to instantiate or run validator: {}", className, e); + throw new HoodieValidationException( + "Failed to run pre-commit validator: " + className, e); + } + } + } + + /** + * Build a pre-commit snapshot of {@link HoodieCommitMetadata} from write statuses and extra metadata. + * + *

    This is intentionally a partial/preview object used only for validation — it contains + * write stats and checkpoint extra-metadata, but omits fields that are not available before the + * commit (e.g. schema, operation type). Validators should treat this as a read-only snapshot + * of what will be committed, not a fully-constructed commit record.

    + */ + private static HoodieCommitMetadata buildCommitMetadata( + List writeStatuses, Map extraMetadata) { + HoodieCommitMetadata metadata = new HoodieCommitMetadata(); + + // Add write stats + for (WriteStatus status : writeStatuses) { + HoodieWriteStat stat = status.getStat(); + if (stat != null) { + metadata.addWriteStat(stat.getPartitionPath(), stat); + } + } + + // Add extra metadata (includes checkpoint info like deltastreamer.checkpoint.key) + if (extraMetadata != null) { + extraMetadata.forEach(metadata::addMetadata); + } + + return metadata; + } + + /** + * Load the previous completed commit metadata from the timeline. + */ + private static Option loadPreviousCommitMetadata(HoodieTableMetaClient metaClient) { + try { + HoodieTimeline completedTimeline = metaClient.reloadActiveTimeline() + .getWriteTimeline() + .filterCompletedInstants(); + Option lastInstant = completedTimeline.lastInstant(); + if (lastInstant.isPresent()) { + return Option.of(completedTimeline.readCommitMetadata(lastInstant.get())); + } + } catch (IOException e) { + throw new HoodieIOException("Failed to load previous commit metadata", e); + } + return Option.empty(); + } + + private SparkStreamerValidatorUtils() { + // Utility class + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkValidationContext.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkValidationContext.java new file mode 100644 index 0000000000000..1d46e13aeedbd --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkValidationContext.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.client.validator.ValidationContext; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.util.Option; + +import java.util.List; + +/** + * Spark/HoodieStreamer implementation of {@link ValidationContext}. + * + *

    Constructed from data available in {@code StreamSync.writeToSinkAndDoMetaSync()} + * before the commit is finalized. Provides validators with access to commit metadata, + * write statistics, and previous commit information for streaming offset validation.

    + * + *

    Unlike Flink's implementation, Spark can optionally provide active timeline access + * via {@link HoodieTableMetaClient} for richer validation patterns.

    + */ +public class SparkValidationContext implements ValidationContext { + + private final String instantTime; + private final Option commitMetadata; + private final Option> writeStats; + private final Option previousCommitMetadata; + private final HoodieTableMetaClient metaClient; + + /** + * Create a Spark validation context with full timeline access. + * + * @param instantTime Current commit instant time + * @param commitMetadata Current commit metadata (with extraMetadata including checkpoints) + * @param writeStats Write statistics from write operations + * @param previousCommitMetadata Metadata from the previous completed commit + * @param metaClient Table meta client for timeline access (may be null for testing) + */ + public SparkValidationContext(String instantTime, + Option commitMetadata, + Option> writeStats, + Option previousCommitMetadata, + HoodieTableMetaClient metaClient) { + this.instantTime = instantTime; + this.commitMetadata = commitMetadata; + this.writeStats = writeStats; + this.previousCommitMetadata = previousCommitMetadata; + this.metaClient = metaClient; + } + + /** + * Create a Spark validation context without timeline access (for testing). + * + * @param instantTime Current commit instant time + * @param commitMetadata Current commit metadata (with extraMetadata including checkpoints) + * @param writeStats Write statistics from write operations + * @param previousCommitMetadata Metadata from the previous completed commit + */ + public SparkValidationContext(String instantTime, + Option commitMetadata, + Option> writeStats, + Option previousCommitMetadata) { + this(instantTime, commitMetadata, writeStats, previousCommitMetadata, null); + } + + @Override + public String getInstantTime() { + return instantTime; + } + + @Override + public Option getCommitMetadata() { + return commitMetadata; + } + + @Override + public Option> getWriteStats() { + return writeStats; + } + + /** + * Get the active timeline. Available when metaClient is provided. + * + * @throws UnsupportedOperationException if metaClient was not provided + */ + @Override + public HoodieActiveTimeline getActiveTimeline() { + if (metaClient == null) { + throw new UnsupportedOperationException( + "Active timeline is not available without HoodieTableMetaClient."); + } + return metaClient.getActiveTimeline(); + } + + /** + * Get the previous completed commit instant by querying the timeline. + * Returns {@link Option#empty()} if this is the first commit or metaClient is unavailable. + */ + @Override + public Option getPreviousCommitInstant() { + if (metaClient == null) { + return Option.empty(); + } + return metaClient.getActiveTimeline() + .getWriteTimeline() + .filterCompletedInstants() + .lastInstant(); + } + + @Override + public boolean isFirstCommit() { + return !previousCommitMetadata.isPresent(); + } + + @Override + public Option getPreviousCommitMetadata() { + return previousCommitMetadata; + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkWriteErrorValidator.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkWriteErrorValidator.java new file mode 100644 index 0000000000000..07591b5b79739 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/validator/SparkWriteErrorValidator.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.client.validator.BasePreCommitValidator; +import org.apache.hudi.client.validator.ValidationContext; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig.ValidationFailurePolicy; +import org.apache.hudi.exception.HoodieValidationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; + +/** + * Pre-commit validator that fails the commit when records failed to write. + * + *

    Equivalent of the legacy {@code HoodieStreamerWriteStatusValidator}'s boolean error check + * ({@code hasErrorRecords = totalErrorRecords > 0}), wired through the pre-commit validator + * framework (issue #18750). Pure validation: no side effects (no error-table commit, no + * top-100 error logging, no instant rollback). Those side effects are handled separately by + * {@code StreamSync}'s pre-commit orchestration.

    + * + *

    Relationship with the inline write-error gate in {@code StreamSync}: the default + * commit path in {@code StreamSync} already applies an equivalent error check via the + * {@code commitOnErrors} flag. This validator exists so that users running multiple validators + * (e.g. write-error + offset checks) can express a unified pass/fail story through a single + * {@code failure.policy} knob. Enabling this validator while leaving {@code commitOnErrors=false} + * means both checks run and either can block the commit — they are intentionally not mutually + * exclusive.

    + * + *

    Behavior mapping from the legacy HSWSV (data-table only — see caveat below):

    + *
      + *
    • {@code commitOnErrors = false} (HSWSV default) ↔ {@code failure.policy = FAIL}
    • + *
    • {@code commitOnErrors = true} ↔ {@code failure.policy = WARN_LOG}
    • + *
    + * + *

    Unification caveat: when {@code hoodie.errortable.write.unification.enabled=true}, + * HSWSV's error check summed errors across both the data-table and the error-table write + * statuses. This validator only sees data-table stats via {@link ValidationContext} (specifically + * {@link ValidationContext#getTotalWriteErrors()} / {@link ValidationContext#getTotalRecordsWritten()}, + * which are derived from {@code HoodieWriteStat} on the data table). Under unification it is + * therefore strictly weaker than HSWSV: error-table-only errors will not trip this validator. Users + * who rely on unified error counts should keep the inline {@code commitOnErrors} gate in + * {@code StreamSync} enabled (i.e. leave {@code --commit-on-errors} off), which still consults + * the unified count via {@code SuccessfulRecordCounter}.

    + * + *

    Configuration:

    + *
      + *
    • {@code hoodie.precommit.validators}: Include + * {@code org.apache.hudi.utilities.streamer.validator.SparkWriteErrorValidator}
    • + *
    • {@code hoodie.precommit.validators.failure.policy}: FAIL (default) or WARN_LOG
    • + *
    + * + *

    Like {@link SparkKafkaOffsetValidator}, this class extends {@link BasePreCommitValidator} + * and must be invoked via {@link SparkStreamerValidatorUtils} — not {@code SparkValidatorUtils}, + * which expects a different constructor signature.

    + */ +public class SparkWriteErrorValidator extends BasePreCommitValidator { + + private static final Logger LOG = LoggerFactory.getLogger(SparkWriteErrorValidator.class); + + private final ValidationFailurePolicy failurePolicy; + + public SparkWriteErrorValidator(TypedProperties config) { + super(config); + String policyStr = config.getString( + HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), + HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.defaultValue()); + try { + this.failurePolicy = ValidationFailurePolicy.valueOf(policyStr); + } catch (IllegalArgumentException e) { + throw new HoodieValidationException(String.format( + "Invalid value '%s' for %s. Allowed values: %s.", + policyStr, + HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), + Arrays.toString(ValidationFailurePolicy.values())), e); + } + } + + @Override + public void validateWithMetadata(ValidationContext context) throws HoodieValidationException { + long totalErrors = context.getTotalWriteErrors(); + long totalRecordsWritten = context.getTotalRecordsWritten(); + // Total considered for the commit = successfully-written + failed. HSWSV computed this from + // the raw WriteStatus RDD; we derive the equivalent from HoodieWriteStat fields exposed by + // ValidationContext. + long totalRecords = totalRecordsWritten + totalErrors; + + if (totalRecords == 0) { + // Empty commit (mirrors HSWSV "No new data, perform empty commit."). + LOG.info("Empty commit (no records written, no errors). Skipping write-error validation " + + "for instant {}.", context.getInstantTime()); + return; + } + + if (totalErrors == 0) { + LOG.info("Write-error validation passed for instant {}: 0 errors out of {} records.", + context.getInstantTime(), totalRecords); + return; + } + + String errorMsg = String.format( + "Write-error validation failed for instant %s. " + + "Errors: %d, Total: %d. " + + "To allow commits despite write errors, both %s=WARN_LOG (bypasses this validator) " + + "and --commit-on-errors (bypasses the StreamSync inline gate) are required.", + context.getInstantTime(), totalErrors, totalRecords, + HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key()); + + if (failurePolicy == ValidationFailurePolicy.WARN_LOG) { + LOG.warn("{} (failure policy is WARN_LOG, commit will proceed)", errorMsg); + } else { + throw new HoodieValidationException(errorMsg); + } + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ChainedTransformer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ChainedTransformer.java index 726770e5ad4a8..79ad5ec3dcf19 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ChainedTransformer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ChainedTransformer.java @@ -27,6 +27,8 @@ import org.apache.hudi.utilities.exception.HoodieTransformPlanException; import org.apache.hudi.utilities.streamer.HoodieStreamer; +import lombok.AccessLevel; +import lombok.Getter; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; @@ -141,6 +143,8 @@ public StructType transformedSchema(JavaSparkContext jsc, SparkSession sparkSess } protected static class TransformerInfo { + + @Getter(AccessLevel.PROTECTED) private final Transformer transformer; private final Option idOpt; @@ -154,10 +158,6 @@ private TransformerInfo(Transformer transformer) { this.idOpt = Option.empty(); } - protected Transformer getTransformer() { - return transformer; - } - private boolean hasIdentifier() { return idOpt.isPresent(); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/FlatteningTransformer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/FlatteningTransformer.java index 1256491528d1b..76f21a308e39c 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/FlatteningTransformer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/FlatteningTransformer.java @@ -21,24 +21,23 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.utilities.exception.HoodieTransformExecutionException; +import lombok.extern.slf4j.Slf4j; 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.types.StructField; import org.apache.spark.sql.types.StructType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.UUID; /** * Transformer that can flatten nested objects. It currently doesn't unnest arrays. */ +@Slf4j public class FlatteningTransformer implements Transformer { private static final String TMP_TABLE = "HUDI_SRC_TMP_TABLE_"; - private static final Logger LOG = LoggerFactory.getLogger(FlatteningTransformer.class); /** * Configs supported. @@ -49,7 +48,7 @@ public Dataset apply(JavaSparkContext jsc, SparkSession sparkSession, Datas try { // tmp table name doesn't like dashes String tmpTable = TMP_TABLE.concat(UUID.randomUUID().toString().replace("-", "_")); - LOG.info("Registering tmp table : " + tmpTable); + log.info("Registering tmp table: {}", tmpTable); rowDataset.createOrReplaceTempView(tmpTable); Dataset transformed = sparkSession.sql("select " + flattenSchema(rowDataset.schema(), null) + " from " + tmpTable); sparkSession.catalog().dropTempView(tmpTable); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlFileBasedTransformer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlFileBasedTransformer.java index cdef1677e587c..1e8af287f8090 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlFileBasedTransformer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlFileBasedTransformer.java @@ -23,14 +23,13 @@ import org.apache.hudi.utilities.config.SqlTransformerConfig; import org.apache.hudi.utilities.exception.HoodieTransformExecutionException; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; 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.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.Scanner; @@ -56,10 +55,9 @@ *

    * SELECT * FROM tmp_personal_trips; */ +@Slf4j public class SqlFileBasedTransformer implements Transformer { - private static final Logger LOG = LoggerFactory.getLogger(SqlFileBasedTransformer.class); - private static final String SRC_PATTERN = ""; private static final String TMP_TABLE = "HOODIE_SRC_TMP_TABLE_"; @@ -75,19 +73,19 @@ public Dataset apply( final FileSystem fs = HadoopFSUtils.getFs(sqlFile, jsc.hadoopConfiguration(), true); // tmp table name doesn't like dashes final String tmpTable = TMP_TABLE.concat(UUID.randomUUID().toString().replace("-", "_")); - LOG.info("Registering tmp table: {}", tmpTable); + log.info("Registering tmp table: {}", tmpTable); rowDataset.createOrReplaceTempView(tmpTable); try (final Scanner scanner = new Scanner(fs.open(new Path(sqlFile)), "UTF-8")) { Dataset rows = null; // each sql statement is separated with semicolon hence set that as delimiter. scanner.useDelimiter(";"); - LOG.info("SQL Query for transformation:"); + log.info("SQL Query for transformation:"); while (scanner.hasNext()) { String sqlStr = scanner.next(); sqlStr = sqlStr.replaceAll(SRC_PATTERN, tmpTable).trim(); if (!sqlStr.isEmpty()) { - LOG.info(sqlStr); + log.info(sqlStr); // overwrite the same dataset object until the last statement then return. rows = sparkSession.sql(sqlStr); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlQueryBasedTransformer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlQueryBasedTransformer.java index 290e3a69432ad..9013ab04b9b3f 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlQueryBasedTransformer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/SqlQueryBasedTransformer.java @@ -22,12 +22,11 @@ import org.apache.hudi.utilities.config.SqlTransformerConfig; import org.apache.hudi.utilities.exception.HoodieTransformExecutionException; +import lombok.extern.slf4j.Slf4j; 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.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.UUID; @@ -38,10 +37,9 @@ * * The query should reference the source as a table named "\" */ +@Slf4j public class SqlQueryBasedTransformer implements Transformer { - private static final Logger LOG = LoggerFactory.getLogger(SqlQueryBasedTransformer.class); - private static final String SRC_PATTERN = ""; private static final String TMP_TABLE = "HOODIE_SRC_TMP_TABLE_"; @@ -53,10 +51,10 @@ public Dataset apply(JavaSparkContext jsc, SparkSession sparkSession, Datas try { // tmp table name doesn't like dashes String tmpTable = TMP_TABLE.concat(UUID.randomUUID().toString().replace("-", "_")); - LOG.info("Registering tmp table: {}", tmpTable); + log.info("Registering tmp table: {}", tmpTable); rowDataset.createOrReplaceTempView(tmpTable); String sqlStr = transformerSQL.replaceAll(SRC_PATTERN, tmpTable); - LOG.debug("SQL Query for transformation: {}", sqlStr); + log.debug("SQL Query for transformation: {}", sqlStr); Dataset transformed = sparkSession.sql(sqlStr); sparkSession.catalog().dropTempView(tmpTable); return transformed; diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHiveIncrementalPuller.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHiveIncrementalPuller.java index 42e0ee8748638..5bfb6933fdc6a 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHiveIncrementalPuller.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHiveIncrementalPuller.java @@ -67,7 +67,7 @@ public static void cleanUpClass() throws Exception { @BeforeEach public void setUp() throws Exception { - HiveTestUtil.setUp(Option.empty(), true); + HiveTestUtil.setUp(Option.empty(), true, tempDir); } @AfterEach diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java index 9aebbfd4dc3f1..49a8f115c9517 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java @@ -37,13 +37,16 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.log.HoodieLogFormat; +import org.apache.hudi.common.table.log.HoodieLogFormatWriter; import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock; import org.apache.hudi.common.table.log.block.HoodieCommandBlock; import org.apache.hudi.common.table.log.block.HoodieLogBlock; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.table.timeline.InstantComparison; import org.apache.hudi.common.table.timeline.TimeGenerator; import org.apache.hudi.common.table.timeline.TimeGenerators; import org.apache.hudi.common.table.timeline.TimelineUtils; @@ -56,9 +59,14 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieCompactionConfig; +import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.HoodieValidationException; import org.apache.hudi.hadoop.fs.HadoopFSUtils; +import org.apache.hudi.metadata.HoodieTableMetadata; +import org.apache.hudi.metadata.HoodieTableMetadataWriter; +import org.apache.hudi.metadata.MetadataPartitionType; +import org.apache.hudi.metadata.SparkMetadataWriterFactory; import org.apache.hudi.stats.HoodieColumnRangeMetadata; import org.apache.hudi.stats.ValueMetadata; import org.apache.hudi.storage.HoodieStorage; @@ -68,6 +76,8 @@ import org.apache.hudi.testutils.HoodieSparkClientTestBase; import org.apache.hudi.testutils.SparkRDDValidationUtils; +import lombok.AccessLevel; +import lombok.Setter; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; @@ -925,10 +935,10 @@ private HoodieLogFile prepareLogFile(String fileId, String baseInstantTime, String instantTime, boolean writeDataBlock) throws IOException, InterruptedException { - try (HoodieLogFormat.Writer writer = HoodieLogFormat.newWriterBuilder() - .onParentPath(new StoragePath(tempDir.toString())) + try (HoodieLogFormat.Writer writer = HoodieLogFormatWriter.builder() + .withParentPath(new StoragePath(tempDir.toString())) .withFileExtension(HoodieLogFile.DELTA_EXTENSION) - .withFileId(fileId) + .withLogFileId(fileId) .withInstantTime(instantTime) .withStorage(storage) .withSizeThreshold(Long.MAX_VALUE).build()) { @@ -1345,6 +1355,7 @@ public void testRecordIndexMismatch(boolean ignoreFailed) throws IOException { } } + @Setter(AccessLevel.PACKAGE) class MockHoodieMetadataTableValidator extends HoodieMetadataTableValidator { private List metadataPartitionsToReturn; @@ -1355,18 +1366,6 @@ public MockHoodieMetadataTableValidator(JavaSparkContext jsc, Config cfg) { super(jsc, cfg); } - void setMetadataPartitionsToReturn(List metadataPartitionsToReturn) { - this.metadataPartitionsToReturn = metadataPartitionsToReturn; - } - - void setFsPartitionsToReturn(List fsPartitionsToReturn) { - this.fsPartitionsToReturn = fsPartitionsToReturn; - } - - void setPartitionCreationTime(Option partitionCreationTime) { - this.partitionCreationTime = partitionCreationTime; - } - @Override List getPartitionsFromFileSystem(HoodieEngineContext engineContext, HoodieTableMetaClient metaClient, HoodieTimeline completedTimeline) { return fsPartitionsToReturn; @@ -1449,6 +1448,7 @@ public void testRliValidationFalsePositiveCase() throws Exception { /** * Class to assist with testing a false positive case with RLI validation. */ + @Setter static class MockHoodieMetadataTableValidatorForRli extends HoodieMetadataTableValidator { private String destFilePath; @@ -1466,14 +1466,6 @@ JavaPairRDD> getRecordLocationsFromRLI(HoodieSparkE new File(destFilePath).renameTo(new File(originalFilePath)); return super.getRecordLocationsFromRLI(sparkEngineContext, basePath, latestCompletedCommit); } - - public void setDestFilePath(String destFilePath) { - this.destFilePath = destFilePath; - } - - public void setOriginalFilePath(String originalFilePath) { - this.originalFilePath = originalFilePath; - } } private String getTempLocation() { @@ -1692,4 +1684,119 @@ private void mockPartitionWithFiles(List partition1, HoodieStorage stora when(storage.listFiles(new StoragePath(basePath + "/" + partition))).thenReturn(Collections.singletonList(storagePathInfo)); } } + + /** + * On a table version 6 metadata table the partition-initialization deltacommits are the data instant + * they were derived from with a three-digit suffix appended (010 for FILES, 011 for RECORD_INDEX) - + * see {@code HoodieBackedTableMetadataWriterTableVersionSix#createIndexInitTimestamp}. Those instants + * sort AFTER the bare data instant under Hudi's lexicographic instant comparison, so a metadata-table + * snapshot taken as of the data table's latest completed commit must still include them. When the data + * table has no commit after the metadata table was initialized, failing to do so makes the record index + * read back empty and every data-table key is reported as missing from it. + *

    + * Table version 8 and above are unaffected: {@code HoodieBackedTableMetadataWriter#generateUniqueInstantTime} + * derives the init instants from {@code SOLO_COMMIT_TIMESTAMP}, which sorts below every data instant. + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testRecordIndexValidationWhenMdtInitializedAtLatestDataCommit(boolean validateContent) throws Exception { + Map writeOptions = new HashMap<>(); + writeOptions.put(DataSourceWriteOptions.TABLE_NAME().key(), "test_table"); + writeOptions.put("hoodie.table.name", "test_table"); + writeOptions.put(DataSourceWriteOptions.TABLE_TYPE().key(), "COPY_ON_WRITE"); + writeOptions.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "_row_key"); + writeOptions.put(DataSourceWriteOptions.PRECOMBINE_FIELD().key(), "timestamp"); + writeOptions.put(DataSourceWriteOptions.OPERATION().key(), WriteOperationType.BULK_INSERT.value()); + // Leave the metadata table off for the write; it is bootstrapped out of band below so that the + // data table's latest completed commit stays the instant the init instants are derived from. + writeOptions.put(HoodieMetadataConfig.ENABLE.key(), "false"); + // Table version 6 is the one whose metadata table initialization instants carry the suffix. + writeOptions.put(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), "6"); + + makeInsertDf("000", 50).write().format("hudi").options(writeOptions) + .mode(SaveMode.Overwrite) + .save(basePath); + + HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder() + .withPath(basePath) + .forTable("test_table") + .withWriteTableVersion(6) + .withMetadataConfig(HoodieMetadataConfig.newBuilder() + .enable(true) + .withEnableGlobalRecordLevelIndex(true) + .withRecordIndexFileGroupCount(1, 1) + .build()) + .build(); + HoodieTableMetaClient metaClientBeforeInit = HoodieTableMetaClient.builder() + .setBasePath(basePath).setConf(HadoopFSUtils.getStorageConf(jsc.hadoopConfiguration())).build(); + assertEquals(HoodieTableVersion.SIX, metaClientBeforeInit.getTableConfig().getTableVersion(), + "the suffixed initialization instants only exist on table version 6"); + + // Creating the writer initializes the FILES and RECORD_INDEX partitions from the filesystem + // without adding a commit to the data table. Go through the factory so the table-version-6 writer + // is selected, exactly as production does. + try (HoodieTableMetadataWriter ignored = SparkMetadataWriterFactory.create( + HadoopFSUtils.getStorageConf(jsc.hadoopConfiguration()), writeConfig, context, + Option.empty(), metaClientBeforeInit.getTableConfig())) { + // constructing the writer performs the initialization + } + + HoodieTableMetaClient dataMetaClient = HoodieTableMetaClient.reload(metaClientBeforeInit); + assertTrue(dataMetaClient.getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX), + "record index should be registered on the data table"); + String latestDataCommit = dataMetaClient.getActiveTimeline().getCommitsAndCompactionTimeline() + .filterCompletedInstants().lastInstant().get().requestedTime(); + HoodieTableMetaClient mdtMetaClient = HoodieTableMetaClient.builder() + .setBasePath(HoodieTableMetadata.getMetadataTableBasePath(basePath)) + .setConf(HadoopFSUtils.getStorageConf(jsc.hadoopConfiguration())).build(); + List mdtInstants = mdtMetaClient.getActiveTimeline().filterCompletedInstants() + .getInstantsAsStream().map(HoodieInstant::requestedTime).collect(Collectors.toList()); + assertTrue( + mdtInstants.stream().allMatch(instant -> instant.startsWith(latestDataCommit) + && instant.length() > latestDataCommit.length()), + "expected every metadata instant to be a suffixed extension of " + latestDataCommit + + " but got " + mdtInstants); + + HoodieMetadataTableValidator.Config config = new HoodieMetadataTableValidator.Config(); + config.basePath = "file:" + basePath; + config.validateLatestFileSlices = true; + // validateRecordIndexContent shadows validateRecordIndexCount, so toggling it exercises both paths. + config.validateRecordIndexContent = validateContent; + config.validateRecordIndexCount = true; + config.ignoreFailed = true; + + HoodieMetadataTableValidator validator = new HoodieMetadataTableValidator(jsc, config); + // Assert on the record index validation directly: doMetadataTableValidation() reports any + // non-HoodieValidationException as a successful run, so run() alone cannot distinguish a genuine + // pass from the read blowing up. + assertDoesNotThrow(() -> validator.validateRecordIndex(new HoodieSparkEngineContext(jsc), dataMetaClient), + "record index validation should pass against an intact record index"); + assertTrue(validator.run(), "validation should succeed against an intact record index"); + assertFalse(validator.hasValidationFailure(), () -> "unexpected validation failures: " + + validator.getThrowables()); + } + + @Test + public void testMetadataTableInstantForIncludesEveryDerivedInstant() { + String dataTableInstant = "20231012054834279"; + + String queryInstant = HoodieMetadataTableValidator.metadataTableInstantFor(dataTableInstant); + + assertEquals("20231012054834280", queryInstant, "the bound should advance the instant by one millisecond"); + // 010-013 are appended when a metadata table partition is initialized, 001-006 by the + // metadata-table-internal operations; all of them must fall inside the queried window. + for (String suffix : new String[] {"001", "002", "003", "004", "005", "006", "010", "011", "012", "013"}) { + assertTrue( + InstantComparison.compareTimestamps(dataTableInstant + suffix, InstantComparison.LESSER_THAN_OR_EQUALS, queryInstant), + () -> "metadata instant " + dataTableInstant + suffix + " should be included by " + queryInstant); + } + // ... while the next data table instant stays outside it. + assertTrue(InstantComparison.compareTimestamps("20231012054834281", InstantComparison.GREATER_THAN, queryInstant), + "a later data table instant should not be included"); + } + + @Test + public void testMetadataTableInstantForLeavesNonTimestampInstantUnchanged() { + assertEquals("100", HoodieMetadataTableValidator.metadataTableInstantFor("100")); + } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieRepairTool.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieRepairTool.java index 0541b429a27f5..a2e9b0e15e01a 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieRepairTool.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieRepairTool.java @@ -33,6 +33,7 @@ import org.apache.hudi.storage.StoragePath; import org.apache.hudi.testutils.providers.SparkProvider; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.HoodieSparkKryoRegistrar$; import org.apache.spark.SparkConf; @@ -45,8 +46,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.provider.Arguments; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.security.SecureRandom; @@ -67,8 +66,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +@Slf4j public class TestHoodieRepairTool extends HoodieCommonTestHarness implements SparkProvider { - private static final Logger LOG = LoggerFactory.getLogger(TestHoodieRepairTool.class); + // Instant time -> List> private static final Map>> BASE_FILE_INFO = new HashMap<>(); private static final Map>> LOG_FILE_INFO = new HashMap<>(); @@ -385,7 +385,7 @@ private List createDanglingDataFilesInFS(String parentPath) { storage.create(path, false); } } catch (IOException e) { - LOG.error("Error creating file: " + path); + log.error("Error creating file: {}", path); } return path.toString(); }) diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHudiHiveSyncJob.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHudiHiveSyncJob.java index 64abaf68a8db8..0099eaebbf390 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHudiHiveSyncJob.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHudiHiveSyncJob.java @@ -67,7 +67,7 @@ public class TestHudiHiveSyncJob { @BeforeEach void setUp() throws Exception { - HiveTestUtil.setUp(Option.empty(), true); + HiveTestUtil.setUp(Option.empty(), true, tempDir); } @AfterEach diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java index 7ea5a39af3fa7..8bece4c3df2c2 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java @@ -31,7 +31,6 @@ import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; -import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; @@ -57,6 +56,7 @@ import org.apache.hudi.utilities.testutils.KafkaTestUtils; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.hadoop.fs.Path; import org.apache.kafka.clients.consumer.ConsumerConfig; @@ -67,8 +67,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; @@ -84,17 +82,18 @@ import java.util.function.BooleanSupplier; import java.util.function.Function; +import static org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2; import static org.apache.hudi.common.table.timeline.InstantComparison.GREATER_THAN; import static org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps; import static org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR; import static org.apache.hudi.common.testutils.HoodieTestUtils.createMetaClient; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +@Slf4j public class HoodieDeltaStreamerTestBase extends UtilitiesTestBase { - private static final Logger LOG = LoggerFactory.getLogger(HoodieDeltaStreamerTestBase.class); - static final Random RANDOM = new Random(); static final String PROPS_FILENAME_TEST_SOURCE = "test-source.properties"; static final String PROPS_FILENAME_TEST_SOURCE1 = "test-source1.properties"; @@ -706,7 +705,7 @@ static HoodieDeltaStreamer.Config makeConfigForHudiIncrSrc(String srcBasePath, S static void assertAtleastNCompactionCommits(int minExpected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage, tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCommitAndReplaceTimeline().filterCompletedInstants(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numCompactionCommits = timeline.countInstants(); assertTrue(minExpected <= numCompactionCommits, "Got=" + numCompactionCommits + ", exp >=" + minExpected); } @@ -714,7 +713,7 @@ static void assertAtleastNCompactionCommits(int minExpected, String tablePath) { static void assertAtleastNDeltaCommits(int minExpected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getDeltaCommitTimeline().filterCompletedInstants(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ", exp >=" + minExpected); } @@ -722,7 +721,7 @@ static void assertAtleastNDeltaCommits(int minExpected, String tablePath) { static void assertAtleastNCompactionCommitsAfterCommit(int minExpected, String lastSuccessfulCommit, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCommitAndReplaceTimeline().findInstantsAfter(lastSuccessfulCommit).filterCompletedInstants(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numCompactionCommits = timeline.countInstants(); assertTrue(minExpected <= numCompactionCommits, "Got=" + numCompactionCommits + ", exp >=" + minExpected); } @@ -730,7 +729,7 @@ static void assertAtleastNCompactionCommitsAfterCommit(int minExpected, String l static void assertAtleastNDeltaCommitsAfterCommit(int minExpected, String lastSuccessfulCommit, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.reloadActiveTimeline().getDeltaCommitTimeline().findInstantsAfter(lastSuccessfulCommit).filterCompletedInstants(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ", exp >=" + minExpected); } @@ -742,10 +741,24 @@ static HoodieInstant assertCommitMetadata(String expected, String tablePath, int HoodieInstant lastInstant = timeline.lastInstant().get(); HoodieCommitMetadata commitMetadata = timeline.readCommitMetadata(lastInstant); assertEquals(totalCommits, timeline.countInstants()); + assertEquals(expected, commitMetadata.getMetadata(HoodieStreamer.CHECKPOINT_KEY)); + assertNull(commitMetadata.getMetadata(STREAMER_CHECKPOINT_KEY_V2)); + return lastInstant; + } + + static HoodieInstant assertCommitMetadataForIncrSource(String expected, String tablePath, int totalCommits) + throws IOException { + HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); + HoodieTimeline timeline = meta.getActiveTimeline().getCommitsTimeline().filterCompletedInstants(); + HoodieInstant lastInstant = timeline.lastInstant().get(); + HoodieCommitMetadata commitMetadata = timeline.readCommitMetadata(lastInstant); + assertEquals(totalCommits, timeline.countInstants()); if (meta.getTableConfig().getTableVersion().greaterThanOrEquals(HoodieTableVersion.EIGHT)) { - assertEquals(expected, commitMetadata.getMetadata(StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2)); + assertEquals(expected, commitMetadata.getMetadata(STREAMER_CHECKPOINT_KEY_V2)); + assertNull(commitMetadata.getMetadata(HoodieStreamer.CHECKPOINT_KEY)); } else { assertEquals(expected, commitMetadata.getMetadata(HoodieStreamer.CHECKPOINT_KEY)); + assertNull(commitMetadata.getMetadata(STREAMER_CHECKPOINT_KEY_V2)); } return lastInstant; } @@ -757,9 +770,9 @@ static void waitTillCondition(Function condition, Future dsFut try { Thread.sleep(2000); ret = condition.apply(true); - LOG.info("Condition completed successfully"); + log.info("Condition completed successfully"); } catch (Throwable error) { - LOG.debug("Got error waiting for condition", error); + log.debug("Got error waiting for condition", error); ret = false; } } @@ -777,7 +790,7 @@ static void waitFor(BooleanSupplier booleanSupplier) { try { Thread.sleep(5); } catch (Throwable error) { - LOG.debug("Got error waiting for condition", error); + log.debug("Got error waiting for condition", error); } } } @@ -785,7 +798,7 @@ static void waitFor(BooleanSupplier booleanSupplier) { static void assertAtLeastNCommits(int minExpected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().filterCompletedInstants(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ", exp >=" + minExpected); } @@ -793,7 +806,7 @@ static void assertAtLeastNCommits(int minExpected, String tablePath) { static void assertAtLeastNReplaceCommits(int minExpected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCompletedReplaceTimeline(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ", exp >=" + minExpected); } @@ -801,7 +814,7 @@ static void assertAtLeastNReplaceCommits(int minExpected, String tablePath) { static void assertPendingIndexCommit(String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.reloadActiveTimeline().getAllCommitsTimeline().filterPendingIndexTimeline(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numIndexCommits = timeline.countInstants(); assertEquals(1, numIndexCommits, "Got=" + numIndexCommits + ", exp=1"); } @@ -809,7 +822,7 @@ static void assertPendingIndexCommit(String tablePath) { static void assertCompletedIndexCommit(String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.reloadActiveTimeline().getAllCommitsTimeline().filterCompletedIndexTimeline(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numIndexCommits = timeline.countInstants(); assertEquals(1, numIndexCommits, "Got=" + numIndexCommits + ", exp=1"); } @@ -817,7 +830,7 @@ static void assertCompletedIndexCommit(String tablePath) { static void assertNoReplaceCommits(String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCompletedReplaceTimeline(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertEquals(0, numDeltaCommits, "Got=" + numDeltaCommits + ", exp =" + 0); } @@ -825,7 +838,7 @@ static void assertNoReplaceCommits(String tablePath) { static void assertAtLeastNClusterRequests(int minExpected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().filterPendingClusteringTimeline(); - LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numDeltaCommits = timeline.countInstants(); assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ", exp >=" + minExpected); } @@ -833,7 +846,7 @@ static void assertAtLeastNClusterRequests(int minExpected, String tablePath) { static void assertAtLeastNCommitsAfterRollback(int minExpectedRollback, int minExpectedCommits, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage.getConf(), tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getRollbackTimeline().filterCompletedInstants(); - LOG.info("Rollback Timeline Instants={}", meta.getActiveTimeline().getInstants()); + log.info("Rollback Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numRollbackCommits = timeline.countInstants(); assertTrue(minExpectedRollback <= numRollbackCommits, "Got=" + numRollbackCommits + ", exp >=" + minExpectedRollback); HoodieInstant firstRollback = timeline.getInstants().get(0); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/MockConfigurationHotUpdateStrategy.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/MockConfigurationHotUpdateStrategy.java index ac2a9a4c56044..9ef10f76ffc1e 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/MockConfigurationHotUpdateStrategy.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/MockConfigurationHotUpdateStrategy.java @@ -23,8 +23,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.utilities.streamer.ConfigurationHotUpdateStrategy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import java.io.Serializable; @@ -33,8 +32,8 @@ /** * ConfigurationHotUpdateStrategy for test purpose. */ +@Slf4j public class MockConfigurationHotUpdateStrategy extends ConfigurationHotUpdateStrategy implements Serializable { - private static final Logger LOG = LoggerFactory.getLogger(MockConfigurationHotUpdateStrategy.class); public MockConfigurationHotUpdateStrategy(HoodieDeltaStreamer.Config cfg, TypedProperties properties) { super(cfg, properties); @@ -46,7 +45,7 @@ public Option updateProperties(TypedProperties currentProps) { long upsertShuffleParallelism = currentProps.getLong(UPSERT_PARALLELISM_VALUE.key()); TypedProperties newProps = TypedProperties.copy(currentProps); newProps.setProperty(UPSERT_PARALLELISM_VALUE.key(), String.valueOf(upsertShuffleParallelism + 5)); - LOG.info("update {} from [{}] to [{}]", UPSERT_PARALLELISM_VALUE.key(), upsertShuffleParallelism, upsertShuffleParallelism + 5); + log.info("update {} from [{}] to [{}]", UPSERT_PARALLELISM_VALUE.key(), upsertShuffleParallelism, upsertShuffleParallelism + 5); return Option.of(newProps); } return Option.empty(); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java index 2145d9c726209..777079cd09343 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java @@ -27,6 +27,7 @@ import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient; import org.apache.hudi.client.transaction.lock.InProcessLockProvider; import org.apache.hudi.common.config.DFSPropertiesConfiguration; +import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.config.HoodieStorageConfig; import org.apache.hudi.common.config.LockConfiguration; @@ -79,11 +80,16 @@ import org.apache.hudi.config.metrics.HoodieMetricsConfig; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.exception.SchemaCompatibilityException; import org.apache.hudi.exception.TableNotFoundException; import org.apache.hudi.execution.bulkinsert.BulkInsertSortMode; import org.apache.hudi.hadoop.fs.HadoopFSUtils; import org.apache.hudi.hive.HiveSyncConfig; import org.apache.hudi.hive.HoodieHiveSyncClient; +import org.apache.hudi.internal.schema.Type; +import org.apache.hudi.internal.schema.Types; +import org.apache.hudi.internal.schema.utils.AvroSchemaEvolutionUtils; +import org.apache.hudi.internal.schema.utils.SchemaChangeUtils; import org.apache.hudi.io.storage.hadoop.HoodieAvroParquetReader; import org.apache.hudi.keygen.ComplexKeyGenerator; import org.apache.hudi.keygen.CustomKeyGenerator; @@ -103,6 +109,7 @@ import org.apache.hudi.utilities.UtilHelpers; import org.apache.hudi.utilities.config.HoodieStreamerConfig; import org.apache.hudi.utilities.config.SourceTestConfig; +import org.apache.hudi.utilities.ingestion.HoodieIngestionException; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; import org.apache.hudi.utilities.schema.KafkaOffsetPostProcessor; import org.apache.hudi.utilities.schema.SchemaProvider; @@ -128,6 +135,9 @@ import org.apache.hudi.utilities.transform.Transformer; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.avro.LogicalType; +import org.apache.avro.LogicalTypes; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; @@ -157,8 +167,6 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; @@ -199,6 +207,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -207,9 +216,13 @@ /** * Basic tests against {@link HoodieDeltaStreamer}, by issuing bulk_inserts, upserts, inserts. Check counts at the end. */ +@Slf4j public class TestHoodieDeltaStreamer extends HoodieDeltaStreamerTestBase { - private static final Logger LOG = LoggerFactory.getLogger(TestHoodieDeltaStreamer.class); + // Per-field verdict for the corrupt logical-repair fixtures: relabel ts_millis to millis and + // attach the local-timestamp logical types that 0.x dropped. ts_micros is already micros. + private static final String LOGICAL_REPAIR_TS_OVERRIDES = + "ts_millis:timestamp-millis,local_ts_millis:local-timestamp-millis,local_ts_micros:local-timestamp-micros"; private void addRecordMerger(HoodieRecordType type, List hoodieConfig) { if (type == HoodieRecordType.SPARK) { @@ -434,7 +447,7 @@ public void testPropsWithInvalidKeyGenerator() { deltaStreamer.sync(); }, "Should error out when setting the key generator class property to an invalid value"); // expected - LOG.warn("Expected error during getting the key generator", e); + log.warn("Expected error during getting the key generator", e); assertTrue(e.getMessage().contains("Unable to load class")); } @@ -464,17 +477,17 @@ public void testInferKeyGenerator(String propsFilename, expectedKeyGeneratorClassName, metaClient.getTableConfig().getKeyGeneratorClassName()); Dataset res = sqlContext.read().format("hudi").load(tableBasePath); assertEquals(1000, res.count()); - assertUseV2Checkpoint(metaClient); + assertCheckpointVersion(metaClient); } - private static void assertUseV2Checkpoint(HoodieTableMetaClient metaClient) { + private static void assertCheckpointVersion(HoodieTableMetaClient metaClient) { metaClient.reloadActiveTimeline(); Option metadata = HoodieClientTestUtils.getCommitMetadataForInstant( metaClient, metaClient.getActiveTimeline().lastInstant().get()); assertFalse(metadata.isEmpty()); Map extraMetadata = metadata.get().getExtraMetadata(); - assertTrue(extraMetadata.containsKey(STREAMER_CHECKPOINT_KEY_V2)); - assertFalse(extraMetadata.containsKey(STREAMER_CHECKPOINT_KEY_V1)); + assertTrue(extraMetadata.containsKey(STREAMER_CHECKPOINT_KEY_V1)); + assertFalse(extraMetadata.containsKey(STREAMER_CHECKPOINT_KEY_V2)); } @Test @@ -484,7 +497,7 @@ public void testTableCreation() throws Exception { syncOnce(TestHelpers.makeConfig(basePath + "/not_a_table", WriteOperationType.BULK_INSERT)); }, "Should error out when pointed out at a dir thats not a table"); // expected - LOG.debug("Expected error during table creation", e); + log.debug("Expected error during table creation", e); } @ParameterizedTest @@ -561,7 +574,7 @@ public void testBulkInsertsAndUpsertsWithBootstrap(HoodieRecordType recordType) cfg.targetBasePath = newDatasetBasePath; syncOnce(cfg); Dataset res = sqlContext.read().format("org.apache.hudi").load(newDatasetBasePath); - LOG.info("Schema : {}", res.schema()); + log.info("Schema : {}", res.schema()); assertRecordCount(1950, newDatasetBasePath, sqlContext); res.registerTempTable("bootstrapped"); @@ -635,7 +648,7 @@ public void testSchemaEvolution(String tableType, boolean useUserProvidedSchema, cfg.configs.add(DataSourceWriteOptions.RECONCILE_SCHEMA().key() + "=true"); syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1000, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00000", tableBasePath, 1); @@ -648,7 +661,7 @@ public void testSchemaEvolution(String tableType, boolean useUserProvidedSchema, cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + basePath + "/source_evolved.avsc"); cfg.configs.add(DataSourceWriteOptions.RECONCILE_SCHEMA().key() + "=true"); syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); // out of 1000 new records, 500 are inserts, 450 are updates and 50 are deletes. assertRecordCount(1450, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00001", tableBasePath, 2); @@ -713,7 +726,7 @@ public void testTimestampMillis() throws Exception { syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1000, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00000", tableBasePath, 1); TableSchemaResolver tableSchemaResolver = new TableSchemaResolver( @@ -737,7 +750,7 @@ public void testTimestampMillis() throws Exception { cfg.configs.add("hoodie.datasource.write.row.writer.enable=false"); syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1450, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00001", tableBasePath, 2); tableSchemaResolver = new TableSchemaResolver( @@ -754,6 +767,97 @@ public void testTimestampMillis() throws Exception { assertEquals(0, sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts < '1980-01-01'").count()); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testLongToTimestampPromotionGated(boolean setNullForMissingColumns) throws Exception { + // Promoting a plain long column to a timestamp logical type is override-gated: rejected without a + // per-field override for every target type, and applied with one. A bare long carries no precision + // signal, so the override is the explicit verdict that authorizes the promotion. One bare-long seed + // is reused: the rejection cases all throw (table stays bare long), and the accepted case runs last. + String tableBasePath = basePath + "/testLongToTs" + setNullForMissingColumns; + defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName(); + + // Sync 0: seed the table with `seconds_since_epoch` stored as a bare long. + HoodieDeltaStreamer.Config seed = TestHelpers.makeConfig(tableBasePath, WriteOperationType.INSERT, + Collections.singletonList(TestIdentityTransformer.class.getName()), PROPS_FILENAME_TEST_SOURCE, + false, true, false, null, HoodieTableType.COPY_ON_WRITE.name()); + seed.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + basePath + "/source-timestamp-millis.avsc"); + seed.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + basePath + "/source-timestamp-millis.avsc"); + seed.configs.add("hoodie.datasource.write.row.writer.enable=false"); + seed.configs.add(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key() + "=" + setNullForMissingColumns); + new HoodieDeltaStreamer(seed, jsc).sync(); + + Schema tableSchema = new TableSchemaResolver(HoodieTestUtils.createMetaClient(storage, tableBasePath)) + .getTableSchema(false).toAvroSchema(); + assertNull(tableSchema.getField("seconds_since_epoch").schema().getLogicalType(), + "seconds_since_epoch must be seeded as a bare long in the table"); + Schema baseSchema = new Schema.Parser().parse(fs.open(new Path(basePath + "/source-timestamp-millis.avsc"))); + + // Every target type is rejected without a per-field override. + for (LogicalType targetType : new LogicalType[] {LogicalTypes.timestampMillis(), LogicalTypes.timestampMicros(), + LogicalTypes.localTimestampMillis(), LogicalTypes.localTimestampMicros()}) { + String schemaFile = writePromotedSchema(baseSchema, targetType, setNullForMissingColumns); + HoodieDeltaStreamer.Config reject = promoteConfig(tableBasePath, schemaFile, setNullForMissingColumns, null); + HoodieDeltaStreamer streamer = new HoodieDeltaStreamer(reject, jsc); + // sync() wraps the guard's SchemaCompatibilityException in a HoodieIngestionException, so walk + // the cause chain to assert on the underlying exception. + Throwable thrown = assertThrows(Exception.class, streamer::sync, + "long -> " + targetType.getName() + " must be rejected without an override"); + Throwable cause = thrown; + while (cause != null && !(cause instanceof SchemaCompatibilityException)) { + cause = cause.getCause(); + } + assertTrue(cause instanceof SchemaCompatibilityException, + "Expected a SchemaCompatibilityException in the cause chain, got: " + thrown); + Type toType = SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:" + targetType.getName()).get("field"); + assertEquals(AvroSchemaEvolutionUtils.timestampPrecisionChangeError( + "seconds_since_epoch", Types.LongType.get(), toType).getMessage(), cause.getMessage()); + } + + // With an override the promotion is authorized (local promotions are covered end-to-end by + // testCOWLogicalRepair / testMORLogicalRepair); verify a UTC promotion succeeds and lands on the + // table schema. + String utcSchemaFile = writePromotedSchema(baseSchema, LogicalTypes.timestampMicros(), setNullForMissingColumns); + HoodieDeltaStreamer.Config accept = promoteConfig(tableBasePath, utcSchemaFile, setNullForMissingColumns, + "seconds_since_epoch:timestamp-micros"); + new HoodieDeltaStreamer(accept, jsc).sync(); + Schema evolved = new TableSchemaResolver(HoodieTestUtils.createMetaClient(storage, tableBasePath)) + .getTableSchema(false).toAvroSchema(); + assertEquals("timestamp-micros", evolved.getField("seconds_since_epoch").schema().getLogicalType().getName()); + } + + private String writePromotedSchema(Schema baseSchema, LogicalType targetType, boolean setNull) throws IOException { + Schema incoming = replaceFieldType(baseSchema, "seconds_since_epoch", + targetType.addToSchema(Schema.create(Schema.Type.LONG))); + String schemaFile = basePath + "/promote-" + targetType.getName() + "-nul" + setNull + ".avsc"; + UtilitiesTestBase.Helpers.saveStringsToDFS(new String[] {incoming.toString()}, storage, schemaFile); + return schemaFile; + } + + private HoodieDeltaStreamer.Config promoteConfig(String tableBasePath, String schemaFile, + boolean setNullForMissingColumns, String override) { + HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, + Collections.singletonList(TestIdentityTransformer.class.getName()), PROPS_FILENAME_TEST_SOURCE, + false, true, false, null, HoodieTableType.COPY_ON_WRITE.name()); + cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + schemaFile); + cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + schemaFile); + cfg.configs.add("hoodie.datasource.write.row.writer.enable=false"); + cfg.configs.add(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key() + "=" + setNullForMissingColumns); + if (override != null) { + cfg.configs.add(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key() + "=" + override); + } + return cfg; + } + + private static Schema replaceFieldType(Schema recordSchema, String fieldName, Schema newFieldType) { + List fields = new ArrayList<>(); + for (Schema.Field field : recordSchema.getFields()) { + Schema fieldSchema = field.name().equals(fieldName) ? newFieldType : field.schema(); + fields.add(new Schema.Field(field.name(), fieldSchema, field.doc(), field.defaultVal())); + } + return Schema.createRecord(recordSchema.getName(), recordSchema.getDoc(), recordSchema.getNamespace(), false, fields); + } + @Test public void testLogicalTypes() throws Exception { try { @@ -780,7 +884,7 @@ public void testLogicalTypes() throws Exception { cfg.configs.add("hoodie.datasource.write.row.writer.enable=false"); syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1000, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00000", tableBasePath, 1); TableSchemaResolver tableSchemaResolver = new TableSchemaResolver( @@ -800,7 +904,7 @@ public void testLogicalTypes() throws Exception { cfg.configs.add("hoodie.datasource.write.row.writer.enable=false"); syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1450, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata("00001", tableBasePath, 2); tableSchemaResolver = new TableSchemaResolver( @@ -875,7 +979,7 @@ void testLogicalTypesWithJsonSource(boolean hasTransformer, String orderingField tableBasePath, WriteOperationType.INSERT, hasTransformer, orderingField, recordType, tableType); syncOnce(cfg); // Validate. - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1000, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata(topicName + ",0:500,1:500", tableBasePath, 1); TableSchemaResolver tableSchemaResolver = new TableSchemaResolver( @@ -891,7 +995,7 @@ void testLogicalTypesWithJsonSource(boolean hasTransformer, String orderingField tableBasePath, WriteOperationType.UPSERT, hasTransformer, orderingField, recordType, tableType); syncOnce(cfg); // Validate. - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(1500, tableBasePath, sqlContext); TestHelpers.assertCommitMetadata(topicName + ",0:1250,1:1250", tableBasePath, 2); tableSchemaResolver = new TableSchemaResolver( @@ -968,6 +1072,13 @@ public void testBackwardsCompatibility(HoodieTableVersion version) throws Except String schemaPath = zipOutput + "/schema.avsc"; cfg.configs.add(String.format(("%s=%s"), "hoodie.streamer.schemaprovider.source.schema.file", schemaPath)); cfg.configs.add(String.format(("%s=%s"), "hoodie.streamer.schemaprovider.target.schema.file", schemaPath)); + // The v6/v8 col-stats fixture reuses the same trips_logical_types_json corrupt schema as + // the logical-repair tests — 0.x collapsed ts_millis to timestamp-micros and dropped the + // local-timestamp logical types entirely. Provide the same explicit verdict so the guard + // in HoodieSchemaUtils.deduceWriterSchema authorizes the repair rather than rejecting the + // unverified precision change. + cfg.configs.add(String.format(("%s=%s"), + HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(), LOGICAL_REPAIR_TS_OVERRIDES)); cfg.forceDisableCompaction = true; cfg.sourceLimit = 100_000; cfg.ignoreCheckpoint = "12345"; @@ -1110,8 +1221,19 @@ private void assertBoundaryCounts(Dataset df, String exprZero, String exprT } @ParameterizedTest - @CsvSource(value = {"SIX,AVRO,CLUSTER", "EIGHT,AVRO,CLUSTER", "CURRENT,AVRO,NONE", "CURRENT,AVRO,CLUSTER", "CURRENT,SPARK,NONE", "CURRENT,SPARK,CLUSTER"}) - void testCOWLogicalRepair(String tableVersion, String recordType, String operation) throws Exception { + @CsvSource(value = { + // Repair succeeds when a per-field verdict is set, on the default (non-reconcile) write path... + "SIX,AVRO,CLUSTER,false,true", "EIGHT,AVRO,CLUSTER,false,true", + "CURRENT,AVRO,NONE,false,true", "CURRENT,AVRO,CLUSTER,false,true", + "CURRENT,SPARK,NONE,false,true", "CURRENT,SPARK,CLUSTER,false,true", + // ...and on the reconcile path (setNullForMissingColumns=true). + "SIX,AVRO,CLUSTER,true,true", "EIGHT,AVRO,CLUSTER,true,true", "CURRENT,AVRO,CLUSTER,true,true", + // Guard: with no verdict, the mislabeled timestamp/local-timestamp columns must be rejected on + // the first sync, on both the reconcile path and the default path. + "SIX,AVRO,CLUSTER,true,false", "SIX,AVRO,CLUSTER,false,false"}) + void testCOWLogicalRepair(String tableVersion, String recordType, String operation, + boolean setNullForMissingColumns, + boolean setTimestampOverride) throws Exception { TestMercifulJsonToRowConverterBase.timestampNTZCompatibility(() -> { String dirName = "trips_logical_types_json_cow_write"; String dataPath = basePath + "/" + dirName; @@ -1140,9 +1262,36 @@ void testCOWLogicalRepair(String tableVersion, String recordType, String operati properties.setProperty("hoodie.parquet.small.file.limit", "-1"); properties.setProperty("hoodie.cleaner.commits.retained", "10"); properties.setProperty(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), tableVersionString); + properties.setProperty(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key(), + Boolean.toString(setNullForMissingColumns)); + if (setTimestampOverride) { + // Per-field verdict authorizing the repair: relabel ts_millis to millis and attach the + // local-timestamp logical types 0.x dropped. ts_micros stays micros (no entry needed). + properties.setProperty(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(), + LOGICAL_REPAIR_TS_OVERRIDES); + } Option propt = Option.of(properties); + if (!setTimestampOverride) { + // No per-field verdict. The mislabeled timestamp/local-timestamp columns must be rejected on + // the first sync rather than silently flipped, on both the reconcile and default write paths. + // syncOnce wraps the guard's SchemaCompatibilityException in a HoodieIngestionException, so + // walk the cause chain to assert on the underlying exception. + Throwable thrown = assertThrows(Exception.class, + () -> syncOnce(prepCfgForCowLogicalRepair(tableBasePath, "456"), propt)); + Throwable cause = thrown; + while (cause != null && !(cause instanceof SchemaCompatibilityException)) { + cause = cause.getCause(); + } + assertTrue(cause instanceof SchemaCompatibilityException, + "Expected a SchemaCompatibilityException in the cause chain, got: " + thrown); + assertTrue(cause.getMessage().contains("column 'ts_millis'") + && cause.getMessage().contains("without an explicit"), + "Unexpected message: " + cause.getMessage()); + return; + } + syncOnce(prepCfgForCowLogicalRepair(tableBasePath, "456"), propt); inputDataPath = getClass().getClassLoader().getResource("logical-repair/cow_write_updates/3").toURI().toString(); @@ -1190,11 +1339,17 @@ void testCOWLogicalRepair(String tableVersion, String recordType, String operati } @ParameterizedTest - @CsvSource(value = {"SIX,AVRO,CLUSTER,AVRO", "EIGHT,AVRO,CLUSTER,AVRO", - "CURRENT,AVRO,NONE,AVRO", "CURRENT,AVRO,CLUSTER,AVRO", "CURRENT,AVRO,COMPACT,AVRO", - "CURRENT,AVRO,NONE,PARQUET", "CURRENT,AVRO,CLUSTER,PARQUET", "CURRENT,AVRO,COMPACT,PARQUET", - "CURRENT,SPARK,NONE,PARQUET", "CURRENT,SPARK,CLUSTER,PARQUET", "CURRENT,SPARK,COMPACT,PARQUET"}) - void testMORLogicalRepair(String tableVersion, String recordType, String operation, String logBlockType) throws Exception { + @CsvSource(value = {"SIX,AVRO,CLUSTER,AVRO,false,true", "EIGHT,AVRO,CLUSTER,AVRO,false,true", + "CURRENT,AVRO,NONE,AVRO,false,true", "CURRENT,AVRO,CLUSTER,AVRO,false,true", "CURRENT,AVRO,COMPACT,AVRO,false,true", + "CURRENT,AVRO,NONE,PARQUET,false,true", "CURRENT,AVRO,CLUSTER,PARQUET,false,true", "CURRENT,AVRO,COMPACT,PARQUET,false,true", + "CURRENT,SPARK,NONE,PARQUET,false,true", "CURRENT,SPARK,CLUSTER,PARQUET,false,true", "CURRENT,SPARK,COMPACT,PARQUET,false,true", + // Variants that exercise the schema-reconcile path (setNullForMissingColumns=true) with a verdict. + "SIX,AVRO,CLUSTER,AVRO,true,true", "EIGHT,AVRO,CLUSTER,AVRO,true,true", "CURRENT,AVRO,CLUSTER,AVRO,true,true", + // Guard: with no verdict, the first sync must throw, on both the reconcile and default paths. + "SIX,AVRO,CLUSTER,AVRO,true,false", "SIX,AVRO,CLUSTER,AVRO,false,false"}) + void testMORLogicalRepair(String tableVersion, String recordType, String operation, String logBlockType, + boolean setNullForMissingColumns, + boolean setTimestampOverride) throws Exception { TestMercifulJsonToRowConverterBase.timestampNTZCompatibility(() -> { String tableSuffix; String logFormatValue; @@ -1242,6 +1397,12 @@ void testMORLogicalRepair(String tableVersion, String recordType, String operati properties.setProperty("hoodie.cleaner.commits.retained", "10"); properties.setProperty(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), tableVersionString); properties.setProperty(HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), logFormatValue); + properties.setProperty(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key(), + Boolean.toString(setNullForMissingColumns)); + if (setTimestampOverride) { + properties.setProperty(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(), + LOGICAL_REPAIR_TS_OVERRIDES); + } boolean disableCompaction; if ("COMPACT".equals(operation)) { @@ -1263,6 +1424,25 @@ void testMORLogicalRepair(String tableVersion, String recordType, String operati Option propt = Option.of(properties); + if (!setTimestampOverride) { + // No per-field verdict. The mislabeled timestamp/local-timestamp columns must be rejected on + // the first sync rather than silently flipped, on both the reconcile and default write paths. + // syncOnce wraps the guard's SchemaCompatibilityException in a HoodieIngestionException, so + // walk the cause chain to assert on the underlying exception. + Throwable thrown = assertThrows(Exception.class, + () -> syncOnce(prepCfgForMorLogicalRepair(tableBasePath, dirName, "123", disableCompaction), propt)); + Throwable cause = thrown; + while (cause != null && !(cause instanceof SchemaCompatibilityException)) { + cause = cause.getCause(); + } + assertTrue(cause instanceof SchemaCompatibilityException, + "Expected a SchemaCompatibilityException in the cause chain, got: " + thrown); + assertTrue(cause.getMessage().contains("column 'ts_millis'") + && cause.getMessage().contains("without an explicit"), + "Unexpected message: " + cause.getMessage()); + return; + } + syncOnce(prepCfgForMorLogicalRepair(tableBasePath, dirName, "123", disableCompaction), propt); String prevTimezone = sparkSession.conf().get("spark.sql.session.timeZone"); @@ -1474,7 +1654,7 @@ public void testUpsertsCOW_ContinuousModeDisabled() throws Exception { cfg.configs.add(String.format("%s=%s", HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key(), MetricsReporterType.INMEMORY.name())); cfg.continuousMode = false; syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(SQL_SOURCE_NUM_RECORDS, tableBasePath, sqlContext); assertFalse(Metrics.isInitialized(tableBasePath), "Metrics should be shutdown"); UtilitiesTestBase.Helpers.deleteFileFromDfs(fs, tableBasePath); @@ -1503,7 +1683,7 @@ public void testUpsertsMOR_ContinuousModeDisabled() throws Exception { cfg.configs.add(String.format("%s=%s", HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key(), MetricsReporterType.INMEMORY.name())); cfg.continuousMode = false; syncOnce(cfg); - assertUseV2Checkpoint(HoodieTestUtils.createMetaClient(storage, tableBasePath)); + assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, tableBasePath)); assertRecordCount(SQL_SOURCE_NUM_RECORDS, tableBasePath, sqlContext); assertFalse(Metrics.isInitialized(tableBasePath), "Metrics should be shutdown"); UtilitiesTestBase.Helpers.deleteFileFromDfs(fs, tableBasePath); @@ -1568,7 +1748,7 @@ static void deltaStreamerTestRunner(HoodieDeltaStreamer ds, HoodieDeltaStreamer. try { ds.sync(); } catch (Exception ex) { - LOG.warn("DS continuous job failed, hence not proceeding with condition check for " + jobId); + log.warn("DS continuous job failed, hence not proceeding with condition check for {}", jobId); throw new RuntimeException(ex.getMessage(), ex); } }); @@ -1966,19 +2146,19 @@ public void testHoodieIndexer(HoodieRecordType recordType) throws Exception { buildIndexerConfig(tableBasePath, ds.getConfig().targetTableName, null, UtilHelpers.SCHEDULE, "COLUMN_STATS")); scheduleIndexInstantTime = scheduleIndexingJob.doSchedule(); } catch (Exception e) { - LOG.info("Schedule indexing failed", e); + log.info("Schedule indexing failed", e); return false; } if (scheduleIndexInstantTime.isPresent()) { TestHelpers.assertPendingIndexCommit(tableBasePath); - LOG.info("Schedule indexing success, now build index with instant time " + scheduleIndexInstantTime.get()); + log.info("Schedule indexing success, now build index with instant time {}", scheduleIndexInstantTime.get()); HoodieIndexer runIndexingJob = new HoodieIndexer(jsc, buildIndexerConfig(tableBasePath, ds.getConfig().targetTableName, scheduleIndexInstantTime.get(), UtilHelpers.EXECUTE, "COLUMN_STATS")); runIndexingJob.start(0); - LOG.info("Metadata indexing success"); + log.info("Metadata indexing success"); TestHelpers.assertCompletedIndexCommit(tableBasePath); } else { - LOG.warn("Metadata indexing failed"); + log.warn("Metadata indexing failed"); } return true; }); @@ -2006,7 +2186,7 @@ public void testHoodieIndexerExecutionAfterCommit() throws Exception { Arrays.asList(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() + "=true", HoodieWriteConfig.MARKERS_TYPE.key() + "=DIRECT"))); scheduleIndexInstantTime = scheduleIndexingJob.doSchedule(); TestHelpers.assertPendingIndexCommit(tableBasePath); - LOG.info("Schedule indexing success, now build index with instant time " + scheduleIndexInstantTime.get()); + log.info("Schedule indexing success, now build index with instant time {}", scheduleIndexInstantTime.get()); // Wait for a pending commit before starting execution phase for the executor. This ensures that indexer waits for the commit to complete. TestHelpers.waitFor(() -> { HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient(storage.getConf(), tableBasePath); @@ -2017,7 +2197,7 @@ public void testHoodieIndexerExecutionAfterCommit() throws Exception { buildIndexerConfig(tableBasePath, ds.getConfig().targetTableName, scheduleIndexInstantTime.get(), UtilHelpers.EXECUTE, "RECORD_INDEX", Arrays.asList(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() + "=true", HoodieWriteConfig.MARKERS_TYPE.key() + "=DIRECT"))); runIndexingJob.start(0); - LOG.info("Metadata indexing success"); + log.info("Metadata indexing success"); TestHelpers.assertCompletedIndexCommit(tableBasePath); // Assert no pending commits before indexing instant HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient(storage.getConf(), tableBasePath); @@ -2066,7 +2246,7 @@ public void testHoodieIndexerExecutionAfterClustering(HoodieRecordType recordTyp buildIndexerConfig(tableBasePath, ds.getConfig().targetTableName, null, UtilHelpers.SCHEDULE, "RECORD_INDEX")); scheduleIndexInstantTime = scheduleIndexingJob.doSchedule(); TestHelpers.assertPendingIndexCommit(tableBasePath); - LOG.info("Schedule indexing success, now build index with instant time " + scheduleIndexInstantTime.get()); + log.info("Schedule indexing success, now build index with instant time {}", scheduleIndexInstantTime.get()); // Wait for clustering instant to be scheduled before starting execution phase of the executor TestHelpers.waitFor(() -> { HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient(storage, tableBasePath); @@ -2083,7 +2263,7 @@ public void testHoodieIndexerExecutionAfterClustering(HoodieRecordType recordTyp boolean res = JavaTestUtils.checkNestedExceptionContains(t, "Index catchup failed"); assertTrue(res, "Indexing catchup task should have timed out"); } - LOG.info("Metadata indexing timed out"); + log.info("Metadata indexing timed out"); } catch (Exception e) { fail("Indexing job should not have failed", e); } @@ -2113,19 +2293,19 @@ public void testHoodieAsyncClusteringJob(boolean shouldPassInClusteringInstantTi initialHoodieClusteringJob(tableBasePath, null, true, null); scheduleClusteringInstantTime = scheduleClusteringJob.doSchedule(); } catch (Exception e) { - LOG.warn("Schedule clustering failed", e); + log.warn("Schedule clustering failed", e); Assertions.fail("Schedule clustering failed", e); } if (scheduleClusteringInstantTime.isPresent()) { - LOG.info("Schedule clustering success, now cluster with instant time " + scheduleClusteringInstantTime.get()); + log.info("Schedule clustering success, now cluster with instant time {}", scheduleClusteringInstantTime.get()); HoodieClusteringJob.Config clusterClusteringConfig = buildHoodieClusteringUtilConfig(tableBasePath, shouldPassInClusteringInstantTime ? scheduleClusteringInstantTime.get() : null, false); HoodieClusteringJob clusterClusteringJob = new HoodieClusteringJob(jsc, clusterClusteringConfig); clusterClusteringJob.cluster(clusterClusteringConfig.retry); TestHelpers.assertAtLeastNReplaceCommits(1, tableBasePath); - LOG.info("Cluster success"); + log.info("Cluster success"); } else { - LOG.warn("Clustering execution failed"); + log.warn("Clustering execution failed"); Assertions.fail("Clustering execution failed"); } } else { @@ -2295,15 +2475,15 @@ public void testHoodieAsyncClusteringJobWithScheduleAndExecute(String runningMod try { int result = scheduleClusteringJob.cluster(0); if (result == 0) { - LOG.info("Cluster success"); + log.info("Cluster success"); } else { - LOG.warn("Cluster failed"); + log.warn("Cluster failed"); if (!runningMode.toLowerCase().equals(UtilHelpers.EXECUTE)) { return false; } } } catch (Exception e) { - LOG.warn("ScheduleAndExecute clustering failed", e); + log.warn("ScheduleAndExecute clustering failed", e); exception = e; if (!runningMode.equalsIgnoreCase(UtilHelpers.EXECUTE)) { return false; @@ -2416,7 +2596,7 @@ private void testBulkInsertRowWriterMultiBatches(Boolean useSchemaProvider, List entry, metaClient, WriteOperationType.BULK_INSERT)); } } - assertUseV2Checkpoint(createMetaClient(jsc, tableBasePath)); + assertCheckpointVersion(createMetaClient(jsc, tableBasePath)); } finally { deltaStreamer.shutdownGracefully(); } @@ -2463,13 +2643,13 @@ private void testBulkInsertRowWriterContinuousMode(boolean useSchemaProvider, Li try { int counter = 2; while (counter < 100) { // lets keep going. if the test times out, we will cancel the future within finally. So, safe to generate 100 batches. - LOG.info("Generating data for batch {}", counter); + log.info("Generating data for batch {}", counter); prepareParquetDFSFiles(100, PARQUET_SOURCE_ROOT, Integer.toString(counter) + ".parquet", false, null, null, makeDatesAmbiguous); counter++; Thread.sleep(2000); } } catch (Exception ex) { - LOG.warn("Input data generation failed", ex); + log.warn("Input data generation failed", ex); throw new RuntimeException(ex.getMessage(), ex); } }); @@ -2536,7 +2716,7 @@ public void testBulkInsertsAndUpsertsWithSQLBasedTransformerFor2StepPipeline() t assertRecordCount(1000, downstreamTableBasePath, sqlContext); assertDistanceCount(1000, downstreamTableBasePath, sqlContext); assertDistanceCountWithExactValue(1000, downstreamTableBasePath, sqlContext); - TestHelpers.assertCommitMetadata(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 1); + TestHelpers.assertCommitMetadataForIncrSource(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 1); // No new data => no commits for upstream table cfg.sourceLimit = 0; @@ -2554,7 +2734,7 @@ public void testBulkInsertsAndUpsertsWithSQLBasedTransformerFor2StepPipeline() t assertRecordCount(1000, downstreamTableBasePath, sqlContext); assertDistanceCount(1000, downstreamTableBasePath, sqlContext); assertDistanceCountWithExactValue(1000, downstreamTableBasePath, sqlContext); - TestHelpers.assertCommitMetadata(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 1); + TestHelpers.assertCommitMetadataForIncrSource(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 1); // upsert() #1 on upstream hudi table cfg.sourceLimit = 2000; @@ -2578,7 +2758,7 @@ public void testBulkInsertsAndUpsertsWithSQLBasedTransformerFor2StepPipeline() t assertDistanceCount(2000, downstreamTableBasePath, sqlContext); assertDistanceCountWithExactValue(2000, downstreamTableBasePath, sqlContext); HoodieInstant finalInstant = - TestHelpers.assertCommitMetadata(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 2); + TestHelpers.assertCommitMetadataForIncrSource(lastInstantForUpstreamTable.getCompletionTime(), downstreamTableBasePath, 2); counts = countsPerCommit(downstreamTableBasePath, sqlContext); assertEquals(2000, counts.stream().mapToLong(entry -> entry.getLong(1)).sum()); @@ -2610,11 +2790,12 @@ public void testNullSchemaProvider() { HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.BULK_INSERT, Collections.singletonList(SqlQueryBasedTransformer.class.getName()), PROPS_FILENAME_TEST_SOURCE, true, false, false, null, null); - Exception e = assertThrows(HoodieException.class, () -> { + Exception e = assertThrows(HoodieIngestionException.class, () -> { syncOnce(new HoodieDeltaStreamer(cfg, jsc, fs, hiveServer.getHiveConf())); }, "Should error out when schema provider is not provided"); - LOG.debug("Expected error during reading data from source ", e); - assertTrue(e.getMessage().contains("Schema provider is required for this operation and for the source of interest. " + log.debug("Expected error during reading data from source ", e); + String errorMsg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage(); + assertTrue(errorMsg.contains("Schema provider is required for this operation and for the source of interest. " + "Please set '--schemaprovider-class' in the top level HoodieStreamer config for the source of interest. " + "Based on the schema provider class chosen, additional configs might be required. " + "For eg, if you choose 'org.apache.hudi.utilities.schema.SchemaRegistryProvider', " @@ -2845,7 +3026,7 @@ public void testFilterDupes() throws Exception { // Ensure it is empty HoodieCommitMetadata commitMetadata = mClient.getActiveTimeline().readCommitMetadata(newLastFinished); - LOG.info("New Commit Metadata={}", commitMetadata); + log.info("New Commit Metadata={}", commitMetadata); assertTrue(commitMetadata.getPartitionToWriteStats().isEmpty()); // Try UPSERT with filterDupes true. Expect exception @@ -3361,7 +3542,7 @@ private void testDeltaStreamerRestartAfterMissingHoodieProps(boolean testInitFai try { fs.delete(entry.getPath()); } catch (IOException e) { - LOG.warn("Failed to delete " + entry.getPath().toString(), e); + log.warn("Failed to delete: {}", entry.getPath().toString(), e); } }); } @@ -3534,16 +3715,18 @@ public void testCsvDFSSourceNoHeaderWithoutSchemaProviderAndWithTransformer() th // Target schema is determined based on the Dataframe after transformation // No CSV header and no schema provider at the same time are not recommended, // as the transformer behavior may be unexpected - Exception e = assertThrows(AnalysisException.class, () -> { + Exception e = assertThrows(HoodieIngestionException.class, () -> { testCsvDFSSource(false, '\t', false, Collections.singletonList(TripsWithDistanceTransformer.class.getName())); }, "Should error out when doing the transformation."); - LOG.debug("Expected error during transformation", e); + log.debug("Expected error during transformation", e); + Throwable cause = e.getCause(); + assertTrue(cause instanceof AnalysisException, "Expected cause to be AnalysisException but was: " + cause.getClass()); // First message for Spark 3.4 and above, second message for Spark 3.3, third message for Spark 3.2 and below assertTrue( - e.getMessage().contains("[UNRESOLVED_COLUMN.WITH_SUGGESTION] A column or function parameter " + cause.getMessage().contains("[UNRESOLVED_COLUMN.WITH_SUGGESTION] A column or function parameter " + "with name `begin_lat` cannot be resolved. Did you mean one of the following?") - || e.getMessage().contains("Column 'begin_lat' does not exist. Did you mean one of the following?") - || e.getMessage().contains("cannot resolve 'begin_lat' given input columns:")); + || cause.getMessage().contains("Column 'begin_lat' does not exist. Did you mean one of the following?") + || cause.getMessage().contains("cannot resolve 'begin_lat' given input columns:")); } @Test @@ -3904,9 +4087,9 @@ public void testResumeCheckpointAfterChangingCOW2MOR() throws Exception { .build(); Properties hoodieProps = new Properties(); hoodieProps.load(fs.open(new Path(cfg.targetBasePath + "/.hoodie/hoodie.properties"))); - LOG.info("old props: {}", hoodieProps); + log.info("old props: {}", hoodieProps); hoodieProps.put("hoodie.table.type", HoodieTableType.MERGE_ON_READ.name()); - LOG.info("new props: {}", hoodieProps); + log.info("new props: {}", hoodieProps); StoragePath metaPathDir = new StoragePath(metaClient.getBasePath(), HoodieTableMetaClient.METAFOLDER_NAME); HoodieTableConfig.create(metaClient.getStorage(), metaPathDir, hoodieProps); @@ -3975,9 +4158,9 @@ public void testResumeCheckpointAfterChangingMOR2COW() throws Exception { .build(); Properties hoodieProps = new Properties(); hoodieProps.load(fs.open(new Path(cfg.targetBasePath + "/.hoodie/hoodie.properties"))); - LOG.info("old props: " + hoodieProps); + log.info("Old props: {}", hoodieProps); hoodieProps.put("hoodie.table.type", HoodieTableType.COPY_ON_WRITE.name()); - LOG.info("new props: " + hoodieProps); + log.info("New props: {}", hoodieProps); StoragePath metaPathDir = new StoragePath(metaClient.getBasePath(), ".hoodie"); HoodieTableConfig.create(metaClient.getStorage(), metaPathDir, hoodieProps); @@ -4271,13 +4454,13 @@ public DummyAvroPayload(GenericRecord gr, Comparable orderingVal) { /** * Return empty table. */ + @Slf4j public static class DropAllTransformer implements Transformer { - private static final Logger LOG = LoggerFactory.getLogger(DropAllTransformer.class); @Override public Dataset apply(JavaSparkContext jsc, SparkSession sparkSession, Dataset rowDataset, TypedProperties properties) { - LOG.info("DropAllTransformer called !!"); + log.info("DropAllTransformer called !!"); return sparkSession.createDataFrame(jsc.emptyRDD(), rowDataset.schema()); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerSchemaEvolutionQuick.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerSchemaEvolutionQuick.java index e2fc16dc6a2f2..5c07ff9d12949 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerSchemaEvolutionQuick.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerSchemaEvolutionQuick.java @@ -29,6 +29,7 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.exception.MissingSchemaFieldException; import org.apache.hudi.utilities.UtilHelpers; +import org.apache.hudi.utilities.ingestion.HoodieIngestionException; import org.apache.hudi.utilities.streamer.HoodieStreamer; import org.apache.spark.sql.Column; @@ -231,7 +232,9 @@ public void testBase(String tableType, addData(df, false); deltaStreamer.sync(); assertTrue(allowNullForDeletedCols); - } catch (MissingSchemaFieldException e) { + } catch (HoodieIngestionException e) { + assertTrue(e.getCause() instanceof MissingSchemaFieldException, + "Expected cause to be MissingSchemaFieldException but was: " + e.getCause()); assertFalse(allowNullForDeletedCols); return; } @@ -421,7 +424,9 @@ public void testDroppedColumn(String tableType, assertTrue(riderFieldOpt.get().schema().getTypes() .stream().anyMatch(t -> HoodieSchemaType.STRING == t.getType())); assertTrue(metaClient.reloadActiveTimeline().lastInstant().get().compareTo(lastInstant) > 0); - } catch (MissingSchemaFieldException e) { + } catch (HoodieIngestionException e) { + assertTrue(e.getCause() instanceof MissingSchemaFieldException, + "Expected cause to be MissingSchemaFieldException but was: " + e.getCause()); assertFalse(allowNullForDeletedCols || targetSchemaSameAsTableSchema); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java index fbd8726242a6a..194b9b9bb9d0e 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.checkpoint.CheckpointUtils; import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.testutils.JavaTestUtils; import org.apache.hudi.io.util.FileIOUtils; import org.apache.hudi.config.HoodieCleanConfig; import org.apache.hudi.config.HoodieCompactionConfig; @@ -38,13 +39,12 @@ import org.apache.hudi.utilities.sources.TestDataSource; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; @@ -68,10 +68,9 @@ import static org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer.CHECKPOINT_KEY; import static org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.deltaStreamerTestRunner; +@Slf4j public class TestHoodieDeltaStreamerWithMultiWriter extends HoodieDeltaStreamerTestBase { - private static final Logger LOG = LoggerFactory.getLogger(TestHoodieDeltaStreamerWithMultiWriter.class); - String basePath; String propsFilePath; String tableBasePath; @@ -413,18 +412,18 @@ private void runJobsInParallel(String tableBasePath, HoodieTableType tableType, deltaStreamerTestRunner(ingestionJob, cfgIngestionJob, conditionForRegularIngestion, jobId); } catch (Throwable ex) { continuousFailed.set(true); - LOG.error("Continuous job failed " + ex.getMessage()); + log.error("Continuous job failed {}", ex.getMessage()); throw new RuntimeException(ex); } }); Future backfillJobFuture = service.submit(() -> { try { - // trigger backfill atleast after 1 requested entry is added to timeline from continuous job. If not, there is a chance that backfill will complete even before + // trigger backfill at least after 1 requested entry is added to timeline from continuous job. If not, there is a chance that backfill will complete even before // continuous job starts. awaitCondition(new GetCommitsAfterInstant(tableBasePath, lastSuccessfulCommit)); backfillJob.sync(); } catch (Throwable ex) { - LOG.error("Backfilling job failed " + ex.getMessage()); + log.error("Backfilling job failed {}", ex.getMessage()); backfillFailed.set(true); throw new RuntimeException(ex); } @@ -439,27 +438,27 @@ private void runJobsInParallel(String tableBasePath, HoodieTableType tableType, * Need to perform getMessage().contains since the exception coming * from {@link org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer.DeltaSyncService} gets wrapped many times into RuntimeExceptions. */ - if (expectConflict && backfillFailed.get() && e.getCause().getMessage().contains(ConcurrentModificationException.class.getName())) { + if (expectConflict && backfillFailed.get() && JavaTestUtils.checkNestedExceptionContains(e, ConcurrentModificationException.class.getName())) { // expected ConcurrentModificationException since ingestion & backfill will have overlapping writes if (!continuousFailed.get()) { // if backfill job failed, shutdown the continuous job. - LOG.warn("Calling shutdown on ingestion job since the backfill job has failed for " + jobId); + log.warn("Calling shutdown on ingestion job since the backfill job has failed for {}", jobId); ingestionJob.shutdownGracefully(); } else { // both backfill and ingestion job cannot fail. throw new HoodieException("Both backfilling and ingestion job failed ", e); } - } else if (expectConflict && continuousFailed.get() && e.getCause().getMessage().contains("Ingestion service was shut down with exception")) { + } else if (expectConflict && continuousFailed.get() && JavaTestUtils.checkNestedExceptionContains(e, "Ingestion service was shut down with exception")) { // incase of regular ingestion job failing, ConcurrentModificationException is not throw all the way. if (!backfillFailed.get()) { - LOG.warn("Calling shutdown on backfill job since the ingstion/continuous job has failed for " + jobId); + log.warn("Calling shutdown on backfill job since the ingstion/continuous job has failed for {}", jobId); backfillJob.shutdownGracefully(); } else { // both backfill and ingestion job cannot fail. throw new HoodieException("Both backfilling and ingestion job failed ", e); } } else { - LOG.error("Conflict happened, but not expected " + e.getCause().getMessage()); + log.error("Conflict happened, but not expected {}", e.getCause().getMessage()); throw e; } } finally { @@ -495,7 +494,7 @@ private static void awaitCondition(GetCommitsAfterInstant callback) throws Inter soFar += 500; } } - LOG.warn("Awaiting completed in " + (System.currentTimeMillis() - startTime)); + log.warn("Awaiting completed in {}", System.currentTimeMillis() - startTime); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieMultiTableDeltaStreamer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieMultiTableDeltaStreamer.java index fc75e76275af4..b1e15b494c7e3 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieMultiTableDeltaStreamer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieMultiTableDeltaStreamer.java @@ -34,9 +34,8 @@ import org.apache.hudi.utilities.streamer.TableExecutionContext; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.Arrays; @@ -49,10 +48,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +@Slf4j public class TestHoodieMultiTableDeltaStreamer extends HoodieDeltaStreamerTestBase { - private static final Logger LOG = LoggerFactory.getLogger(TestHoodieMultiTableDeltaStreamer.class); - static class TestHelpers { static HoodieMultiTableDeltaStreamer.Config getConfig(String fileName, String configFolder, String sourceClassName, boolean enableHiveSync, boolean enableMetaSync, @@ -104,7 +102,7 @@ public void testInvalidHiveSyncProps() throws IOException { Exception e = assertThrows(HoodieException.class, () -> { new HoodieMultiTableDeltaStreamer(cfg, jsc); }, "Should fail when hive sync table not provided with enableHiveSync flag"); - LOG.debug("Expected error when creating table execution objects", e); + log.debug("Expected error when creating table execution objects", e); assertTrue(e.getMessage().contains("Meta sync table field not provided!")); } @@ -114,7 +112,7 @@ public void testInvalidPropsFilePath() throws IOException { Exception e = assertThrows(IllegalArgumentException.class, () -> { new HoodieMultiTableDeltaStreamer(cfg, jsc); }, "Should fail when invalid props file is provided"); - LOG.debug("Expected error when creating table execution objects", e); + log.debug("Expected error when creating table execution objects", e); assertTrue(e.getMessage().contains("Please provide valid common config file path!")); } @@ -124,7 +122,7 @@ public void testInvalidTableConfigFilePath() throws IOException { Exception e = assertThrows(IllegalArgumentException.class, () -> { new HoodieMultiTableDeltaStreamer(cfg, jsc); }, "Should fail when invalid table config props file path is provided"); - LOG.debug("Expected error when creating table execution objects", e); + log.debug("Expected error when creating table execution objects", e); assertTrue(e.getMessage().contains("Please provide valid table config file path!")); } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestSourceFormatAdapter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestSourceFormatAdapter.java index 5fdae40ed5b8d..7a97e62fd77e9 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestSourceFormatAdapter.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestSourceFormatAdapter.java @@ -95,13 +95,13 @@ public void teardown() { } private void setupRowSource(Dataset ds, TypedProperties properties, SchemaProvider schemaProvider) { - InputBatch> batch = new InputBatch<>(Option.of(ds), DUMMY_CHECKPOINT, schemaProvider); + InputBatch> batch = new InputBatch<>(Option.of(ds), new StreamerCheckpointV2(DUMMY_CHECKPOINT), schemaProvider); testRowDataSource = new TestRowDataSource(properties, jsc, spark, schemaProvider, batch); } private void setupJsonSource(JavaRDD ds, HoodieSchema schema) { SchemaProvider basicSchemaProvider = new BasicSchemaProvider(schema); - InputBatch> batch = new InputBatch<>(Option.of(ds), DUMMY_CHECKPOINT, basicSchemaProvider); + InputBatch> batch = new InputBatch<>(Option.of(ds), new StreamerCheckpointV2(DUMMY_CHECKPOINT), basicSchemaProvider); testJsonDataSource = new TestJsonDataSource(new TypedProperties(), jsc, spark, basicSchemaProvider, batch); } @@ -183,7 +183,7 @@ public void testTargetSchemaUsedForNonFileBasedProvider() { SchemaProvider schemaProvider = new TestSchemaProviderWithTransformation(sourceSchema, targetSchema); // Setup the row source - InputBatch> batch = new InputBatch<>(Option.of(testDataset), DUMMY_CHECKPOINT, schemaProvider); + InputBatch> batch = new InputBatch<>(Option.of(testDataset), new StreamerCheckpointV2(DUMMY_CHECKPOINT), schemaProvider); TestRowDataSource testSource = new TestRowDataSource(new TypedProperties(), jsc, spark, schemaProvider, batch); // Create SourceFormatAdapter and fetch data in Avro format diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/multisync/TestMultipleMetaSync.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/multisync/TestMultipleMetaSync.java index d1e0f70b78f8c..9c332de1feba2 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/multisync/TestMultipleMetaSync.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/multisync/TestMultipleMetaSync.java @@ -22,6 +22,7 @@ import org.apache.hudi.exception.HoodieMetaSyncException; import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer; import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase; +import org.apache.hudi.utilities.ingestion.HoodieIngestionException; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; import org.apache.hudi.utilities.sources.TestDataSource; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; @@ -61,8 +62,9 @@ void testWithException(String syncClassNames) { MockSyncTool1.syncSuccess = false; MockSyncTool2.syncSuccess = false; HoodieDeltaStreamer.Config cfg = getConfig(tableBasePath, syncClassNames); - Exception e = assertThrows(HoodieMetaSyncException.class, () -> syncOnce(new HoodieDeltaStreamer(cfg, jsc, fs, hiveServer.getHiveConf()))); - assertTrue(e.getMessage().contains(MockSyncToolException1.class.getName())); + Exception e = assertThrows(HoodieIngestionException.class, () -> syncOnce(new HoodieDeltaStreamer(cfg, jsc, fs, hiveServer.getHiveConf()))); + assertTrue(e.getCause() instanceof HoodieMetaSyncException); + assertTrue(e.getCause().getMessage().contains(MockSyncToolException1.class.getName())); assertTrue(MockSyncTool1.syncSuccess); assertTrue(MockSyncTool2.syncSuccess); } @@ -73,9 +75,10 @@ void testMultipleExceptions() { MockSyncTool1.syncSuccess = false; MockSyncTool2.syncSuccess = false; HoodieDeltaStreamer.Config cfg = getConfig(tableBasePath, getSyncNames("MockSyncTool1", "MockSyncTool2", "MockSyncToolException1", "MockSyncToolException2")); - Exception e = assertThrows(HoodieMetaSyncException.class, () -> syncOnce(new HoodieDeltaStreamer(cfg, jsc, fs, hiveServer.getHiveConf()))); - assertTrue(e.getMessage().contains(MockSyncToolException1.class.getName())); - assertTrue(e.getMessage().contains(MockSyncToolException2.class.getName())); + Exception e = assertThrows(HoodieIngestionException.class, () -> syncOnce(new HoodieDeltaStreamer(cfg, jsc, fs, hiveServer.getHiveConf()))); + assertTrue(e.getCause() instanceof HoodieMetaSyncException); + assertTrue(e.getCause().getMessage().contains(MockSyncToolException1.class.getName())); + assertTrue(e.getCause().getMessage().contains(MockSyncToolException2.class.getName())); assertTrue(MockSyncTool1.syncSuccess); assertTrue(MockSyncTool2.syncSuccess); } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deser/TestKafkaAvroSchemaDeserializer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deser/TestKafkaAvroSchemaDeserializer.java index 6846724704337..01887573a91f4 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deser/TestKafkaAvroSchemaDeserializer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deser/TestKafkaAvroSchemaDeserializer.java @@ -30,8 +30,12 @@ import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; +import org.apache.kafka.common.header.internals.RecordHeaders; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; import java.util.HashMap; import java.util.Properties; @@ -125,4 +129,205 @@ public void testKafkaAvroSchemaDeserializer() { assertEquals(HoodieSchema.fromAvroSchema(actualRec.getSchema()), evolSchema); assertNull(genericRecord.get("age")); } + + private Schema loadSchemaFromResource(String resourcePath) throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath)) { + return new Schema.Parser().parse(is); + } + } + + private static Schema getRecordTypeFromUnion(Schema unionSchema) { + return unionSchema.getTypes().stream() + .filter(s -> s.getType() == Schema.Type.RECORD) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No record type in union")); + } + + private GenericRecord createCdcSourceRecord(Schema envelopeSchema) { + Schema sourceSchema = envelopeSchema.getField("source").schema(); + GenericRecord source = new GenericData.Record(sourceSchema); + source.put("version", "2.3.0.Final"); + source.put("connector", "mysql"); + source.put("name", "cdc"); + source.put("ts_ms", 1700000000000L); + source.put("snapshot", "false"); + source.put("db", "testdb"); + source.put("sequence", null); + source.put("table", "item"); + source.put("server_id", 1L); + source.put("gtid", null); + source.put("file", "binlog.000001"); + source.put("pos", 12345L); + source.put("row", 0); + source.put("thread", null); + source.put("query", null); + return source; + } + + private GenericRecord createCdcValueRecord(Schema envelopeSchema) { + Schema valueSchema = getRecordTypeFromUnion(envelopeSchema.getField("before").schema()); + GenericRecord value = new GenericData.Record(valueSchema); + value.put("id", ByteBuffer.wrap(new byte[]{1, 2, 3, 4})); + value.put("account_id", 42); + value.put("title", "Test Item"); + value.put("query", "test query"); + value.put("request_query_id", null); + value.put("page", null); + value.put("request_page_id", null); + value.put("source_rank_id", null); + value.put("property_id", 100); + value.put("created", "2024-01-01T00:00:00Z"); + value.put("created_by", 1); + value.put("updated", "2024-01-02T00:00:00Z"); + value.put("updated_by", 2); + value.put("version_id", null); + value.put("related_urls", null); + value.put("assignee", 5); + value.put("status", "IN_PROGRESS"); + value.put("tags", null); + value.put("deleted", false); + value.put("profile_id", null); + return value; + } + + private GenericRecord createCdcValueRecordBefore(Schema envelopeSchema) { + Schema valueSchema = getRecordTypeFromUnion(envelopeSchema.getField("before").schema()); + GenericRecord value = new GenericData.Record(valueSchema); + value.put("id", ByteBuffer.wrap(new byte[]{1, 2, 3, 4})); + value.put("account_id", 42); + value.put("title", "Old Item Title"); + value.put("query", "old query"); + value.put("request_query_id", null); + value.put("page", "https://example.com/old"); + value.put("request_page_id", null); + value.put("source_rank_id", 10); + value.put("property_id", 100); + value.put("created", "2024-01-01T00:00:00Z"); + value.put("created_by", 1); + value.put("updated", "2024-01-02T00:00:00Z"); + value.put("updated_by", 1); + value.put("version_id", "v1"); + value.put("related_urls", "[\"https://example.com\"]"); + value.put("assignee", 5); + value.put("status", "TO_DO"); + value.put("tags", null); + value.put("deleted", false); + value.put("profile_id", null); + return value; + } + + private GenericRecord createCdcEnvelopeRecord(Schema envelopeSchema, GenericRecord beforeRecord, GenericRecord afterRecord) { + GenericRecord envelope = new GenericData.Record(envelopeSchema); + envelope.put("before", beforeRecord); + envelope.put("after", afterRecord); + envelope.put("source", createCdcSourceRecord(envelopeSchema)); + envelope.put("op", "u"); + envelope.put("ts_ms", 1700000000000L); + envelope.put("transaction", null); + return envelope; + } + + /** + * Tests deserialization of a CDC (Debezium) envelope schema with schema evolution + * across all deserialize method overloads. The old schema lacks 4 fields + * (notes, search_engine_id, locale_id, language_id) in the nested Value record. + * When deserializing old records with the evolved schema, those new fields should default to null. + * + * Exercises: + * - deserialize(String topic, Boolean isKey, byte[] payload, Schema readerSchema) + * - deserialize(String topic, byte[] bytes) + * - deserialize(String topic, byte[] bytes, Schema readerSchema) + * - deserialize(String topic, Headers headers, byte[] bytes) + */ + @Test + public void testKafkaAvroSchemaDeserializerWithCdcEnvelopeEvolution() throws IOException { + Schema cdcOldSchema = loadSchemaFromResource("schema/cdc_envelope_old.avsc"); + Schema cdcNewSchema = loadSchemaFromResource("schema/cdc_envelope_new.avsc"); + + // Create and serialize records with old schema (no notes/search_engine_id/locale_id/language_id) + GenericRecord oldBefore = createCdcValueRecordBefore(cdcOldSchema); + GenericRecord oldAfter = createCdcValueRecord(cdcOldSchema); + GenericRecord oldEnvelope = createCdcEnvelopeRecord(cdcOldSchema, oldBefore, oldAfter); + byte[] bytesOldRecord = avroSerializer.serialize(topic, oldEnvelope); + + // Create and serialize records with evolved schema (new fields populated) + GenericRecord newBefore = createCdcValueRecordBefore(cdcNewSchema); + GenericRecord newAfter = createCdcValueRecord(cdcNewSchema); + newAfter.put("notes", "[{\"note\": \"test\"}]"); + newAfter.put("search_engine_id", "[\"default\"]"); + newAfter.put("locale_id", 1); + newAfter.put("language_id", 2); + GenericRecord newEnvelope = createCdcEnvelopeRecord(cdcNewSchema, newBefore, newAfter); + byte[] bytesNewRecord = avroSerializer.serialize(topic, newEnvelope); + + // Configure deserializer with evolved schema + config.put(AvroKafkaSource.KAFKA_AVRO_VALUE_DESERIALIZER_SCHEMA, cdcNewSchema.toString()); + KafkaAvroSchemaDeserializer deserializer = new KafkaAvroSchemaDeserializer(schemaRegistry, new HashMap(config)); + deserializer.configure(new HashMap(config), false); + + // === 1. deserialize(String, Boolean, byte[], Schema) — the existing override === + IndexedRecord evolvedDeserialized = (IndexedRecord) deserializer.deserialize(topic, false, bytesOldRecord, cdcNewSchema); + GenericRecord evolvedEnvelope = (GenericRecord) evolvedDeserialized; + assertEquals(cdcNewSchema, evolvedEnvelope.getSchema()); + assertEquals("u", evolvedEnvelope.get("op").toString()); + // Validate before record + GenericRecord beforeRecord = (GenericRecord) evolvedEnvelope.get("before"); + assertEquals("Old Item Title", beforeRecord.get("title").toString()); + assertEquals(42, beforeRecord.get("account_id")); + assertEquals("TO_DO", beforeRecord.get("status").toString()); + assertNull(beforeRecord.get(20)); + assertNull(beforeRecord.get(21)); + assertNull(beforeRecord.get(22)); + assertNull(beforeRecord.get(23)); + // Validate after record + GenericRecord afterRecord = (GenericRecord) evolvedEnvelope.get("after"); + assertEquals("Test Item", afterRecord.get("title").toString()); + assertNull(afterRecord.get("notes")); + assertNull(afterRecord.get("search_engine_id")); + assertNull(afterRecord.get("locale_id")); + assertNull(afterRecord.get("language_id")); + + // Evolved record via same method — should round-trip exactly + IndexedRecord newDeserialized = (IndexedRecord) deserializer.deserialize(topic, false, bytesNewRecord, cdcNewSchema); + assertEquals(newEnvelope, newDeserialized); + + // === 2. deserialize(String, byte[]) === + GenericRecord topicBytesResult = (GenericRecord) deserializer.deserialize(topic, bytesOldRecord); + GenericRecord tbBefore = (GenericRecord) topicBytesResult.get("before"); + assertEquals("Old Item Title", tbBefore.get("title").toString()); + assertNull(tbBefore.get(20)); + assertNull(tbBefore.get(21)); + assertNull(tbBefore.get(22)); + assertNull(tbBefore.get(23)); + + // === 3. deserialize(String, byte[], Schema) === + GenericRecord topicBytesSchemaResult = (GenericRecord) deserializer.deserialize(topic, bytesOldRecord, cdcNewSchema); + GenericRecord tbsBefore = (GenericRecord) topicBytesSchemaResult.get("before"); + assertEquals("Old Item Title", tbsBefore.get("title").toString()); + assertNull(tbsBefore.get(20)); + assertNull(tbsBefore.get(21)); + assertNull(tbsBefore.get(22)); + assertNull(tbsBefore.get(23)); + + // === 4. deserialize(String, Headers, byte[]) === + RecordHeaders headers = new RecordHeaders(); + GenericRecord headersResult = (GenericRecord) deserializer.deserialize(topic, headers, bytesOldRecord); + assertEquals(cdcNewSchema, headersResult.getSchema()); + GenericRecord hdrBefore = (GenericRecord) headersResult.get("before"); + assertEquals("Old Item Title", hdrBefore.get("title").toString()); + assertEquals(42, hdrBefore.get("account_id")); + assertNull(hdrBefore.get(20)); + assertNull(hdrBefore.get(21)); + assertNull(hdrBefore.get(22)); + assertNull(hdrBefore.get(23)); + + // New record via headers method + GenericRecord newHdrResult = (GenericRecord) deserializer.deserialize(topic, headers, bytesNewRecord); + GenericRecord newHdrAfter = (GenericRecord) newHdrResult.get("after"); + assertEquals("[{\"note\": \"test\"}]", newHdrAfter.get("notes").toString()); + assertEquals("[\"default\"]", newHdrAfter.get("search_engine_id").toString()); + assertEquals(1, newHdrAfter.get("locale_id")); + assertEquals(2, newHdrAfter.get("language_id")); + } + } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHiveSchemaProvider.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHiveSchemaProvider.java index bbd9eae152dc5..028e67c291b7a 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHiveSchemaProvider.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHiveSchemaProvider.java @@ -29,6 +29,7 @@ import org.apache.hudi.utilities.testutils.SparkClientFunctionalTestHarnessWithHiveSupport; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; +import lombok.extern.slf4j.Slf4j; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.junit.jupiter.api.Assertions; @@ -36,8 +37,6 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; @@ -46,9 +45,10 @@ /** * Basic tests against {@link HiveSchemaProvider}. */ +@Slf4j @Tag("functional") public class TestHiveSchemaProvider extends SparkClientFunctionalTestHarnessWithHiveSupport { - private static final Logger LOG = LoggerFactory.getLogger(TestHiveSchemaProvider.class); + private static final TypedProperties PROPS = new TypedProperties(); private static final String SOURCE_SCHEMA_TABLE_NAME = "schema_registry.source_schema_tab"; private static final String TARGET_SCHEMA_TABLE_NAME = "schema_registry.target_schema_tab"; @@ -75,7 +75,7 @@ public void testSourceSchema() throws Exception { assertNotNull(originalField); } } catch (HoodieException e) { - LOG.error("Failed to get source schema. ", e); + log.error("Failed to get source schema. ", e); throw e; } } @@ -97,7 +97,7 @@ public void testTargetSchema() throws Exception { assertNotNull(originalField); } } catch (HoodieException e) { - LOG.error("Failed to get source/target schema. ", e); + log.error("Failed to get source/target schema. ", e); throw e; } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieSnapshotExporter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieSnapshotExporter.java index 542349240189e..ebe26c89e9c34 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieSnapshotExporter.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieSnapshotExporter.java @@ -112,7 +112,7 @@ public void init() throws Exception { } List pathInfoList = storage.listFiles(new StoragePath(sourcePath)); for (StoragePathInfo pathInfo : pathInfoList) { - LOG.info(">>> Prepared test file: " + pathInfo.getPath()); + LOG.info(">>> Prepared test file: {}", pathInfo.getPath()); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestJdbcbasedSchemaProvider.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestJdbcbasedSchemaProvider.java index a13a270b4c02d..fc8755ff878cf 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestJdbcbasedSchemaProvider.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestJdbcbasedSchemaProvider.java @@ -26,11 +26,10 @@ import org.apache.hudi.utilities.schema.JdbcbasedSchemaProvider; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.DriverManager; @@ -43,10 +42,10 @@ import static org.apache.hudi.utilities.testutils.JdbcTestUtils.JDBC_USER; import static org.junit.jupiter.api.Assertions.assertEquals; +@Slf4j @Tag("functional") public class TestJdbcbasedSchemaProvider extends SparkClientFunctionalTestHarness { - private static final Logger LOG = LoggerFactory.getLogger(TestJdbcbasedSchemaProvider.class); private static final TypedProperties PROPS = new TypedProperties(); @BeforeAll @@ -67,7 +66,7 @@ public void testJdbcbasedSchemaProvider() throws Exception { HoodieSchema sourceSchema = UtilHelpers.createSchemaProvider(JdbcbasedSchemaProvider.class.getName(), PROPS, jsc()).getSourceHoodieSchema(); assertEquals(sourceSchema.toString().toUpperCase(), HoodieSchema.parse(UtilitiesTestBase.Helpers.readFile("streamer-config/source-jdbc.avsc")).toString().toUpperCase()); } catch (HoodieException e) { - LOG.error("Failed to get connection through jdbc. ", e); + log.error("Failed to get connection through jdbc. ", e); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/multitable/TestHoodieMultiTableServicesMain.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/multitable/TestHoodieMultiTableServicesMain.java index b93b70342b1cc..39e30475cbb02 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/multitable/TestHoodieMultiTableServicesMain.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/multitable/TestHoodieMultiTableServicesMain.java @@ -50,6 +50,7 @@ import org.apache.hudi.testutils.providers.SparkProvider; import org.apache.hudi.utilities.HoodieCompactor; +import lombok.extern.slf4j.Slf4j; import org.apache.hadoop.fs.Path; import org.apache.spark.HoodieSparkKryoRegistrar$; import org.apache.spark.SparkConf; @@ -61,8 +62,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; @@ -79,10 +78,9 @@ * Tests for HoodieMultiTableServicesMain * @see HoodieMultiTableServicesMain */ +@Slf4j class TestHoodieMultiTableServicesMain extends HoodieCommonTestHarness implements SparkProvider { - private static final Logger LOG = LoggerFactory.getLogger(TestHoodieMultiTableServicesMain.class); - protected boolean initialized = false; private static SparkSession spark; @@ -155,10 +153,10 @@ public void testStreamRunAllServices() throws IOException, ExecutionException, I new Thread(() -> { try { Thread.sleep(10000); - LOG.info("Shutdown the table services"); + log.info("Shutdown the table services"); main.cancel(); } catch (InterruptedException e) { - LOG.warn("InterruptedException: ", e); + log.warn("InterruptedException: ", e); } }).start(); main.startServices(); @@ -193,10 +191,10 @@ public void testRunMultiTableServicesWithOneWrongPath() throws IOException { new Thread(() -> { try { Thread.sleep(10000); - LOG.info("Shutdown the table services"); + log.info("Shutdown the table services"); main.cancel(); } catch (InterruptedException e) { - LOG.warn("InterruptedException: ", e); + log.warn("InterruptedException: ", e); } }).start(); try { diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/offlinejob/HoodieOfflineJobTestBase.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/offlinejob/HoodieOfflineJobTestBase.java index 4731c269040b1..634c434d3845f 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/offlinejob/HoodieOfflineJobTestBase.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/offlinejob/HoodieOfflineJobTestBase.java @@ -110,7 +110,7 @@ static class TestHelpers { static void assertNCompletedCommits(int expected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage, tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getWriteTimeline().filterCompletedInstants(); - LOG.info("Timeline Instants=" + meta.getActiveTimeline().getInstants()); + LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numCommits = timeline.countInstants(); assertEquals(expected, numCommits, "Got=" + numCommits + ", exp =" + expected); } @@ -118,7 +118,7 @@ static void assertNCompletedCommits(int expected, String tablePath) { static void assertNCleanCommits(int expected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage, tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCleanerTimeline().filterCompletedInstants(); - LOG.info("Timeline Instants=" + meta.getActiveTimeline().getInstants()); + LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numCleanCommits = timeline.countInstants(); assertEquals(expected, numCleanCommits, "Got=" + numCleanCommits + ", exp =" + expected); } @@ -126,7 +126,7 @@ static void assertNCleanCommits(int expected, String tablePath) { static void assertNClusteringCommits(int expected, String tablePath) { HoodieTableMetaClient meta = createMetaClient(storage, tablePath); HoodieTimeline timeline = meta.getActiveTimeline().getCompletedReplaceTimeline(); - LOG.info("Timeline Instants=" + meta.getActiveTimeline().getInstants()); + LOG.info("Timeline Instants={}", meta.getActiveTimeline().getInstants()); int numCommits = timeline.countInstants(); assertEquals(expected, numCommits, "Got=" + numCommits + ", exp =" + expected); } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/BaseTestKafkaSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/BaseTestKafkaSource.java index 243ad570ad75c..5c836a95c8f49 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/BaseTestKafkaSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/BaseTestKafkaSource.java @@ -33,6 +33,9 @@ import org.apache.hudi.utilities.streamer.SourceProfileSupplier; import org.apache.hudi.utilities.testutils.KafkaTestUtils; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; import org.apache.avro.generic.GenericRecord; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.KafkaConsumer; @@ -323,28 +326,15 @@ public void testKafkaSourceWithOffsetsFromSourceProfile() { verify(metrics, times(2)).updateStreamerSourceBytesToBeIngestedInSyncRound(Long.MAX_VALUE); } + @AllArgsConstructor + @Getter static class TestSourceProfile implements SourceProfile { private final long maxSourceBytes; private final int sourcePartitions; + @Getter(AccessLevel.NONE) private final long numEvents; - public TestSourceProfile(long maxSourceBytes, int sourcePartitions, long numEvents) { - this.maxSourceBytes = maxSourceBytes; - this.sourcePartitions = sourcePartitions; - this.numEvents = numEvents; - } - - @Override - public long getMaxSourceBytes() { - return maxSourceBytes; - } - - @Override - public int getSourcePartitions() { - return sourcePartitions; - } - @Override public Long getSourceSpecificContext() { return numEvents; diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java index 5e43daaa2a9c2..ecf34920218d7 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/S3EventsHoodieIncrSourceHarness.java @@ -56,6 +56,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaRDD; @@ -279,27 +282,15 @@ protected void readAndAssert(IncrSourceHelper.MissingCheckpointStrategy missingC readAndAssert(missingCheckpointStrategy, checkpointToPull, sourceLimit, expectedCheckpoint, typedProperties); } + @AllArgsConstructor + @Getter static class TestSourceProfile implements SourceProfile { + private final long maxSourceBytes; private final int sourcePartitions; + @Getter(AccessLevel.NONE) private final long bytesPerPartition; - public TestSourceProfile(long maxSourceBytes, int sourcePartitions, long bytesPerPartition) { - this.maxSourceBytes = maxSourceBytes; - this.sourcePartitions = sourcePartitions; - this.bytesPerPartition = bytesPerPartition; - } - - @Override - public long getMaxSourceBytes() { - return maxSourceBytes; - } - - @Override - public int getSourcePartitions() { - return sourcePartitions; - } - @Override public Long getSourceSpecificContext() { return bytesPerPartition; diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java index 923aba49f5e9b..ed993a837c99b 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java @@ -24,22 +24,23 @@ import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.testutils.sources.AbstractBaseTestSource; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.SparkSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.List; import java.util.stream.Collectors; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; + /** * An implementation of {@link Source}, that emits test upserts. */ +@Slf4j public class TestDataSource extends AbstractBaseTestSource { - private static final Logger LOG = LoggerFactory.getLogger(TestDataSource.class); public static boolean returnEmptyBatch = false; public static Option recordInstantTime = Option.empty(); private static int counter = 0; @@ -56,20 +57,20 @@ protected InputBatch> readFromCheckpoint(Option Integer.parseInt(s.getCheckpointKey()) + 1).orElse(0); String instantTime = String.format("%05d", nextCommitNum); - LOG.info("Source Limit is set to " + sourceLimit); + log.info("Source Limit is set to {}", sourceLimit); // No new data. if (sourceLimit <= 0 || returnEmptyBatch) { - LOG.warn("Return no new data from Test Data source " + counter + ", source limit " + sourceLimit); + log.warn("Return no new data from Test Data source {}, source limit {}", counter, sourceLimit); return new InputBatch<>(Option.empty(), lastCheckpoint.orElse(null)); } else { - LOG.warn("Returning valid data from Test Data source " + counter + ", source limit " + sourceLimit); + log.warn("Returning valid data from Test Data source {}, source limit {}", counter, sourceLimit); } counter++; List records = fetchNextBatch(props, (int) sourceLimit, recordInstantTime.orElse(instantTime), DEFAULT_PARTITION_NUM).collect(Collectors.toList()); JavaRDD avroRDD = sparkContext.parallelize(records, 4); - return new InputBatch<>(Option.of(avroRDD), instantTime); + return new InputBatch<>(Option.of(avroRDD), createCheckpoint(instantTime)); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java index 9e95d3e6d4f18..746760a86307d 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsHoodieIncrSource.java @@ -57,6 +57,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaRDD; @@ -73,8 +74,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; @@ -96,6 +95,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@Slf4j public class TestGcsEventsHoodieIncrSource extends SparkClientFunctionalTestHarness { private static final HoodieSchema GCS_METADATA_SCHEMA = SchemaTestUtil.getSchemaFromResource( @@ -122,8 +122,6 @@ public class TestGcsEventsHoodieIncrSource extends SparkClientFunctionalTestHarn private HoodieTableMetaClient metaClient; private JavaSparkContext jsc; - private static final Logger LOG = LoggerFactory.getLogger(TestGcsEventsHoodieIncrSource.class); - @BeforeEach public void setUp() throws IOException { metaClient = getHoodieMetaClient(storageConf(), basePath()); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsSource.java index c45a9eaf7d107..e7b9b2b46c3cd 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestGcsEventsSource.java @@ -20,6 +20,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; @@ -60,7 +61,7 @@ public class TestGcsEventsSource extends UtilitiesTestBase { protected FilebasedSchemaProvider schemaProvider; private TypedProperties props; - private static final Checkpoint CHECKPOINT_VALUE_ZERO = new StreamerCheckpointV2("0"); + private static final Checkpoint CHECKPOINT_VALUE_ZERO = new StreamerCheckpointV1("0"); @BeforeAll public static void beforeAll() throws Exception { diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHiveIncrPullSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHiveIncrPullSource.java new file mode 100644 index 0000000000000..8fbb98e0ab6df --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHiveIncrPullSource.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.hudi.utilities.sources; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.testutils.HoodieSparkClientTestHarness; +import org.apache.hudi.utilities.config.HiveIncrPullSourceConfig; + +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestHiveIncrPullSource extends HoodieSparkClientTestHarness { + + private TypedProperties props; + private String incrPullRoot; + + @BeforeEach + void setUp() throws Exception { + initSparkContexts(); + initPath(); + initHoodieStorage(); + incrPullRoot = basePath + "/incrPullRoot"; + FileSystem fs = (FileSystem) storage.getFileSystem(); + fs.mkdirs(new Path(incrPullRoot)); + props = new TypedProperties(); + props.setProperty(HiveIncrPullSourceConfig.ROOT_INPUT_PATH.key(), incrPullRoot); + } + + @AfterEach + void teardown() throws Exception { + cleanupResources(); + } + + private void createCommitDir(String commitTime) throws Exception { + FileSystem fs = (FileSystem) storage.getFileSystem(); + fs.mkdirs(new Path(incrPullRoot, commitTime)); + } + + @Test + void findCommitToPullReturnsV1CheckpointForFirstCommitWhenNoLatestTargetCommit() throws Exception { + createCommitDir("20240101"); + createCommitDir("20240102"); + HiveIncrPullSource source = new HiveIncrPullSource(props, jsc, sparkSession, null); + Method findCommitToPull = HiveIncrPullSource.class.getDeclaredMethod("findCommitToPull", Option.class); + findCommitToPull.setAccessible(true); + + @SuppressWarnings("unchecked") + Option result = (Option) findCommitToPull.invoke(source, Option.empty()); + + assertTrue(result.isPresent()); + assertInstanceOf(StreamerCheckpointV1.class, result.get()); + assertEquals("20240101", result.get().getCheckpointKey()); + } + + @Test + void readFromCheckpointRewrapsV2LastCheckpointAsV1WhenNoCommitToPull() throws Exception { + createCommitDir("20240101"); + createCommitDir("20240102"); + HiveIncrPullSource source = new HiveIncrPullSource(props, jsc, sparkSession, null); + + // lastCheckpoint past the last commit so findCommitToPull returns empty and the early return hits. + InputBatch batch = source.readFromCheckpoint( + Option.of(new StreamerCheckpointV2("20240103")), Long.MAX_VALUE); + + assertFalse(batch.getBatch().isPresent()); + assertInstanceOf(StreamerCheckpointV1.class, batch.getCheckpointForNextBatch()); + assertEquals("20240103", batch.getCheckpointForNextBatch().getCheckpointKey()); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHoodieIncrSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHoodieIncrSource.java index 6011339a48a76..d94953f75da41 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHoodieIncrSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestHoodieIncrSource.java @@ -67,6 +67,9 @@ import org.apache.hudi.utilities.streamer.SourceProfile; import org.apache.hudi.utilities.streamer.SourceProfileSupplier; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; import org.apache.spark.SparkConf; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; @@ -1092,47 +1095,28 @@ private static Stream getArgsForLogicalPlanSizeValidation() { ); } + @AllArgsConstructor + @Getter static class TestSourceProfile implements SourceProfile { private final long maxSourceBytes; private final int sourcePartitions; + @Getter(AccessLevel.NONE) private final int numInstantsPerFetch; - public TestSourceProfile(long maxSourceBytes, int sourcePartitions, int numInstantsPerFetch) { - this.maxSourceBytes = maxSourceBytes; - this.sourcePartitions = sourcePartitions; - this.numInstantsPerFetch = numInstantsPerFetch; - } - - @Override - public long getMaxSourceBytes() { - return maxSourceBytes; - } - - @Override - public int getSourcePartitions() { - return sourcePartitions; - } - @Override public Integer getSourceSpecificContext() { return numInstantsPerFetch; } } + @AllArgsConstructor + @Getter static class WriteResult { + private HoodieInstant instant; private List records; - WriteResult(HoodieInstant instant, List records) { - this.instant = instant; - this.records = records; - } - - public HoodieInstant getInstant() { - return instant; - } - public String getInstantTime() { return instant.requestedTime(); } @@ -1140,9 +1124,5 @@ public String getInstantTime() { public String getCompletionTime() { return instant.getCompletionTime(); } - - public List getRecords() { - return records; - } } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestInputBatch.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestInputBatch.java index f7bb36076a7a3..bf969ae2ced43 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestInputBatch.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestInputBatch.java @@ -34,7 +34,7 @@ public class TestInputBatch { @Test public void getSchemaProviderShouldThrowException() { - final InputBatch inputBatch = new InputBatch<>(Option.of("foo"), (String) null, null); + final InputBatch inputBatch = new InputBatch<>(Option.of("foo"), null, null); Throwable t = assertThrows(HoodieException.class, inputBatch::getSchemaProvider); assertEquals("Schema provider is required for this operation and for the source of interest. " + "Please set '--schemaprovider-class' in the top level HoodieStreamer config for the source of interest. " @@ -45,7 +45,7 @@ public void getSchemaProviderShouldThrowException() { @Test public void getSchemaProviderShouldReturnNullSchemaProvider() { - final InputBatch inputBatch = new InputBatch<>(Option.empty(), (String) null, null); + final InputBatch inputBatch = new InputBatch<>(Option.empty(), null, null); SchemaProvider schemaProvider = inputBatch.getSchemaProvider(); assertTrue(schemaProvider instanceof InputBatch.NullSchemaProvider); } @@ -53,7 +53,7 @@ public void getSchemaProviderShouldReturnNullSchemaProvider() { @Test public void getSchemaProviderShouldReturnGivenSchemaProvider() { SchemaProvider schemaProvider = new RowBasedSchemaProvider(null); - final InputBatch inputBatch = new InputBatch<>(Option.of("foo"), (String) null, schemaProvider); + final InputBatch inputBatch = new InputBatch<>(Option.of("foo"), null, schemaProvider); assertSame(schemaProvider, inputBatch.getSchemaProvider()); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJdbcSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJdbcSource.java index 6d00c9c91bf6f..9fc76f94f5139 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJdbcSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJdbcSource.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.util.Option; @@ -273,7 +274,7 @@ public void testIncrementalFetchWhenLastCheckpointMoreThanTableRecords() { InputBatch> batch = runSource(Option.empty(), 100); Dataset rowDataset = batch.getBatch().get(); assertEquals(100, rowDataset.count()); - assertEquals(new StreamerCheckpointV2("100"), batch.getCheckpointForNextBatch()); + assertEquals(new StreamerCheckpointV1("100"), batch.getCheckpointForNextBatch()); // Add 100 records with commit time "001" insert("001", 100, connection, DATA_GENERATOR, PROPS); @@ -361,7 +362,7 @@ public void testFullFetchWithCheckpoint() { InputBatch> batch = runSource(Option.empty(), 10); Dataset rowDataset = batch.getBatch().get(); assertEquals(10, rowDataset.count()); - assertEquals(new StreamerCheckpointV2(""), batch.getCheckpointForNextBatch()); + assertEquals(new StreamerCheckpointV1(""), batch.getCheckpointForNextBatch()); // Get max of incremental column Column incrementalColumn = rowDataset diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSource.java new file mode 100644 index 0000000000000..e6e4897e05f13 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSource.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.hudi.utilities.sources; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.Option; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for {@link Source#releaseResources()}. + * + *

    releaseResources() is invoked from StreamSync.syncOnce()'s finally block, after the + * write/commit has already completed. A transient Spark failure while unpersisting the + * cached source RDD (e.g. a NullPointerException from BlockManagerMaster.removeRdd when + * the SparkContext is mid-teardown/stopping) must not fail an otherwise-successful + * ingestion round. + */ +public class TestSource { + + /** + * Minimal concrete {@link Source} whose unpersist step always fails, so the + * swallow-on-failure behaviour of releaseResources() can be exercised without a + * live SparkContext. + */ + private static class MockSourceWithUnpersistenceFailure extends Source { + private int unpersistCalls = 0; + + MockSourceWithUnpersistenceFailure(TypedProperties props) { + super(props, null, null, null); + } + + @Override + protected InputBatch fetchNewData(Option lastCkptStr, long sourceLimit) { + // Not exercised by these tests; releaseResources() is tested directly. + return null; + } + + @Override + protected void unpersistCachedSourceRdd() { + unpersistCalls++; + throw new NullPointerException("simulated BlockManagerMaster.removeRdd NPE"); + } + } + + @Test + public void releaseResourcesSwallowsTransientUnpersistFailure() { + MockSourceWithUnpersistenceFailure source = + new MockSourceWithUnpersistenceFailure(new TypedProperties()); + assertDoesNotThrow(source::releaseResources, + "A transient unpersist failure in cleanup must not propagate out of releaseResources()"); + assertEquals(1, source.unpersistCalls, "unpersist should have been attempted exactly once"); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlFileBasedSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlFileBasedSource.java index f89552e62390b..72984e7247f96 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlFileBasedSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlFileBasedSource.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; import org.apache.hudi.utilities.streamer.SourceFormatAdapter; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; @@ -45,6 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; /** * Test against {@link SqlSource}. @@ -60,6 +62,7 @@ public class TestSqlFileBasedSource extends UtilitiesTestBase { private TypedProperties props; private SqlFileBasedSource sqlFileSource; private SourceFormatAdapter sourceFormatAdapter; + private final HoodieIngestionMetrics metrics = mock(HoodieIngestionMetrics.class); @BeforeAll public static void initClass() throws Exception { @@ -114,7 +117,7 @@ public void testSqlFileBasedSourceAvroFormat() throws IOException { UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); props.setProperty(sqlFileSourceConfig, UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); - sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider); + sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlFileSource); // Test fetching Avro format @@ -141,7 +144,7 @@ public void testSqlFileBasedSourceRowFormat() throws IOException { UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); props.setProperty(sqlFileSourceConfig, UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); - sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider); + sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlFileSource); // Test fetching Row format @@ -163,7 +166,7 @@ public void testSqlFileBasedSourceMoreRecordsThanSourceLimit() throws IOExceptio UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); props.setProperty(sqlFileSourceConfig, UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); - sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider); + sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlFileSource); InputBatch> fetch1AsRows = @@ -184,7 +187,7 @@ public void testSqlFileBasedSourceInvalidTable() throws IOException { UtilitiesTestBase.basePath + "/sql-file-based-source-invalid-table.sql"); props.setProperty(sqlFileSourceConfig, UtilitiesTestBase.basePath + "/sql-file-based-source-invalid-table.sql"); - sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider); + sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlFileSource); assertThrows( @@ -201,7 +204,7 @@ public void shouldSetCheckpointForSqlFileBasedSourceWithEpochCheckpoint() throws props.setProperty(sqlFileSourceConfig, UtilitiesTestBase.basePath + "/sql-file-based-source.sql"); props.setProperty(sqlFileSourceConfigEmitChkPointConf, "true"); - sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider); + sqlFileSource = new SqlFileBasedSource(props, jsc, sparkSession, schemaProvider, metrics); Pair>, Checkpoint> nextBatch = sqlFileSource.fetchNextBatch(Option.empty(), Long.MAX_VALUE); assertEquals(10000, nextBatch.getLeft().get().count()); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlSource.java index 146c5253d5f5b..8a393e8b982eb 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestSqlSource.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; import org.apache.hudi.common.util.Option; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; import org.apache.hudi.utilities.streamer.SourceFormatAdapter; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; @@ -43,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; /** * Test against {@link SqlSource}. @@ -57,6 +59,7 @@ public class TestSqlSource extends UtilitiesTestBase { private TypedProperties props; private SqlSource sqlSource; private SourceFormatAdapter sourceFormatAdapter; + private final HoodieIngestionMetrics metrics = mock(HoodieIngestionMetrics.class); @BeforeAll public static void initClass() throws Exception { @@ -105,7 +108,7 @@ private void generateTestTable(String filename, String instantTime, int n) throw @Test public void testSqlSourceAvroFormat() throws IOException { props.setProperty(sqlSourceConfig, "select * from test_sql_table"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); // Test fetching Avro format @@ -128,7 +131,7 @@ public void testSqlSourceAvroFormat() throws IOException { @Test public void testSqlSourceRowFormat() throws IOException { props.setProperty(sqlSourceConfig, "select * from test_sql_table"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); // Test fetching Row format @@ -146,7 +149,7 @@ public void testSqlSourceRowFormat() throws IOException { @Test public void testSqlSourceCheckpoint() throws IOException { props.setProperty(sqlSourceConfig, "select * from test_sql_table where 1=0"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); InputBatch> fetch1AsRows = @@ -163,7 +166,7 @@ public void testSqlSourceCheckpoint() throws IOException { @Test public void testSqlSourceMoreRecordsThanSourceLimit() throws IOException { props.setProperty(sqlSourceConfig, "select * from test_sql_table"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); InputBatch> fetch1AsRows = @@ -180,7 +183,7 @@ public void testSqlSourceMoreRecordsThanSourceLimit() throws IOException { @Test public void testSqlSourceZeroRecord() throws IOException { props.setProperty(sqlSourceConfig, "select * from test_sql_table where 1=0"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); InputBatch> fetch1AsRows = @@ -197,7 +200,7 @@ public void testSqlSourceZeroRecord() throws IOException { @Test public void testSqlSourceInvalidTable() throws IOException { props.setProperty(sqlSourceConfig, "select * from not_exist_sql_table"); - sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider); + sqlSource = new SqlSource(props, jsc, sparkSession, schemaProvider, metrics); sourceFormatAdapter = new SourceFormatAdapter(sqlSource); assertThrows( diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestStreamerSourceCheckpointVersion.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestStreamerSourceCheckpointVersion.java new file mode 100644 index 0000000000000..70ec4e708b9b3 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestStreamerSourceCheckpointVersion.java @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hudi.utilities.sources; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.CheckpointUtils; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.Pair; +import org.apache.hudi.utilities.config.DFSPathSelectorConfig; +import org.apache.hudi.utilities.config.JdbcSourceConfig; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; +import org.apache.hudi.utilities.schema.SchemaProvider; +import org.apache.hudi.utilities.sources.helpers.DFSPathSelector; +import org.apache.hudi.utilities.sources.helpers.KafkaOffsetGen; +import org.apache.hudi.utilities.sources.helpers.KinesisOffsetGen; +import org.apache.hudi.utilities.sources.helpers.KinesisOffsetGen.KinesisShardRange; +import org.apache.hudi.utilities.sources.helpers.gcs.PubsubMessagesFetcher; +import org.apache.hudi.utilities.streamer.DefaultStreamContext; +import org.apache.hudi.utilities.streamer.StreamerCheckpointUtils; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +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.RowFactory; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.streaming.kafka010.OffsetRange; +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.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Validates that every non-incremental streamer source emits a V1 checkpoint regardless of the + * configured write table version or the wrapper class of the input checkpoint (so V2 keys + * persisted by older releases are read on input but never written back as V2). Hudi incremental + * sources own their own V1/V2 semantics and are exercised by + * {@link #testS3AndGcsIncrSourcesStayV1OnBothTableVersions()} plus + * {@code TestHoodieIncrSource} family. + */ +class TestStreamerSourceCheckpointVersion { + + private static JavaSparkContext jsc; + private static SparkSession spark; + + @TempDir + static java.nio.file.Path tempDir; + + @BeforeAll + static void initSpark() { + spark = SparkSession.builder().master("local[1]").appName("TestSourceCheckpointVersion").getOrCreate(); + jsc = new JavaSparkContext(spark.sparkContext()); + } + + @AfterAll + static void stopSpark() { + if (jsc != null) { + jsc.stop(); + } + if (spark != null) { + spark.stop(); + } + } + + // Covers AvroDFSSource, JsonDFSSource, CsvDFSSource, ParquetDFSSource, ORCDFSSource. + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testDfsPathSelector(int writeTableVersion) throws IOException { + TypedProperties props = propsWith(writeTableVersion); + props.setProperty(DFSPathSelectorConfig.ROOT_INPUT_PATH.key(), tempDir.toString()); + Configuration hadoopConf = jsc.hadoopConfiguration(); + FileSystem fs = FileSystem.get(hadoopConf); + fs.mkdirs(new Path(tempDir.toString())); + DFSPathSelector selector = new DFSPathSelector(props, hadoopConf); + Pair, Checkpoint> result = + selector.getNextFilePathsAndMaxModificationTime(jsc, Option.empty(), Long.MAX_VALUE); + assertV1(result.getRight()); + } + + // Covers AvroKafkaSource, JsonKafkaSource, ProtoKafkaSource. + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testKafkaSource(int writeTableVersion, InputCheckpointKind inputKind) { + TestableKafkaSource source = new TestableKafkaSource(propsWith(writeTableVersion), jsc, spark); + KafkaOffsetGen offsetGen = mock(KafkaOffsetGen.class); + when(offsetGen.getNextOffsetRanges(any(), anyLong(), any())).thenReturn( + new OffsetRange[] {OffsetRange.create("t", 0, 0L, 0L)}); + when(offsetGen.getTopicName()).thenReturn("t"); + source.offsetGen = offsetGen; + InputBatch batch = source.fetchNext(makeInputCheckpoint(inputKind, "k"), 1L); + assertV1(batch.getCheckpointForNextBatch()); + } + + // Covers JsonKinesisSource. + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testKinesisSource(int writeTableVersion, InputCheckpointKind inputKind) { + TestableKinesisSource source = new TestableKinesisSource(propsWith(writeTableVersion), jsc, spark); + KinesisOffsetGen offsetGen = mock(KinesisOffsetGen.class); + when(offsetGen.getStreamName()).thenReturn("s"); + when(offsetGen.getNextShardRanges(any(), anyLong())).thenReturn( + new KinesisShardRange[0]); + source.setOffsetGen(offsetGen); + InputBatch> batch = source.fetchNext(makeInputCheckpoint(inputKind, "s"), 1L); + assertV1(batch.getCheckpointForNextBatch()); + } + + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testJdbcSource(int writeTableVersion, InputCheckpointKind inputKind) throws Exception { + JdbcSource source = new JdbcSource(propsWith(writeTableVersion), jsc, spark, null); + Method m = JdbcSource.class.getDeclaredMethod( + "checkpoint", Dataset.class, boolean.class, Option.class); + m.setAccessible(true); + Checkpoint c = (Checkpoint) m.invoke(source, null, false, makeInputCheckpoint(inputKind, "k")); + assertV1(c); + } + + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testSqlFileBasedSource(int writeTableVersion, InputCheckpointKind inputKind) throws IOException { + java.nio.file.Path sqlFile = tempDir.resolve("q.sql"); + Files.write(sqlFile, "SELECT 1".getBytes()); + TypedProperties props = propsWith(writeTableVersion); + props.setProperty("hoodie.streamer.source.sql.file", sqlFile.toString()); + props.setProperty("hoodie.streamer.source.sql.checkpoint.emit", "true"); + SqlFileBasedSource source = new SqlFileBasedSource(props, jsc, spark, null, null); + Pair>, Checkpoint> result = + invokeRowSourceFetch(source, makeInputCheckpoint(inputKind, "k")); + assertV1(result.getRight()); + } + + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testHiveIncrPullSource(int writeTableVersion, InputCheckpointKind inputKind) throws Exception { + Files.createDirectories(tempDir.resolve("20200101000000")); + TypedProperties props = propsWith(writeTableVersion); + props.setProperty("hoodie.streamer.source.incrpull.root", tempDir.toString()); + HiveIncrPullSource source = new HiveIncrPullSource(props, jsc, spark, null); + Method m = HiveIncrPullSource.class.getDeclaredMethod("findCommitToPull", Option.class); + m.setAccessible(true); + @SuppressWarnings("unchecked") + Option result = (Option) m.invoke( + source, makeInputCheckpoint(inputKind, "00000000000000")); + assertV1(result.get()); + } + + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testGcsEventsSource(int writeTableVersion, InputCheckpointKind inputKind) { + PubsubMessagesFetcher fetcher = mock(PubsubMessagesFetcher.class); + when(fetcher.fetchMessages()).thenReturn(Collections.emptyList()); + TypedProperties props = propsWith(writeTableVersion); + props.setProperty("hoodie.streamer.source.gcs.project.id", "p"); + props.setProperty("hoodie.streamer.source.gcs.subscription.id", "s"); + GcsEventsSource source = new GcsEventsSource(props, jsc, spark, null, fetcher); + Pair>, Checkpoint> result = + invokeRowSourceFetch(source, makeInputCheckpoint(inputKind, "k")); + assertV1(result.getRight()); + } + + @ParameterizedTest + @EnumSource(InputCheckpointKind.class) + void testJdbcSourceIncrementalWithMaxValueEmitsV1(InputCheckpointKind inputKind) throws Exception { + TypedProperties props = propsWith(8); + props.setProperty(JdbcSourceConfig.INCREMENTAL_COLUMN.key(), "idx"); + JdbcSource source = new JdbcSource(props, jsc, spark, null); + StructType schema = new StructType().add("idx", DataTypes.StringType, true); + List rows = Arrays.asList(RowFactory.create("100"), RowFactory.create("200")); + Dataset dataset = spark.createDataFrame(rows, schema); + Method m = JdbcSource.class.getDeclaredMethod( + "checkpoint", Dataset.class, boolean.class, Option.class); + m.setAccessible(true); + Checkpoint c = (Checkpoint) m.invoke(source, dataset, true, makeInputCheckpoint(inputKind, "k")); + assertV1(c); + assertEquals("200", c.getCheckpointKey()); + } + + // Drives the isIncremental + max==null pass-through to confirm it re-wraps a V2 input as V1. + @ParameterizedTest + @EnumSource(InputCheckpointKind.class) + void testJdbcSourcePassThroughEmitsV1(InputCheckpointKind inputKind) throws Exception { + TypedProperties props = propsWith(8); + props.setProperty(JdbcSourceConfig.INCREMENTAL_COLUMN.key(), "idx"); + JdbcSource source = new JdbcSource(props, jsc, spark, null); + StructType schema = new StructType().add("idx", DataTypes.StringType, true); + List rows = Collections.singletonList(RowFactory.create((Object) null)); + Dataset nullColDataset = spark.createDataFrame(rows, schema); + Method m = JdbcSource.class.getDeclaredMethod( + "checkpoint", Dataset.class, boolean.class, Option.class); + m.setAccessible(true); + Checkpoint c = (Checkpoint) m.invoke(source, nullColDataset, true, makeInputCheckpoint(inputKind, "k")); + assertV1(c); + } + + // Input key sorts after the only commit on disk so findCommitToPull returns empty and + // readFromCheckpoint enters its pass-through branch. + @ParameterizedTest + @EnumSource(InputCheckpointKind.class) + void testHiveIncrPullSourcePassThroughEmitsV1(InputCheckpointKind inputKind) throws IOException { + Files.createDirectories(tempDir.resolve("20200101000000")); + TypedProperties props = propsWith(8); + props.setProperty("hoodie.streamer.source.incrpull.root", tempDir.toString()); + HiveIncrPullSource source = new HiveIncrPullSource(props, jsc, spark, null); + InputBatch batch = source.readFromCheckpoint( + makeInputCheckpoint(inputKind, "30000000000000"), 1L); + assertV1(batch.getCheckpointForNextBatch()); + } + + @ParameterizedTest + @CsvSource({"6, V1", "6, V2", "8, V1", "8, V2"}) + void testTranslateCheckpointNormalizesToV1(int writeTableVersion, InputCheckpointKind inputKind) { + TestableKafkaSource source = new TestableKafkaSource(propsWith(writeTableVersion), jsc, spark); + Option translated = source.translateCheckpoint(makeInputCheckpoint(inputKind, "k")); + assertV1(translated.get()); + assertEquals("k", translated.get().getCheckpointKey()); + } + + @ParameterizedTest + @ValueSource(ints = {6, 8}) + void testTranslateCheckpointPreservesEmpty(int writeTableVersion) { + TestableKafkaSource source = new TestableKafkaSource(propsWith(writeTableVersion), jsc, spark); + assertTrue(source.translateCheckpoint(Option.empty()).isEmpty()); + } + + @Test + void testS3AndGcsIncrSourcesStayV1OnBothTableVersions() { + String s3 = S3EventsHoodieIncrSource.class.getName(); + String gcs = GcsEventsHoodieIncrSource.class.getName(); + assertTrue(CheckpointUtils.DATASOURCES_NOT_SUPPORTED_WITH_CKPT_V2.contains(s3)); + assertTrue(CheckpointUtils.DATASOURCES_NOT_SUPPORTED_WITH_CKPT_V2.contains(gcs)); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(8, s3)); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(8, gcs)); + } + + private static TypedProperties propsWith(int writeTableVersion) { + TypedProperties props = new TypedProperties(); + props.setProperty(WRITE_TABLE_VERSION.key(), String.valueOf(writeTableVersion)); + return props; + } + + private static void assertV1(Checkpoint c) { + assertEquals(StreamerCheckpointV1.class, c.getClass()); + } + + @SuppressWarnings("unchecked") + private static Pair>, Checkpoint> invokeRowSourceFetch( + RowSource source, Option lastCheckpoint) { + try { + Method m = source.getClass().getDeclaredMethod("fetchNextBatch", Option.class, long.class); + m.setAccessible(true); + return (Pair>, Checkpoint>) m.invoke(source, lastCheckpoint, 1L); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + private enum InputCheckpointKind { V1, V2 } + + private static Option makeInputCheckpoint(InputCheckpointKind kind, String key) { + switch (kind) { + case V1: return Option.of(new StreamerCheckpointV1(key)); + case V2: return Option.of(new StreamerCheckpointV2(key)); + default: throw new IllegalArgumentException("Unsupported kind: " + kind); + } + } + + private static class TestableKafkaSource extends KafkaSource { + TestableKafkaSource(TypedProperties props, JavaSparkContext jsc, SparkSession spark) { + super(props, jsc, spark, SourceType.JSON, mock(HoodieIngestionMetrics.class), + new DefaultStreamContext(null, Option.empty())); + } + + @Override + protected String toBatch(OffsetRange[] offsetRanges) { + return "batch"; + } + } + + private static class TestableKinesisSource extends KinesisSource> { + TestableKinesisSource(TypedProperties props, JavaSparkContext jsc, SparkSession spark) { + super(props, jsc, spark, SourceType.JSON, mock(HoodieIngestionMetrics.class), + new DefaultStreamContext((SchemaProvider) null, Option.empty())); + } + + void setOffsetGen(KinesisOffsetGen gen) { + this.offsetGen = gen; + } + + @Override + protected JavaRDD toBatch(KinesisShardRange[] shardRanges, long sourceLimit) { + return jsc.emptyRDD(); + } + + @Override + protected String createCheckpointFromBatch(JavaRDD batch, + KinesisShardRange[] shardRangesWithUnreadRecords, + KinesisShardRange[] allOpenClosedShardRanges) { + return "checkpoint"; + } + + @Override + protected long getRecordCount(JavaRDD batch) { + return 1L; + } + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestDFSPathSelectorCommonMethods.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestDFSPathSelectorCommonMethods.java index 5b3d970c03680..30e138d5e3e96 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestDFSPathSelectorCommonMethods.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestDFSPathSelectorCommonMethods.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.ReflectionUtils; @@ -44,6 +45,7 @@ import static org.apache.hudi.utilities.config.DFSPathSelectorConfig.ROOT_INPUT_PATH; import static org.apache.hudi.utilities.config.DatePartitionPathSelectorConfig.PARTITIONS_LIST_PARALLELISM; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; public class TestDFSPathSelectorCommonMethods extends HoodieSparkClientTestHarness { @@ -164,4 +166,17 @@ public void getNextFilePathsAndMaxModificationTimeShouldIgnoreSourceLimitIfSameM String checkpointStr2ndRead = nextFilePathsAndCheckpoint.getRight().getCheckpointKey(); assertEquals(2000L, Long.parseLong(checkpointStr2ndRead), "should read up to foo5 (inclusive)"); } + + @ParameterizedTest + @ValueSource(classes = {DFSPathSelector.class, DatePartitionPathSelector.class}) + void getNextFilePathsAndMaxModificationTimeReturnsV1CheckpointWhenNoEligibleFiles(Class clazz) throws Exception { + DFSPathSelector selector = (DFSPathSelector) ReflectionUtils.loadClass(clazz.getName(), props, storageConf.unwrap()); + createBaseFile(basePath, "p1", "000", "foo1", 10, 1000); + createBaseFile(basePath, "p1", "000", "foo2", 10, 2000); + Pair, Checkpoint> result = selector + .getNextFilePathsAndMaxModificationTime(jsc, Option.of(new StreamerCheckpointV2("999999999")), 30); + assertTrue(result.getLeft().isEmpty()); + assertInstanceOf(StreamerCheckpointV1.class, result.getRight()); + assertEquals("999999999", result.getRight().getCheckpointKey()); + } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestErrorTableCommitter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestErrorTableCommitter.java new file mode 100644 index 0000000000000..4298079743423 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestErrorTableCommitter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.util.Option; + +import org.apache.spark.api.java.JavaRDD; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ErrorTableCommitter} covering the two write paths (unification on/off), + * the success/failure pass-through contract, and the no-op when no RDD is present. + */ +public class TestErrorTableCommitter { + + private static final String INSTANT = "20260520120000000"; + + @SuppressWarnings("unchecked") + private static JavaRDD rdd() { + return (JavaRDD) Mockito.mock(JavaRDD.class); + } + + @Test + public void testUnificationCommitSuccess() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + JavaRDD rdd = rdd(); + Mockito.when(writer.commit(rdd)).thenReturn(true); + + boolean result = ErrorTableCommitter.commit(writer, Option.of(rdd), true, INSTANT, Option.empty()); + + assertTrue(result); + Mockito.verify(writer).commit(rdd); + Mockito.verify(writer, Mockito.never()).upsertAndCommit(Mockito.any(), Mockito.any()); + } + + @Test + public void testUnificationCommitFailurePropagates() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + JavaRDD rdd = rdd(); + Mockito.when(writer.commit(rdd)).thenReturn(false); + + boolean result = ErrorTableCommitter.commit(writer, Option.of(rdd), true, INSTANT, Option.empty()); + + assertFalse(result); + } + + @Test + public void testUnificationWithoutRddIsNoOpAndReturnsTrue() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + + boolean result = ErrorTableCommitter.commit(writer, Option.empty(), true, INSTANT, Option.empty()); + + assertTrue(result); + Mockito.verifyNoInteractions(writer); + } + + @Test + public void testLegacyPathUsesUpsertAndCommit() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + Option latest = Option.of("20260520115959000"); + Mockito.when(writer.upsertAndCommit(INSTANT, latest)).thenReturn(true); + + boolean result = ErrorTableCommitter.commit(writer, Option.empty(), false, INSTANT, latest); + + assertTrue(result); + Mockito.verify(writer).upsertAndCommit(INSTANT, latest); + Mockito.verify(writer, Mockito.never()).commit(Mockito.any()); + } + + @Test + public void testLegacyPathFailurePropagates() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + Mockito.when(writer.upsertAndCommit(Mockito.anyString(), Mockito.any())).thenReturn(false); + + boolean result = ErrorTableCommitter.commit(writer, Option.empty(), false, INSTANT, Option.empty()); + + assertFalse(result); + } + + @Test + public void testLegacyPathIgnoresRddEvenWhenProvided() { + // When unification is OFF, the RDD must not be touched even if accidentally passed in. + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + JavaRDD rdd = rdd(); + Mockito.when(writer.upsertAndCommit(Mockito.anyString(), Mockito.any())).thenReturn(true); + + ErrorTableCommitter.commit(writer, Option.of(rdd), false, INSTANT, Option.empty()); + + Mockito.verify(writer, Mockito.never()).commit(Mockito.any()); + Mockito.verify(writer).upsertAndCommit(INSTANT, Option.empty()); + } + + @Test + public void testNullWriterRejected() { + assertThrows(NullPointerException.class, () -> + ErrorTableCommitter.commit(null, Option.empty(), false, INSTANT, Option.empty())); + } + + @Test + public void testNullRddOptionRejected() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + assertThrows(NullPointerException.class, () -> + ErrorTableCommitter.commit(writer, null, false, INSTANT, Option.empty())); + } + + @Test + public void testNullInstantRejected() { + BaseErrorTableWriter writer = Mockito.mock(BaseErrorTableWriter.class); + assertThrows(NullPointerException.class, () -> + ErrorTableCommitter.commit(writer, Option.empty(), false, null, Option.empty())); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2E.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2E.java index 409b2d9fe9c8b..64c432474953a 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2E.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2E.java @@ -23,7 +23,6 @@ import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableVersion; -import org.apache.hudi.common.table.checkpoint.CheckpointUtils; import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; import org.apache.hudi.common.util.Option; import org.apache.hudi.testutils.HoodieClientTestUtils; @@ -109,7 +108,11 @@ public void verifyLastInstantCommitMetadata(Map expectedMetadata Option metadata = HoodieClientTestUtils.getCommitMetadataForInstant( metaClient, metaClient.getActiveTimeline().lastInstant().get()); assertFalse(metadata.isEmpty()); - assertEquals(metadata.get().getExtraMetadata(), expectedMetadata); + // Assert expected entries are a subset of the actual extra metadata. CommitMetadataProperties + // also enriches commit metadata with hudi.version, engine, and config.* entries on every write, + // so the actual map is a superset. + Map actual = metadata.get().getExtraMetadata(); + expectedMetadata.forEach((k, v) -> assertEquals(v, actual.get(k), "extraMetadata[" + k + "]")); } @@ -512,9 +515,9 @@ public void testSyncE2EForceSkip(String tableVersion) throws Exception { @Test public void testTargetCheckpointV2ForS3Gcs() { // To ensure we properly track sources that must use checkpoint V1. - assertFalse(CheckpointUtils.shouldTargetCheckpointV2(8, S3EventsHoodieIncrSource.class.getName())); - assertFalse(CheckpointUtils.shouldTargetCheckpointV2(6, S3EventsHoodieIncrSource.class.getName())); - assertFalse(CheckpointUtils.shouldTargetCheckpointV2(8, GcsEventsHoodieIncrSource.class.getName())); - assertFalse(CheckpointUtils.shouldTargetCheckpointV2(6, GcsEventsHoodieIncrSource.class.getName())); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(8, S3EventsHoodieIncrSource.class.getName())); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(6, S3EventsHoodieIncrSource.class.getName())); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(8, GcsEventsHoodieIncrSource.class.getName())); + assertFalse(StreamerCheckpointUtils.shouldTargetCheckpointV2(6, GcsEventsHoodieIncrSource.class.getName())); } } \ No newline at end of file diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2EAutoUpgrade.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2EAutoUpgrade.java index 661216053b239..52a6734ec3317 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2EAutoUpgrade.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieIncrSourceE2EAutoUpgrade.java @@ -37,6 +37,7 @@ import org.apache.hudi.testutils.HoodieClientTestUtils; import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer; import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase; +import org.apache.hudi.utilities.ingestion.HoodieIngestionException; import org.apache.hudi.utilities.sources.MockGeneralHoodieIncrSource; import org.apache.hudi.utilities.sources.S3EventsHoodieIncrSourceHarness; @@ -136,7 +137,11 @@ public void verifyLastInstantCommitMetadata(Map expectedMetadata Option metadata = HoodieClientTestUtils.getCommitMetadataForInstant( metaClient, metaClient.getActiveTimeline().lastInstant().get()); assertFalse(metadata.isEmpty()); - assertEquals(expectedMetadata, metadata.get().getExtraMetadata()); + // Assert expected entries are a subset of the actual extra metadata. CommitMetadataProperties + // also enriches commit metadata with hudi.version, engine, and config.* entries on every write, + // so the actual map is a superset. + Map actual = metadata.get().getExtraMetadata(); + expectedMetadata.forEach((k, v) -> assertEquals(v, actual.get(k), "extraMetadata[" + k + "]")); } /** @@ -191,8 +196,10 @@ public void testSyncE2ENoPrevCkpThenSyncMultipleTimes() throws Exception { HoodieDeltaStreamer.Config cfg = createConfig(basePath(), null, sourceClass); cfg.checkpoint = "overrideWhenAutoUpgradingWouldFail"; ds = new HoodieDeltaStreamer(cfg, jsc, Option.of(props)); - Exception ex = assertThrows(HoodieUpgradeDowngradeException.class, ds::sync); - assertTrue(ex.getMessage().contains("When upgrade/downgrade is happening, please avoid setting --checkpoint option and --ignore-checkpoint for your delta streamers.")); + Exception ex = assertThrows(HoodieIngestionException.class, ds::sync); + Throwable cause = ex.getCause(); + assertTrue(cause instanceof HoodieUpgradeDowngradeException, "Expected cause to be HoodieUpgradeDowngradeException but was: " + cause.getClass()); + assertTrue(cause.getMessage().contains("When upgrade/downgrade is happening, please avoid setting --checkpoint option and --ignore-checkpoint for your delta streamers.")); // No changes to the timeline / table config. metaClient.reloadActiveTimeline(); assertEquals(metaClient.getActiveTimeline().lastInstant().get(), instantAfterFirstRound); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieStreamerMetrics.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieStreamerMetrics.java index 8a45c79e73486..4fb7db1cd634a 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieStreamerMetrics.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestHoodieStreamerMetrics.java @@ -68,4 +68,51 @@ public void testHoodieStreamerMetricsForErrorTableIfDisabled() { metrics.updateErrorTableCommitDuration(0L); assertNull(metrics.getMetrics()); } + + @Test + public void testEmitStreamerJobSuccessMetrics() { + HoodieMetricsConfig metricsConfig = HoodieMetricsConfig.newBuilder() + .on(true) + .withPath("/tmp/path3") + .withReporterType("INMEMORY") + .build(); + HoodieStreamerMetrics metrics = new HoodieStreamerMetrics( + metricsConfig, HoodieStorageUtils.getStorage(getDefaultStorageConf())); + metrics.emitStreamerJobSuccessMetrics(); + MetricRegistry registry = metrics.getMetrics().getRegistry(); + assertEquals(1, registry.getGauges().size()); + assertEquals(".deltastreamer.success", registry.getGauges().firstKey()); + assertEquals(1L, registry.getGauges().get(".deltastreamer.success").getValue()); + } + + @Test + public void testEmitStreamerJobFailedMetrics() { + HoodieMetricsConfig metricsConfig = HoodieMetricsConfig.newBuilder() + .on(true) + .withPath("/tmp/path4") + .withReporterType("INMEMORY") + .build(); + HoodieStreamerMetrics metrics = new HoodieStreamerMetrics( + metricsConfig, HoodieStorageUtils.getStorage(getDefaultStorageConf())); + metrics.emitStreamerJobFailedMetrics(); + MetricRegistry registry = metrics.getMetrics().getRegistry(); + assertEquals(1, registry.getGauges().size()); + assertEquals(".deltastreamer.failure", registry.getGauges().firstKey()); + assertEquals(1L, registry.getGauges().get(".deltastreamer.failure").getValue()); + } + + @Test + public void testEmitStreamerJobMetricsIfDisabled() { + HoodieMetricsConfig metricsConfig = HoodieMetricsConfig.newBuilder() + .on(false) + .withPath("/tmp/path5") + .withReporterType("INMEMORY") + .build(); + HoodieStreamerMetrics metrics = new HoodieStreamerMetrics( + metricsConfig, HoodieStorageUtils.getStorage(getDefaultStorageConf())); + // Should not throw when metrics are disabled + metrics.emitStreamerJobSuccessMetrics(); + metrics.emitStreamerJobFailedMetrics(); + assertNull(metrics.getMetrics()); + } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestParquetDfsCheckpointFormatOnV6.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestParquetDfsCheckpointFormatOnV6.java new file mode 100644 index 0000000000000..596be797e36bf --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestParquetDfsCheckpointFormatOnV6.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.hudi.utilities.streamer; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer; +import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase; +import org.apache.hudi.utilities.sources.ParquetDFSSource; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Continues ingestion against a fixture table that was originally written with V1 checkpoint + * format only and is now being resumed on a Hudi 1.x release with + * {@code hoodie.write.table.version=6}. + * + *

    Expected: the next commit's {@code extraMetadata} carries the V1 key + * {@code deltastreamer.checkpoint.key} and NOT the V2 key {@code streamer.checkpoint.key.v2}, + * because non-incremental sources emit V1 regardless of write table version. + * + *

    If a V2 key shows up on the resumed commit, that reproduces the bug reported against + * {@code hoodie.write.table.version=6}. + */ +public class TestParquetDfsCheckpointFormatOnV6 extends HoodieDeltaStreamerTestBase { + + private static final String FIXTURE_RESOURCE = "checkpoint-v6/parquet-dfs-v1-fixture.zip"; + + @Test + public void resumedV6CommitKeepsV1CheckpointFormat() throws Exception { + String dirName = "checkpoint-v6-resume-" + System.currentTimeMillis(); + String dataPath = basePath + "/" + dirName; + Path zipOutput = Paths.get(new URI(dataPath)); + Files.createDirectories(zipOutput); + HoodieTestUtils.extractZipToDirectory(FIXTURE_RESOURCE, zipOutput, getClass()); + + // The fixture table is rooted directly at zipOutput (zip contains .hoodie/, partitions, etc.). + String tableBasePath = zipOutput.toString(); + assertTrue(Files.exists(zipOutput.resolve(".hoodie/hoodie.properties")), + "Fixture did not unpack a hudi table at " + tableBasePath); + + // Sanity-check that the fixture's last commit carries a V1 checkpoint and no V2 key. + HoodieCommitMetadata baselineCommit = readLatestCommitMetadata(tableBasePath); + assertNotNull(baselineCommit.getMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1), + "Fixture baseline expected to carry V1 checkpoint key."); + assertNull(baselineCommit.getMetadata(StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2), + "Fixture baseline must not have a V2 checkpoint key (was built on master)."); + + // Produce a fresh parquet source file under a new root so the streamer has work to do. + String parquetSourceRoot = basePath + "/" + dirName + "-source"; + prepareParquetDFSFiles(50, parquetSourceRoot, "resume.parquet", false, null, null).close(); + + String propsFile = dirName + "-source.properties"; + TypedProperties extraProps = new TypedProperties(); + extraProps.setProperty("hoodie.datasource.write.table.type", HoodieTableType.COPY_ON_WRITE.name()); + prepareParquetDFSSource(false, false, "source.avsc", "target.avsc", + propsFile, parquetSourceRoot, false, "partition_path", "", extraProps, false, false); + + HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, + ParquetDFSSource.class.getName(), Collections.emptyList(), propsFile, false, false, + 100_000, false, null, HoodieTableType.COPY_ON_WRITE.name(), "timestamp", null); + cfg.configs.add(HoodieWriteConfig.WRITE_TABLE_VERSION.key() + "=6"); + new HoodieDeltaStreamer(cfg, jsc).sync(); + + // Confirm the on-disk table version stayed at 6 so we are testing the v6-write path, not an + // accidental v6 -> v9 auto-upgrade (which would make V2 the correct result). + HoodieTableMetaClient resumedMetaClient = HoodieTestUtils.createMetaClient(storage, tableBasePath); + assertEquals(6, resumedMetaClient.getTableConfig().getTableVersion().versionCode(), + "Table version on disk should still be 6 after resume"); + + HoodieCommitMetadata resumedCommit = readLatestCommitMetadata(tableBasePath); + // Under hoodie.write.table.version=6, ParquetDFSSource must keep emitting V1 keys; in the + // always-V1 design, this holds across every write table version for non-incremental sources. + assertNotNull(resumedCommit.getMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1), + "Resumed v6 commit must persist V1 checkpoint key. extraMetadata=" + + resumedCommit.getExtraMetadata()); + assertNull(resumedCommit.getMetadata(StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2), + "Resumed v6 commit must NOT persist a V2 checkpoint key. extraMetadata=" + + resumedCommit.getExtraMetadata()); + } + + private static HoodieCommitMetadata readLatestCommitMetadata(String tableBasePath) throws IOException { + HoodieTableMetaClient metaClient = HoodieTestUtils.createMetaClient(storage, tableBasePath); + HoodieInstant lastInstant = metaClient.getActiveTimeline() + .getCommitsTimeline() + .filterCompletedInstants() + .lastInstant() + .orElseThrow(() -> new IllegalStateException("No completed commit found in " + tableBasePath)); + return metaClient.getActiveTimeline().readCommitMetadata(lastInstant); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSync.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSync.java index 26ba7599b451f..42499d8c76d48 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSync.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSync.java @@ -28,6 +28,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.HoodieTableVersion; import org.apache.hudi.common.table.checkpoint.Checkpoint; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.common.util.collection.Triple; @@ -36,6 +37,7 @@ import org.apache.hudi.storage.HoodieStorage; import org.apache.hudi.storage.hadoop.HoodieHadoopStorage; import org.apache.hudi.testutils.SparkClientFunctionalTestHarness; +import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics; import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.sources.InputBatch; import org.apache.hudi.utilities.transform.Transformer; @@ -55,6 +57,7 @@ import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; +import java.lang.reflect.Field; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -75,6 +78,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -103,10 +107,10 @@ void testFetchNextBatchFromSource(Boolean useRowWriter, Boolean hasTransformer, SourceFormatAdapter sourceFormatAdapter = mock(SourceFormatAdapter.class); SchemaProvider inputBatchSchemaProvider = getSchemaProvider("InputBatch", false); Option> fakeDataFrame = Option.of(mock(Dataset.class)); - InputBatch> fakeRowInputBatch = new InputBatch<>(fakeDataFrame, "chkpt", inputBatchSchemaProvider); + InputBatch> fakeRowInputBatch = new InputBatch<>(fakeDataFrame, new StreamerCheckpointV2("chkpt"), inputBatchSchemaProvider); when(sourceFormatAdapter.fetchNewDataInRowFormat(any(), anyLong())).thenReturn(fakeRowInputBatch); //batch is empty because we don't want getBatch().map() to do anything because it calls static method we can't mock - InputBatch> fakeAvroInputBatch = new InputBatch<>(Option.empty(), "chkpt", inputBatchSchemaProvider); + InputBatch> fakeAvroInputBatch = new InputBatch<>(Option.empty(), new StreamerCheckpointV2("chkpt"), inputBatchSchemaProvider); when(sourceFormatAdapter.fetchNewDataInAvroFormat(any(),anyLong())).thenReturn(fakeAvroInputBatch); //transformer @@ -356,11 +360,33 @@ public void testExtractCheckpointMetadata_WhenCheckpointExists() { } @Test - public void testExtractCheckpointMetadata_WhenCheckpointIsNullV2() { + void testExtractCheckpointMetadata_WhenCheckpointIsNullNonIncrementalSourceOnV8UsesV1() { StreamSync streamSync = setupStreamSync(); HoodieStreamer.Config cfg = new HoodieStreamer.Config(); cfg.checkpoint = "test-checkpoint"; cfg.ignoreCheckpoint = "test-ignore"; + cfg.sourceClassName = "org.apache.hudi.utilities.sources.KafkaSource"; + TypedProperties props = new TypedProperties(); + + InputBatch inputBatch = mock(InputBatch.class); + when(inputBatch.getCheckpointForNextBatch()).thenReturn(null); + + Map result = streamSync.extractCheckpointMetadata( + inputBatch, props, HoodieTableVersion.EIGHT.versionCode(), cfg); + + Map expected = new HashMap<>(); + expected.put(CHECKPOINT_IGNORE_KEY, "test-ignore"); + expected.put(CHECKPOINT_RESET_KEY, "test-checkpoint"); + assertEquals(expected, result, "Should fall back to V1 keys for non-incremental sources on v8"); + } + + @Test + void testExtractCheckpointMetadata_WhenCheckpointIsNullIncrementalSourceOnV8UsesV2() { + StreamSync streamSync = setupStreamSync(); + HoodieStreamer.Config cfg = new HoodieStreamer.Config(); + cfg.checkpoint = "test-checkpoint"; + cfg.ignoreCheckpoint = "test-ignore"; + cfg.sourceClassName = "org.apache.hudi.utilities.sources.HoodieIncrSource"; TypedProperties props = new TypedProperties(); InputBatch inputBatch = mock(InputBatch.class); @@ -372,7 +398,7 @@ public void testExtractCheckpointMetadata_WhenCheckpointIsNullV2() { Map expected = new HashMap<>(); expected.put(CHECKPOINT_IGNORE_KEY, "test-ignore"); expected.put(STREAMER_CHECKPOINT_RESET_KEY_V2, "test-checkpoint"); - assertEquals(expected, result, "Should return default metadata when checkpoint is null"); + assertEquals(expected, result, "Should fall back to V2 keys for incremental sources on v8"); } @Test @@ -450,5 +476,35 @@ void testParseOverridingMergeConfigsWithMixedConfigs() { assertEquals("org.apache.hudi.common.model.OverwriteWithLatestPayload", triple.getMiddle()); assertEquals("any_id", triple.getRight()); } + + @Test + void testReportSuccessMetricsDelegatesToMetrics() throws Exception { + StreamSync streamSync = mock(StreamSync.class); + HoodieIngestionMetrics mockMetrics = mock(HoodieIngestionMetrics.class); + Field metricsField = StreamSync.class.getDeclaredField("metrics"); + metricsField.setAccessible(true); + metricsField.set(streamSync, mockMetrics); + doCallRealMethod().when(streamSync).reportSuccessMetrics(); + + streamSync.reportSuccessMetrics(); + + verify(mockMetrics, times(1)).emitStreamerJobSuccessMetrics(); + verify(mockMetrics, never()).emitStreamerJobFailedMetrics(); + } + + @Test + void testReportFailureMetricsDelegatesToMetrics() throws Exception { + StreamSync streamSync = mock(StreamSync.class); + HoodieIngestionMetrics mockMetrics = mock(HoodieIngestionMetrics.class); + Field metricsField = StreamSync.class.getDeclaredField("metrics"); + metricsField.setAccessible(true); + metricsField.set(streamSync, mockMetrics); + doCallRealMethod().when(streamSync).reportFailureMetrics(); + + streamSync.reportFailureMetrics(); + + verify(mockMetrics, times(1)).emitStreamerJobFailedMetrics(); + verify(mockMetrics, never()).emitStreamerJobSuccessMetrics(); + } } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSyncWriteStatusValidation.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSyncWriteStatusValidation.java new file mode 100644 index 0000000000000..793cfd5de8baa --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamSyncWriteStatusValidation.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.testutils.SparkClientFunctionalTestHarness; + +import org.apache.spark.api.java.JavaRDD; +import org.junit.jupiter.api.Test; +import scala.Tuple2; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link StreamSync#sumRecordAndErrorCounts(JavaRDD)}, the single-pass fold that replaces two + * separate {@code mapToDouble(...).sum()} actions in error-table commit validation. + */ +class TestStreamSyncWriteStatusValidation extends SparkClientFunctionalTestHarness { + + @Test + void sumsTotalAndErroredRecordsAcrossPartitions() { + List writeStatuses = new ArrayList<>(); + writeStatuses.add(writeStatus(10L, 3L)); + writeStatuses.add(writeStatus(20L, 5L)); + writeStatuses.add(writeStatus(7L, 0L)); + // More partitions than elements forces an empty partition, exercising the aggregate fold. + JavaRDD writeStatusRDD = jsc().parallelize(writeStatuses, 4); + + Tuple2 counts = StreamSync.sumRecordAndErrorCounts(writeStatusRDD); + + assertEquals(37L, counts._1, "total records summed across all partitions"); + assertEquals(8L, counts._2, "total errored records summed across all partitions"); + } + + @Test + void sumsToZeroWhenNoWriteStatusesPresent() { + JavaRDD emptyRDD = jsc().parallelize(new ArrayList(), 2); + + Tuple2 counts = StreamSync.sumRecordAndErrorCounts(emptyRDD); + + assertEquals(0L, counts._1); + assertEquals(0L, counts._2); + } + + @Test + void sumsToZeroForZeroPartitionRDD() { + // BaseErrorTableWriter.upsert can return sc.emptyRDD() on an empty commit; JavaRDD.reduce throws + // UnsupportedOperationException on a 0-partition RDD, whereas aggregate returns the zero value. + JavaRDD emptyRDD = jsc().emptyRDD(); + + Tuple2 counts = StreamSync.sumRecordAndErrorCounts(emptyRDD); + + assertEquals(0L, counts._1); + assertEquals(0L, counts._2); + } + + private static WriteStatus writeStatus(long totalRecords, long totalErrorRecords) { + WriteStatus writeStatus = new WriteStatus(); + writeStatus.setTotalRecords(totalRecords); + writeStatus.setTotalErrorRecords(totalErrorRecords); + return writeStatus; + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamerCheckpointUtils.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamerCheckpointUtils.java index f4e30667b295c..0d9c3f7d15f45 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamerCheckpointUtils.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestStreamerCheckpointUtils.java @@ -25,6 +25,7 @@ import org.apache.hudi.common.table.checkpoint.Checkpoint; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.table.checkpoint.UnresolvedStreamerCheckpointBasedOnCfg; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; @@ -41,6 +42,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; @@ -51,11 +54,13 @@ import static org.apache.hudi.utilities.streamer.HoodieStreamer.CHECKPOINT_KEY; import static org.apache.hudi.utilities.streamer.StreamSync.CHECKPOINT_IGNORE_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(MockitoExtension.class) public class TestStreamerCheckpointUtils extends SparkClientFunctionalTestHarness { + private static final String CHECKPOINT_TO_RESUME = "20240101000000"; private TypedProperties props; private HoodieStreamer.Config streamerConfig; protected HoodieTableMetaClient metaClient; @@ -68,6 +73,53 @@ public void setUp() throws IOException { streamerConfig.tableType = HoodieTableType.COPY_ON_WRITE.name(); } + @ParameterizedTest + @CsvSource({ + // version, sourceClassName, expectedV2 + // Only HoodieIncrSource family on v8+ targets V2. + "8, org.apache.hudi.utilities.sources.HoodieIncrSource, true", + "9, org.apache.hudi.utilities.sources.HoodieIncrSource, true", + "8, org.apache.hudi.utilities.sources.MockGeneralHoodieIncrSource, true", + // v6/v7 always V1. + "7, org.apache.hudi.utilities.sources.HoodieIncrSource, false", + "6, org.apache.hudi.utilities.sources.HoodieIncrSource, false", + // Non-incremental sources always V1 regardless of version. + "8, org.apache.hudi.utilities.sources.KafkaSource, false", + "9, org.apache.hudi.utilities.sources.JdbcSource, false", + // V2-not-supported allowlist always V1. + "8, org.apache.hudi.utilities.sources.S3EventsHoodieIncrSource, false", + "8, org.apache.hudi.utilities.sources.GcsEventsHoodieIncrSource, false", + "8, org.apache.hudi.utilities.sources.MockS3EventsHoodieIncrSource, false", + "8, org.apache.hudi.utilities.sources.MockGcsEventsHoodieIncrSource, false" + }) + void testShouldTargetCheckpointV2(int version, String sourceClassName, boolean expectedV2) { + assertEquals(expectedV2, StreamerCheckpointUtils.shouldTargetCheckpointV2(version, sourceClassName)); + } + + @Test + void testBuildCheckpointFromConfigOverride() { + Checkpoint hoodieIncrV8 = StreamerCheckpointUtils.buildCheckpointFromConfigOverride( + "org.apache.hudi.utilities.sources.HoodieIncrSource", + HoodieTableVersion.EIGHT.versionCode(), + CHECKPOINT_TO_RESUME); + assertInstanceOf(UnresolvedStreamerCheckpointBasedOnCfg.class, hoodieIncrV8); + assertEquals(CHECKPOINT_TO_RESUME, hoodieIncrV8.getCheckpointKey()); + + Checkpoint nonIncrV8 = StreamerCheckpointUtils.buildCheckpointFromConfigOverride( + "org.apache.hudi.utilities.sources.KafkaSource", + HoodieTableVersion.EIGHT.versionCode(), + CHECKPOINT_TO_RESUME); + assertInstanceOf(StreamerCheckpointV1.class, nonIncrV8); + assertEquals(CHECKPOINT_TO_RESUME, nonIncrV8.getCheckpointKey()); + + Checkpoint hoodieIncrV6 = StreamerCheckpointUtils.buildCheckpointFromConfigOverride( + "org.apache.hudi.utilities.sources.HoodieIncrSource", + HoodieTableVersion.SIX.versionCode(), + CHECKPOINT_TO_RESUME); + assertInstanceOf(StreamerCheckpointV1.class, hoodieIncrV6); + assertEquals(CHECKPOINT_TO_RESUME, hoodieIncrV6.getCheckpointKey()); + } + @Test public void testEmptyTimelineCase() throws IOException { Option checkpoint = StreamerCheckpointUtils.resolveCheckpointBetweenConfigAndPrevCommit( diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestSuccessfulRecordCounter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestSuccessfulRecordCounter.java new file mode 100644 index 0000000000000..1484a1d3e58e0 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestSuccessfulRecordCounter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.util.Option; + +import org.apache.spark.SparkConf; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link SuccessfulRecordCounter}. Covers the driver-side (collected list) + * counting and error-table unification paths, plus null safety on public entry points. + */ +public class TestSuccessfulRecordCounter { + + private static JavaSparkContext jsc; + + @BeforeAll + public static void setUp() { + SparkConf conf = new SparkConf() + .setAppName("TestSuccessfulRecordCounter") + .setMaster("local[2]"); + jsc = new JavaSparkContext(conf); + } + + @AfterAll + public static void tearDown() { + if (jsc != null) { + jsc.close(); + } + } + + @Test + public void testEmptyInputReturnsZero() { + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Collections.emptyList(), Option.empty(), false); + + assertEquals(0L, counts.getTotalRecords()); + assertEquals(0L, counts.getTotalErrorRecords()); + assertEquals(0L, counts.getTotalSuccessfulRecords()); + assertFalse(counts.hasErrors()); + } + + @Test + public void testSingleWriteStatusNoErrors() { + WriteStatus ws = Mockito.mock(WriteStatus.class); + Mockito.when(ws.getTotalRecords()).thenReturn(1000L); + Mockito.when(ws.getTotalErrorRecords()).thenReturn(0L); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Collections.singletonList(ws), Option.empty(), false); + + assertEquals(1000L, counts.getTotalRecords()); + assertEquals(0L, counts.getTotalErrorRecords()); + assertEquals(1000L, counts.getTotalSuccessfulRecords()); + assertFalse(counts.hasErrors()); + } + + @Test + public void testMultipleWriteStatusesAreSummed() { + WriteStatus a = Mockito.mock(WriteStatus.class); + Mockito.when(a.getTotalRecords()).thenReturn(100L); + Mockito.when(a.getTotalErrorRecords()).thenReturn(5L); + + WriteStatus b = Mockito.mock(WriteStatus.class); + Mockito.when(b.getTotalRecords()).thenReturn(200L); + Mockito.when(b.getTotalErrorRecords()).thenReturn(10L); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Arrays.asList(a, b), Option.empty(), false); + + assertEquals(300L, counts.getTotalRecords()); + assertEquals(15L, counts.getTotalErrorRecords()); + assertEquals(285L, counts.getTotalSuccessfulRecords()); + assertTrue(counts.hasErrors()); + } + + @Test + public void testUnificationDisabledIgnoresErrorTableRdd() { + WriteStatus ws = Mockito.mock(WriteStatus.class); + Mockito.when(ws.getTotalRecords()).thenReturn(50L); + Mockito.when(ws.getTotalErrorRecords()).thenReturn(2L); + + // Even if an error-table RDD is provided, unification=false means it must be ignored. + // Pass an "always throws" mock to prove the helper never touches it. + @SuppressWarnings("unchecked") + JavaRDD rdd = (JavaRDD) Mockito.mock(JavaRDD.class); + Mockito.when(rdd.mapToDouble(Mockito.any())).thenThrow(new AssertionError("RDD must not be consulted when unification is disabled")); + Mockito.when(rdd.map(Mockito.any())).thenThrow(new AssertionError("RDD must not be consulted when unification is disabled")); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Collections.singletonList(ws), Option.of(rdd), false); + + assertEquals(50L, counts.getTotalRecords()); + assertEquals(2L, counts.getTotalErrorRecords()); + assertEquals(48L, counts.getTotalSuccessfulRecords()); + } + + @Test + public void testHasErrorsBoundary() { + WriteStatus ws = Mockito.mock(WriteStatus.class); + Mockito.when(ws.getTotalRecords()).thenReturn(10L); + Mockito.when(ws.getTotalErrorRecords()).thenReturn(1L); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Collections.singletonList(ws), Option.empty(), false); + + assertTrue(counts.hasErrors()); + } + + // ========== Unification path (real Spark) ========== + + @Test + public void testUnificationEnabledSumsErrorTable() { + WriteStatus dataA = stat(100L, 5L); + WriteStatus dataB = stat(200L, 10L); + WriteStatus errA = stat(50L, 50L); + WriteStatus errB = stat(25L, 25L); + JavaRDD errorRdd = jsc.parallelize(Arrays.asList(errA, errB)); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Arrays.asList(dataA, dataB), Option.of(errorRdd), true); + + assertEquals(375L, counts.getTotalRecords()); // 100 + 200 + 50 + 25 + assertEquals(90L, counts.getTotalErrorRecords()); // 5 + 10 + 50 + 25 + assertEquals(285L, counts.getTotalSuccessfulRecords()); + assertTrue(counts.hasErrors()); + } + + // ========== RDD-based path (real Spark) ========== + + @Test + public void testUnificationWithRealSparkErrorRdd() { + WriteStatus dataA = stat(100L, 5L); + JavaRDD errorRdd = jsc.parallelize(Collections.singletonList(stat(50L, 50L))); + + SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute( + Collections.singletonList(dataA), Option.of(errorRdd), true); + + assertEquals(150L, counts.getTotalRecords()); + assertEquals(55L, counts.getTotalErrorRecords()); + assertEquals(95L, counts.getTotalSuccessfulRecords()); + } + + // ========== Null safety ========== + + @Test + public void testNullDataTableListRejected() { + assertThrows(NullPointerException.class, () -> + SuccessfulRecordCounter.compute(null, Option.empty(), false)); + } + + @Test + public void testNullErrorTableOptionRejected() { + assertThrows(NullPointerException.class, () -> + SuccessfulRecordCounter.compute(Collections.emptyList(), null, false)); + } + + // ========== Helper ========== + + private static WriteStatus stat(long totalRecords, long totalErrorRecords) { + // Use a real WriteStatus so it serializes for Spark closures (Mockito mocks are not Serializable). + // @Data on WriteStatus exposes setters for totalRecords/totalErrorRecords. + WriteStatus ws = new WriteStatus(false, 0.0); + ws.setTotalRecords(totalRecords); + ws.setTotalErrorRecords(totalErrorRecords); + return ws; + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestWriteErrorReporter.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestWriteErrorReporter.java new file mode 100644 index 0000000000000..d13378edb74e2 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/TestWriteErrorReporter.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.hudi.utilities.streamer; + +import org.apache.hudi.client.WriteStatus; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Tests for {@link WriteErrorReporter}. Verifies the no-op contract for null/empty inputs and + * that the List overload short-circuits without touching a Spark RDD. Logging output itself is + * intentionally not asserted — the value of the logger is human triage, not test assertions. + */ +public class TestWriteErrorReporter { + + @Test + public void testNullListIsNoOp() { + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors((List) null)); + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors((List) null, 10)); + } + + @Test + public void testEmptyListIsNoOp() { + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors(Collections.emptyList())); + } + + @Test + public void testZeroMaxErrorsIsNoOp() { + WriteStatus ws = errored("global err"); + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors(Collections.singletonList(ws), 0)); + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors(Collections.singletonList(ws), -5)); + } + + @Test + public void testListWithErrorsLogsWithoutThrowing() { + List statuses = Arrays.asList( + errored("err1"), + clean(), + errored("err2")); + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors(statuses, 10)); + } + + @Test + public void testMaxCapLimitsIteration() { + // Build a list with 5 errored statuses; cap at 2. Should iterate only the first 2 + // (no throw, no interaction beyond the first 2 verified by reaching the end of the call). + WriteStatus a = errored("a"); + WriteStatus b = errored("b"); + WriteStatus c = errored("c"); + WriteStatus d = errored("d"); + WriteStatus e = errored("e"); + assertDoesNotThrow(() -> WriteErrorReporter.logTopErrors(Arrays.asList(a, b, c, d, e), 2)); + // The other statuses must not have been touched. Their getErrors() should not have been called. + Mockito.verify(c, Mockito.never()).getErrors(); + Mockito.verify(d, Mockito.never()).getErrors(); + Mockito.verify(e, Mockito.never()).getErrors(); + } + + // ========== Helpers ========== + + private static WriteStatus errored(String globalError) { + WriteStatus ws = Mockito.mock(WriteStatus.class); + Mockito.when(ws.hasErrors()).thenReturn(true); + Mockito.when(ws.getGlobalError()).thenReturn(new RuntimeException(globalError)); + Mockito.when(ws.getErrors()).thenReturn(new HashMap<>()); + return ws; + } + + private static WriteStatus clean() { + WriteStatus ws = Mockito.mock(WriteStatus.class); + Mockito.when(ws.hasErrors()).thenReturn(false); + return ws; + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkKafkaOffsetValidator.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkKafkaOffsetValidator.java new file mode 100644 index 0000000000000..d109aa3246f64 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkKafkaOffsetValidator.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; +import org.apache.hudi.exception.HoodieValidationException; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link SparkKafkaOffsetValidator}. + */ +public class TestSparkKafkaOffsetValidator { + + // ========== Helper methods ========== + + private static TypedProperties defaultConfig() { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.STREAMING_OFFSET_TOLERANCE_PERCENTAGE.key(), "0.0"); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "FAIL"); + return props; + } + + private static TypedProperties configWithTolerance(double tolerance) { + TypedProperties props = defaultConfig(); + props.setProperty(HoodiePreCommitValidatorConfig.STREAMING_OFFSET_TOLERANCE_PERCENTAGE.key(), + String.valueOf(tolerance)); + return props; + } + + private static TypedProperties configWithWarnPolicy() { + TypedProperties props = defaultConfig(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "WARN_LOG"); + return props; + } + + /** + * Build a Spark Kafka checkpoint string. + * Format: topic,partition:offset,partition:offset,... + */ + private static String buildSparkKafkaCheckpoint(String topic, int[] partitions, long[] offsets) { + StringBuilder sb = new StringBuilder(); + sb.append(topic); + for (int i = 0; i < partitions.length; i++) { + sb.append(",").append(partitions[i]).append(":").append(offsets[i]); + } + return sb.toString(); + } + + private static HoodieCommitMetadata buildMetadata(String checkpointValue) { + HoodieCommitMetadata metadata = new HoodieCommitMetadata(); + if (checkpointValue != null) { + metadata.addMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, checkpointValue); + } + return metadata; + } + + private static List buildWriteStats(long numInserts, long numUpdates) { + HoodieWriteStat stat = new HoodieWriteStat(); + stat.setNumInserts(numInserts); + stat.setNumUpdateWrites(numUpdates); + stat.setPartitionPath("partition1"); + return Collections.singletonList(stat); + } + + private static SparkValidationContext buildContext( + String instantTime, + HoodieCommitMetadata currentMetadata, + List writeStats, + HoodieCommitMetadata previousMetadata) { + return new SparkValidationContext( + instantTime, + Option.of(currentMetadata), + Option.of(writeStats), + previousMetadata != null ? Option.of(previousMetadata) : Option.empty()); + } + + // ========== Tests ========== + + @Test + public void testExactMatchPasses() { + // Previous: partition 0 at offset 100, partition 1 at offset 200 + // Current: partition 0 at offset 200, partition 1 at offset 300 + // Diff = (200-100) + (300-200) = 200. Records written = 200. + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0, 1}, new long[]{100, 200}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0, 1}, new long[]{200, 300}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(200, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testDataLossDetected() { + // Diff = 1000 but only 500 records written -> 50% deviation + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(500, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertThrows(HoodieValidationException.class, () -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testWithinTolerancePasses() { + // Diff = 1000, records = 950 -> 5% deviation, tolerance = 10% + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(950, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(configWithTolerance(10.0)); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testWarnPolicyDoesNotThrow() { + // Data loss but WARN_LOG policy + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(0, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(configWithWarnPolicy()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testSkipsFirstCommit() { + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + // No previous commit + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(buildMetadata(currCheckpoint)), + Option.of(buildWriteStats(500, 0)), + Option.empty()); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testSkipsWhenNoCheckpointKey() { + // Current metadata has no checkpoint key + HoodieCommitMetadata currentMeta = new HoodieCommitMetadata(); + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{100}); + + SparkValidationContext ctx = buildContext("20260320120000000", + currentMeta, + buildWriteStats(500, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testMultiPartitionValidation() { + // 4 partitions, each advancing by 250 = total diff 1000 + String prevCheckpoint = buildSparkKafkaCheckpoint("events", + new int[]{0, 1, 2, 3}, new long[]{0, 0, 0, 0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", + new int[]{0, 1, 2, 3}, new long[]{250, 250, 250, 250}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(800, 200), // 800 inserts + 200 updates = 1000 + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testEmptyCommitSkipsValidation() { + // Both offsets same and no records written + String checkpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{100}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(checkpoint), + buildWriteStats(0, 0), + buildMetadata(checkpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testPreviousCheckpointMissingSkipsValidation() { + // Previous metadata exists but has no checkpoint key + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(500, 0), + prevMeta); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testOvercountingDetected() { + // More records written than offset diff + // Diff = 100, records = 200 -> |100-200|/100 = 100% deviation + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{100}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(200, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertThrows(HoodieValidationException.class, () -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testExactToleranceBoundaryPasses() { + // Diff = 1000, records = 900 -> 10% deviation, tolerance = 10% + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(900, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(configWithTolerance(10.0)); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testJustOverToleranceFails() { + // Diff = 1000, records = 899 -> 10.1% deviation, tolerance = 10% + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(899, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(configWithTolerance(10.0)); + assertThrows(HoodieValidationException.class, () -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testOnlyInsertsNoUpdates() { + // Pure insert workload + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0, 1}, new long[]{0, 0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0, 1}, new long[]{500, 500}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(1000, 0), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testUpdatesCountedInRecordTotal() { + // Diff = 1000. 600 inserts + 400 updates = 1000 total + String prevCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{0}); + String currCheckpoint = buildSparkKafkaCheckpoint("events", new int[]{0}, new long[]{1000}); + + SparkValidationContext ctx = buildContext("20260320120000000", + buildMetadata(currCheckpoint), + buildWriteStats(600, 400), + buildMetadata(prevCheckpoint)); + + SparkKafkaOffsetValidator validator = new SparkKafkaOffsetValidator(defaultConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkStreamerValidatorUtils.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkStreamerValidatorUtils.java new file mode 100644 index 0000000000000..69d5d228dab60 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkStreamerValidatorUtils.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.HoodieTableVersion; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV2; +import org.apache.hudi.common.testutils.HoodieTestTable; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; +import org.apache.hudi.exception.HoodieValidationException; + +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; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link SparkStreamerValidatorUtils}. + * + *

    Tests cover orchestration logic (class loading, config passing, error handling) + * as well as end-to-end offset validation using a two-commit timeline to verify + * the real comparison path is exercised.

    + */ +public class TestSparkStreamerValidatorUtils { + + @TempDir + Path tempDir; + + private static TypedProperties propsWithValidator(String validatorClassName) { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.key(), validatorClassName); + props.setProperty(HoodiePreCommitValidatorConfig.STREAMING_OFFSET_TOLERANCE_PERCENTAGE.key(), "0.0"); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "FAIL"); + return props; + } + + private static WriteStatus buildWriteStatus(String partitionPath, long numInserts, long numUpdates) { + HoodieWriteStat stat = new HoodieWriteStat(); + stat.setPartitionPath(partitionPath); + stat.setNumInserts(numInserts); + stat.setNumUpdateWrites(numUpdates); + + WriteStatus ws = new WriteStatus(false, 0.0); + ws.setStat(stat); + return ws; + } + + private HoodieTableMetaClient createMetaClient() throws IOException { + return HoodieTestUtils.init(tempDir.toAbsolutePath().toString()); + } + + private HoodieTableMetaClient createMetaClient(HoodieTableVersion version) throws IOException { + return HoodieTestUtils.init(tempDir.toAbsolutePath().toString(), HoodieTableType.COPY_ON_WRITE, version); + } + + // ========== Tests ========== + + @Test + public void testNoValidatorsConfigured() throws IOException { + TypedProperties props = new TypedProperties(); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, + new HashMap<>(), createMetaClient())); + } + + @Test + public void testEmptyValidatorString() throws IOException { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATOR_CLASS_NAMES.key(), ""); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, + new HashMap<>(), createMetaClient())); + } + + @Test + public void testValidValidatorFirstCommitPasses() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + Map extraMeta = new HashMap<>(); + extraMeta.put(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:100"); + + // First commit (no previous metadata on timeline) — validator should skip and pass + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, createMetaClient())); + } + + @Test + public void testInvalidValidatorClassThrows() throws IOException { + TypedProperties props = propsWithValidator("com.nonexistent.FakeValidator"); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertThrows(HoodieValidationException.class, + () -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, new HashMap<>(), createMetaClient())); + } + + @Test + public void testMultipleValidators() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator," + + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + Map extraMeta = new HashMap<>(); + extraMeta.put(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:100"); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, createMetaClient())); + } + + @Test + public void testValidatorWithWhitespaceInClassNames() throws IOException { + TypedProperties props = propsWithValidator( + " org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator , "); + + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, new HashMap<>(), createMetaClient())); + } + + @Test + public void testNullExtraMetadataHandled() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, null, createMetaClient())); + } + + @Test + public void testMultipleWriteStatusesAggregated() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + List writeStatuses = new ArrayList<>(); + writeStatuses.add(buildWriteStatus("p1", 60, 0)); + writeStatuses.add(buildWriteStatus("p2", 40, 0)); + + Map extraMeta = new HashMap<>(); + extraMeta.put(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:100"); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, createMetaClient())); + } + + @Test + public void testEmptyWriteStatuses() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + List writeStatuses = Collections.emptyList(); + Map extraMeta = new HashMap<>(); + extraMeta.put(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:100"); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, createMetaClient())); + } + + @Test + public void testValidationExceptionPreservedAcrossValidators() throws IOException { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator," + + "com.nonexistent.FakeValidator"); + + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, new HashMap<>(), createMetaClient())); + assertTrue(ex.getMessage().contains("FakeValidator")); + } + + @ParameterizedTest + @ValueSource(strings = { + StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, + StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2 + }) + public void testSecondCommitMatchingOffsetsPasses(String checkpointKey) throws Exception { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + // Create table with a previous committed instant: offset 0 -> 500 + HoodieTableMetaClient metaClient = createMetaClient(); + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + prevMeta.addMetadata(checkpointKey, "events,0:500"); + HoodieTestTable.of(metaClient).addCommit("20260320110000000", Option.of(prevMeta)); + + // Second commit: offset 500 -> 600, 100 records written — matches diff exactly + Map extraMeta = new HashMap<>(); + extraMeta.put(checkpointKey, "events,0:600"); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 100, 0)); + + assertDoesNotThrow(() -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, metaClient)); + } + + @ParameterizedTest + @ValueSource(strings = { + StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, + StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2 + }) + public void testSecondCommitDataLossDetected(String checkpointKey) throws Exception { + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + // Create table with a previous committed instant: offset 0 -> 1000 + HoodieTableMetaClient metaClient = createMetaClient(); + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + prevMeta.addMetadata(checkpointKey, "events,0:1000"); + HoodieTestTable.of(metaClient).addCommit("20260320110000000", Option.of(prevMeta)); + + // Second commit: offset 1000 -> 2000 (diff=1000) but only 500 records written — data loss + Map extraMeta = new HashMap<>(); + extraMeta.put(checkpointKey, "events,0:2000"); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 500, 0)); + + assertThrows(HoodieValidationException.class, + () -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, metaClient)); + } + + @Test + public void testV2CheckpointKeyOnTableVersionEightFires() throws Exception { + // Verifies the validator actually fires on a writeTableVersion=8 table that uses the + // V2 checkpoint key — i.e. the auto-resolution in StreamingOffsetValidator picks up V2 + // and runs the comparison instead of silently skipping. + TypedProperties props = propsWithValidator( + "org.apache.hudi.utilities.streamer.validator.SparkKafkaOffsetValidator"); + + HoodieTableMetaClient metaClient = createMetaClient(HoodieTableVersion.EIGHT); + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + prevMeta.addMetadata(StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2, "events,0:1000"); + HoodieTestTable.of(metaClient).addCommit("20260320110000000", Option.of(prevMeta)); + + // Offset diff = 1000 but only 200 records written — must fail + Map extraMeta = new HashMap<>(); + extraMeta.put(StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2, "events,0:2000"); + List writeStatuses = Collections.singletonList(buildWriteStatus("p1", 200, 0)); + + assertThrows(HoodieValidationException.class, + () -> SparkStreamerValidatorUtils.runValidators( + props, "20260320120000000", writeStatuses, extraMeta, metaClient)); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkValidationContext.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkValidationContext.java new file mode 100644 index 0000000000000..7f94262e98c43 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkValidationContext.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.table.checkpoint.StreamerCheckpointV1; +import org.apache.hudi.common.util.Option; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link SparkValidationContext}. + */ +public class TestSparkValidationContext { + + private static HoodieWriteStat buildStat(long inserts, long updates) { + HoodieWriteStat stat = new HoodieWriteStat(); + stat.setNumInserts(inserts); + stat.setNumUpdateWrites(updates); + stat.setPartitionPath("partition1"); + return stat; + } + + @Test + public void testBasicProperties() { + HoodieCommitMetadata metadata = new HoodieCommitMetadata(); + metadata.addMetadata("key1", "value1"); + List writeStats = Collections.singletonList(buildStat(100, 50)); + + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(metadata), + Option.of(writeStats), + Option.empty()); + + assertEquals("20260320120000000", ctx.getInstantTime()); + assertTrue(ctx.getCommitMetadata().isPresent()); + assertTrue(ctx.getWriteStats().isPresent()); + assertEquals(1, ctx.getWriteStats().get().size()); + } + + @Test + public void testRecordCounting() { + List writeStats = Arrays.asList( + buildStat(100, 50), // partition1: 100 inserts, 50 updates + buildStat(200, 30)); // partition2: 200 inserts, 30 updates + + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(new HoodieCommitMetadata()), + Option.of(writeStats), + Option.empty()); + + assertEquals(300, ctx.getTotalInsertRecordsWritten()); + assertEquals(80, ctx.getTotalUpdateRecordsWritten()); + assertEquals(380, ctx.getTotalRecordsWritten()); + } + + @Test + public void testFirstCommitDetection() { + // No previous commit metadata -> first commit + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(new HoodieCommitMetadata()), + Option.of(Collections.emptyList()), + Option.empty()); + + assertTrue(ctx.isFirstCommit()); + } + + @Test + public void testNotFirstCommitWhenPreviousExists() { + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(new HoodieCommitMetadata()), + Option.of(Collections.emptyList()), + Option.of(prevMeta)); + + assertFalse(ctx.isFirstCommit()); + } + + @Test + public void testExtraMetadataAccess() { + HoodieCommitMetadata metadata = new HoodieCommitMetadata(); + metadata.addMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:1000"); + metadata.addMetadata("custom.key", "custom_value"); + + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(metadata), + Option.of(Collections.emptyList()), + Option.empty()); + + assertEquals("events,0:1000", + ctx.getExtraMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1).get()); + assertEquals("custom_value", ctx.getExtraMetadata("custom.key").get()); + assertFalse(ctx.getExtraMetadata("nonexistent.key").isPresent()); + } + + @Test + public void testPreviousCommitMetadataAccess() { + HoodieCommitMetadata prevMeta = new HoodieCommitMetadata(); + prevMeta.addMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1, "events,0:500"); + + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(new HoodieCommitMetadata()), + Option.of(Collections.emptyList()), + Option.of(prevMeta)); + + assertTrue(ctx.getPreviousCommitMetadata().isPresent()); + assertEquals("events,0:500", + ctx.getPreviousCommitMetadata().get().getMetadata(StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1)); + } + + @Test + public void testEmptyWriteStats() { + SparkValidationContext ctx = new SparkValidationContext( + "20260320120000000", + Option.of(new HoodieCommitMetadata()), + Option.empty(), + Option.empty()); + + assertEquals(0, ctx.getTotalRecordsWritten()); + assertEquals(0, ctx.getTotalInsertRecordsWritten()); + assertEquals(0, ctx.getTotalUpdateRecordsWritten()); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkWriteErrorValidator.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkWriteErrorValidator.java new file mode 100644 index 0000000000000..92460a94e9430 --- /dev/null +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/streamer/validator/TestSparkWriteErrorValidator.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.hudi.utilities.streamer.validator; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.model.HoodieCommitMetadata; +import org.apache.hudi.common.model.HoodieWriteStat; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodiePreCommitValidatorConfig; +import org.apache.hudi.exception.HoodieValidationException; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link SparkWriteErrorValidator}. + */ +public class TestSparkWriteErrorValidator { + + private static final String INSTANT = "20260520120000000"; + + // ========== Helpers ========== + + private static TypedProperties failConfig() { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "FAIL"); + return props; + } + + private static TypedProperties warnConfig() { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "WARN_LOG"); + return props; + } + + private static HoodieWriteStat stat(String partition, long numInserts, long numUpdates, long writeErrors) { + HoodieWriteStat s = new HoodieWriteStat(); + s.setPartitionPath(partition); + s.setNumInserts(numInserts); + s.setNumUpdateWrites(numUpdates); + s.setTotalWriteErrors(writeErrors); + return s; + } + + private static SparkValidationContext context(List writeStats) { + return new SparkValidationContext( + INSTANT, + Option.of(new HoodieCommitMetadata()), + Option.of(writeStats), + Option.empty()); + } + + // ========== Tests ========== + + @Test + public void testNoErrorsPasses() { + // 1000 records written, no errors -> passes + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 1000, 0, 0))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testErrorsWithFailPolicyThrows() { + // 500 written + 50 errors -> fails under FAIL policy (mirrors commitOnErrors=false) + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 500, 0, 50))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> validator.validateWithMetadata(ctx)); + assertTrue(ex.getMessage().contains("Errors: 50"), "message should report error count"); + assertTrue(ex.getMessage().contains("Total: 550"), "message should report total record count"); + } + + @Test + public void testErrorsWithWarnPolicyDoesNotThrow() { + // 500 written + 50 errors -> passes under WARN_LOG (mirrors commitOnErrors=true) + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 500, 0, 50))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(warnConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testEmptyCommitPasses() { + // 0 records, 0 errors -> empty commit, validation is skipped + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 0, 0, 0))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testNoWriteStatsTreatedAsEmpty() { + SparkValidationContext ctx = new SparkValidationContext( + INSTANT, + Option.of(new HoodieCommitMetadata()), + Option.empty(), + Option.empty()); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + assertDoesNotThrow(() -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testErrorsAcrossMultiplePartitionsAreSummed() { + // p1: 100 written + 5 errors. p2: 200 written + 10 errors. Total errors > 0 -> fail. + SparkValidationContext ctx = context(Arrays.asList( + stat("p1", 100, 0, 5), + stat("p2", 200, 0, 10))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> validator.validateWithMetadata(ctx)); + assertTrue(ex.getMessage().contains("Errors: 15"), + "errors should be summed across partitions, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("Total: 315"), + "total should be inserts + updates + errors across partitions, got: " + ex.getMessage()); + } + + @Test + public void testUpdatesCountedTowardTotal() { + // 0 inserts, 100 updates, 1 error -> 1/101 -> fail under FAIL + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 0, 100, 1))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(failConfig()); + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> validator.validateWithMetadata(ctx)); + assertTrue(ex.getMessage().contains("Total: 101"), + "updates should count toward total, got: " + ex.getMessage()); + } + + @Test + public void testDefaultPolicyIsFail() { + // No failure.policy set -> default is FAIL + TypedProperties props = new TypedProperties(); + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 10, 0, 1))); + + SparkWriteErrorValidator validator = new SparkWriteErrorValidator(props); + assertThrows(HoodieValidationException.class, () -> validator.validateWithMetadata(ctx)); + } + + @Test + public void testInvalidFailurePolicyRejected() { + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "garbage"); + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> new SparkWriteErrorValidator(props)); + assertTrue(ex.getMessage().contains("Invalid value 'garbage'"), + "message should name the bad value, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("FAIL") && ex.getMessage().contains("WARN_LOG"), + "message should list allowed values, got: " + ex.getMessage()); + } + + @Test + public void testLowercasePolicyRejected() { + // Java enum valueOf is case-sensitive; lowercase should fail loudly with a clear message. + TypedProperties props = new TypedProperties(); + props.setProperty(HoodiePreCommitValidatorConfig.VALIDATION_FAILURE_POLICY.key(), "fail"); + assertThrows(HoodieValidationException.class, () -> new SparkWriteErrorValidator(props)); + } + + @Test + public void testErrorMessageReferencesCommitOnErrorsFlag() { + // Regression: the prior message referenced a non-existent config key + // "hoodie.streamer.commit.on.errors". The user-facing fix should mention --commit-on-errors. + SparkValidationContext ctx = context(Collections.singletonList(stat("p1", 10, 0, 1))); + HoodieValidationException ex = assertThrows(HoodieValidationException.class, + () -> new SparkWriteErrorValidator(failConfig()).validateWithMetadata(ctx)); + assertTrue(ex.getMessage().contains("--commit-on-errors"), + "should reference the real CLI flag, got: " + ex.getMessage()); + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/JdbcTestUtils.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/JdbcTestUtils.java index 7f4f264b69c23..4cd96d24301c9 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/JdbcTestUtils.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/JdbcTestUtils.java @@ -23,9 +23,8 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.testutils.HoodieTestDataGenerator; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.sql.Connection; @@ -39,10 +38,9 @@ /** * Helper class used in testing {@link org.apache.hudi.utilities.sources.JdbcSource}. */ +@Slf4j public class JdbcTestUtils { - private static final Logger LOG = LoggerFactory.getLogger(JdbcTestUtils.class); - public static final String JDBC_URL = "jdbc:h2:mem:test_mem"; public static final String JDBC_DRIVER = "org.h2.Driver"; public static final String JDBC_USER = "test"; @@ -99,7 +97,7 @@ public static List insert(String commitTime, int numRecords, Conne insertStatement.setDouble(9, Double.parseDouble(((GenericRecord) record.get("fare")).get("amount").toString())); insertStatement.addBatch(); } catch (SQLException e) { - LOG.warn(e.getMessage()); + log.warn(e.getMessage()); } }); insertStatement.executeBatch(); @@ -137,7 +135,7 @@ public static List update(String commitTime, List in updateStatement.setString(10, r.get("_row_key").toString()); updateStatement.addBatch(); } catch (SQLException e) { - LOG.warn(e.getMessage()); + log.warn(e.getMessage()); } }); updateStatement.executeBatch(); @@ -149,7 +147,7 @@ private static void execute(Connection connection, String query, String message) try (Statement statement = connection.createStatement()) { statement.executeUpdate(query); } catch (SQLException e) { - LOG.error(message); + log.error(message); } } @@ -159,7 +157,7 @@ private static void close(Statement statement) { statement.close(); } } catch (SQLException e) { - LOG.error("Error while closing statement. " + e.getMessage()); + log.error("Error while closing statement. {}", e.getMessage()); } } @@ -169,7 +167,7 @@ public static void close(Connection connection) { connection.close(); } } catch (SQLException e) { - LOG.error("Error while closing connection. " + e.getMessage()); + log.error("Error while closing connection. {}", e.getMessage()); } } @@ -179,7 +177,7 @@ public static int count(Connection connection, String tableName) { rs.next(); return rs.getInt(1); } catch (SQLException e) { - LOG.warn("Error while counting records. " + e.getMessage()); + log.warn("Error while counting records. {}", e.getMessage()); return 0; } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/UtilitiesTestBase.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/UtilitiesTestBase.java index 4b5269e0a6dd1..7f7b30b27e3e4 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/UtilitiesTestBase.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/UtilitiesTestBase.java @@ -53,6 +53,7 @@ import com.fasterxml.jackson.dataformat.csv.CsvMapper; import com.fasterxml.jackson.dataformat.csv.CsvSchema; import com.fasterxml.jackson.dataformat.csv.CsvSchema.Builder; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.Schema; import org.apache.avro.file.DataFileWriter; import org.apache.avro.generic.GenericDatumWriter; @@ -79,8 +80,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.io.TempDir; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.FileInputStream; @@ -110,8 +109,8 @@ * Abstract test that provides a dfs & spark contexts. * */ +@Slf4j public class UtilitiesTestBase { - private static final Logger LOG = LoggerFactory.getLogger(UtilitiesTestBase.class); @TempDir protected static java.nio.file.Path sharedTempDir; protected static FileSystem fs; @@ -256,7 +255,7 @@ public static void cleanUpUtilitiesTestServices() { } if (!failedReleases.isEmpty()) { - LOG.error("Exception happened during releasing: " + String.join(",", failedReleases)); + log.error("Exception happened during releasing: {}", String.join(",", failedReleases)); } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/AbstractBaseTestSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/AbstractBaseTestSource.java index e943545ddbc09..3036810d84756 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/AbstractBaseTestSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/AbstractBaseTestSource.java @@ -30,13 +30,12 @@ import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.sources.AvroSource; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.Row; import org.apache.spark.sql.SQLContext; import org.apache.spark.sql.SparkSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; @@ -48,12 +47,11 @@ import java.util.stream.IntStream; import java.util.stream.Stream; +@Slf4j public abstract class AbstractBaseTestSource extends AvroSource { public static String schemaStr = HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA; - private static final Logger LOG = LoggerFactory.getLogger(AbstractBaseTestSource.class); - public static final int DEFAULT_PARTITION_NUM = 0; // Static instance, helps with reuse across a test. @@ -69,7 +67,7 @@ public static void initDataGen(TypedProperties props, int partition) { boolean useRocksForTestDataGenKeys = ConfigUtils.getBooleanWithAltKeys(props, SourceTestConfig.USE_ROCKSDB_FOR_TEST_DATAGEN_KEYS); String baseStoreDir = ConfigUtils.getStringWithAltKeys(props, SourceTestConfig.ROCKSDB_BASE_DIR_FOR_TEST_DATAGEN_KEYS, File.createTempFile("test_data_gen", ".keys").getParent()) + "/" + partition; - LOG.info("useRocksForTestDataGenKeys={}, BaseStoreDir={}", useRocksForTestDataGenKeys, baseStoreDir); + log.info("useRocksForTestDataGenKeys={}, BaseStoreDir={}", useRocksForTestDataGenKeys, baseStoreDir); dataGeneratorMap.put(partition, new HoodieTestDataGenerator(HoodieTestDataGenerator.DEFAULT_PARTITION_PATHS, useRocksForTestDataGenKeys ? new RocksDBBasedMap<>(baseStoreDir) : new HashMap<>())); } catch (IOException e) { @@ -114,11 +112,11 @@ protected static Stream fetchNextBatch(TypedProperties props, int // generate `sourceLimit` number of upserts each time. int numExistingKeys = dataGenerator.getNumExistingKeys(schemaStr); - LOG.info("NumExistingKeys={}", numExistingKeys); + log.info("NumExistingKeys={}", numExistingKeys); int numUpdates = Math.min(numExistingKeys, sourceLimit / 2); int numInserts = sourceLimit - numUpdates; - LOG.info("Before adjustments => numInserts={}, numUpdates={}", numInserts, numUpdates); + log.info("Before adjustments => numInserts={}, numUpdates={}", numInserts, numUpdates); boolean reachedMax = false; if (numInserts + numExistingKeys > maxUniqueKeys) { @@ -135,16 +133,16 @@ protected static Stream fetchNextBatch(TypedProperties props, int Stream deleteStream = Stream.empty(); Stream updateStream; long memoryUsage1 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); - LOG.info("Before DataGen. Memory Usage={}, Total Memory={}, Free Memory={}", memoryUsage1, Runtime.getRuntime().totalMemory(), + log.info("Before DataGen. Memory Usage={}, Total Memory={}, Free Memory={}", memoryUsage1, Runtime.getRuntime().totalMemory(), Runtime.getRuntime().freeMemory()); if (!reachedMax && numUpdates >= 50) { - LOG.info("After adjustments => NumInserts={}, NumUpdates={}, NumDeletes=50, maxUniqueRecords={}", numInserts, (numUpdates - 50), maxUniqueKeys); + log.info("After adjustments => NumInserts={}, NumUpdates={}, NumDeletes=50, maxUniqueRecords={}", numInserts, (numUpdates - 50), maxUniqueKeys); // if we generate update followed by deletes -> some keys in update batch might be picked up for deletes. Hence generating delete batch followed by updates deleteStream = dataGenerator.generateUniqueDeleteRecordStream(instantTime, 50, false, schemaStr, 0L).map(AbstractBaseTestSource::toGenericRecord); updateStream = dataGenerator.generateUniqueUpdatesStream(instantTime, numUpdates - 50, schemaStr, 0L) .map(AbstractBaseTestSource::toGenericRecord); } else { - LOG.info("After adjustments => NumInserts={}, NumUpdates={}, maxUniqueRecords={}", numInserts, numUpdates, maxUniqueKeys); + log.info("After adjustments => NumInserts={}, NumUpdates={}, maxUniqueRecords={}", numInserts, numUpdates, maxUniqueKeys); updateStream = dataGenerator.generateUniqueUpdatesStream(instantTime, numUpdates, schemaStr, 0L) .map(AbstractBaseTestSource::toGenericRecord); } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/DistributedTestDataSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/DistributedTestDataSource.java index 224acd6016a9c..7b7c06d622d40 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/DistributedTestDataSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/sources/DistributedTestDataSource.java @@ -26,23 +26,23 @@ import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.sources.InputBatch; +import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.SparkSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.apache.hudi.common.table.checkpoint.CheckpointUtils.createCheckpoint; + /** * A Test DataSource which scales test-data generation by using spark parallelism. */ +@Slf4j public class DistributedTestDataSource extends AbstractBaseTestSource { - private static final Logger LOG = LoggerFactory.getLogger(DistributedTestDataSource.class); - private final int numTestSourcePartitions; public DistributedTestDataSource(TypedProperties props, JavaSparkContext sparkContext, SparkSession sparkSession, @@ -55,11 +55,11 @@ public DistributedTestDataSource(TypedProperties props, JavaSparkContext sparkCo protected InputBatch> readFromCheckpoint(Option lastCheckpoint, long sourceLimit) { int nextCommitNum = lastCheckpoint.map(s -> Integer.parseInt(s.getCheckpointKey()) + 1).orElse(0); String instantTime = String.format("%05d", nextCommitNum); - LOG.info("Source Limit is set to {}", sourceLimit); + log.info("Source Limit is set to {}", sourceLimit); // No new data. if (sourceLimit <= 0) { - return new InputBatch<>(Option.empty(), instantTime); + return new InputBatch<>(Option.empty(), createCheckpoint(instantTime)); } TypedProperties newProps = new TypedProperties(); @@ -73,12 +73,12 @@ protected InputBatch> readFromCheckpoint(Option avroRDD = sparkContext.parallelize(IntStream.range(0, numTestSourcePartitions).boxed().collect(Collectors.toList()), numTestSourcePartitions).mapPartitionsWithIndex((p, idx) -> { - LOG.info("Initializing source with newProps={}", newProps); + log.info("Initializing source with newProps={}", newProps); if (!dataGeneratorMap.containsKey(p)) { initDataGen(newProps, p); } return fetchNextBatch(newProps, perPartitionSourceLimit, instantTime, p).iterator(); }, true); - return new InputBatch<>(Option.of(avroRDD), instantTime); + return new InputBatch<>(Option.of(avroRDD), createCheckpoint(instantTime)); } } diff --git a/hudi-utilities/src/test/resources/checkpoint-v6/parquet-dfs-v1-fixture.zip b/hudi-utilities/src/test/resources/checkpoint-v6/parquet-dfs-v1-fixture.zip new file mode 100644 index 0000000000000..29c1b98044359 Binary files /dev/null and b/hudi-utilities/src/test/resources/checkpoint-v6/parquet-dfs-v1-fixture.zip differ diff --git a/hudi-utilities/src/test/resources/schema/cdc_envelope_new.avsc b/hudi-utilities/src/test/resources/schema/cdc_envelope_new.avsc new file mode 100644 index 0000000000000..bf3ed9c0432d4 --- /dev/null +++ b/hudi-utilities/src/test/resources/schema/cdc_envelope_new.avsc @@ -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. + */ +{ + "type" : "record", + "name" : "Envelope", + "namespace" : "cdc.test.inventory.item", + "fields" : [ { + "name" : "before", + "type" : [ "null", { + "type" : "record", + "name" : "Value", + "fields" : [ { + "name" : "id", + "type" : "bytes" + }, { + "name" : "account_id", + "type" : "int" + }, { + "name" : "title", + "type" : "string" + }, { + "name" : "query", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "request_query_id", + "type" : [ "null", "bytes" ], + "default" : null + }, { + "name" : "page", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "request_page_id", + "type" : [ "null", "bytes" ], + "default" : null + }, { + "name" : "source_rank_id", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "property_id", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "created", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.default" : "1970-01-01T00:00:00Z", + "connect.name" : "io.debezium.time.ZonedTimestamp" + }, + "default" : "1970-01-01T00:00:00Z" + }, { + "name" : "created_by", + "type" : "int" + }, { + "name" : "updated", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.default" : "1970-01-01T00:00:00Z", + "connect.name" : "io.debezium.time.ZonedTimestamp" + }, + "default" : "1970-01-01T00:00:00Z" + }, { + "name" : "updated_by", + "type" : "int" + }, { + "name" : "version_id", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "related_urls", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "assignee", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "status", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.parameters" : { + "allowed" : "TO_DO,IN_PROGRESS,IN_REVIEW,APPROVED,PUBLISHED" + }, + "connect.default" : "TO_DO", + "connect.name" : "io.debezium.data.Enum" + }, + "default" : "TO_DO" + }, { + "name" : "tags", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "deleted", + "type" : { + "type" : "boolean", + "connect.default" : false + }, + "default" : false + }, { + "name" : "profile_id", + "type" : [ "null", "bytes" ], + "default" : null + }, { + "name" : "notes", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "search_engine_id", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "locale_id", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "language_id", + "type" : [ "null", "int" ], + "default" : null + } ], + "connect.name" : "cdc.test.inventory.item.Value" + } ], + "default" : null + }, { + "name" : "after", + "type" : [ "null", "Value" ], + "default" : null + }, { + "name" : "source", + "type" : { + "type" : "record", + "name" : "Source", + "namespace" : "io.debezium.connector.mysql", + "fields" : [ { + "name" : "version", + "type" : "string" + }, { + "name" : "connector", + "type" : "string" + }, { + "name" : "name", + "type" : "string" + }, { + "name" : "ts_ms", + "type" : "long" + }, { + "name" : "snapshot", + "type" : [ { + "type" : "string", + "connect.version" : 1, + "connect.parameters" : { + "allowed" : "true,last,false,incremental" + }, + "connect.default" : "false", + "connect.name" : "io.debezium.data.Enum" + }, "null" ], + "default" : "false" + }, { + "name" : "db", + "type" : "string" + }, { + "name" : "sequence", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "table", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "server_id", + "type" : "long" + }, { + "name" : "gtid", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "file", + "type" : "string" + }, { + "name" : "pos", + "type" : "long" + }, { + "name" : "row", + "type" : "int" + }, { + "name" : "thread", + "type" : [ "null", "long" ], + "default" : null + }, { + "name" : "query", + "type" : [ "null", "string" ], + "default" : null + } ], + "connect.name" : "io.debezium.connector.mysql.Source" + } + }, { + "name" : "op", + "type" : "string" + }, { + "name" : "ts_ms", + "type" : [ "null", "long" ], + "default" : null + }, { + "name" : "transaction", + "type" : [ "null", { + "type" : "record", + "name" : "block", + "namespace" : "event", + "fields" : [ { + "name" : "id", + "type" : "string" + }, { + "name" : "total_order", + "type" : "long" + }, { + "name" : "data_collection_order", + "type" : "long" + } ], + "connect.version" : 1, + "connect.name" : "event.block" + } ], + "default" : null + } ], + "connect.version" : 1, + "connect.name" : "cdc.test.inventory.item.Envelope" +} diff --git a/hudi-utilities/src/test/resources/schema/cdc_envelope_old.avsc b/hudi-utilities/src/test/resources/schema/cdc_envelope_old.avsc new file mode 100644 index 0000000000000..3d2a832d5d3ce --- /dev/null +++ b/hudi-utilities/src/test/resources/schema/cdc_envelope_old.avsc @@ -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. + */ +{ + "type" : "record", + "name" : "Envelope", + "namespace" : "cdc.test.inventory.item", + "fields" : [ { + "name" : "before", + "type" : [ "null", { + "type" : "record", + "name" : "Value", + "fields" : [ { + "name" : "id", + "type" : "bytes" + }, { + "name" : "account_id", + "type" : "int" + }, { + "name" : "title", + "type" : "string" + }, { + "name" : "query", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "request_query_id", + "type" : [ "null", "bytes" ], + "default" : null + }, { + "name" : "page", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "request_page_id", + "type" : [ "null", "bytes" ], + "default" : null + }, { + "name" : "source_rank_id", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "property_id", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "created", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.default" : "1970-01-01T00:00:00Z", + "connect.name" : "io.debezium.time.ZonedTimestamp" + }, + "default" : "1970-01-01T00:00:00Z" + }, { + "name" : "created_by", + "type" : "int" + }, { + "name" : "updated", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.default" : "1970-01-01T00:00:00Z", + "connect.name" : "io.debezium.time.ZonedTimestamp" + }, + "default" : "1970-01-01T00:00:00Z" + }, { + "name" : "updated_by", + "type" : "int" + }, { + "name" : "version_id", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "related_urls", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "assignee", + "type" : [ "null", "int" ], + "default" : null + }, { + "name" : "status", + "type" : { + "type" : "string", + "connect.version" : 1, + "connect.parameters" : { + "allowed" : "TO_DO,IN_PROGRESS,IN_REVIEW,APPROVED,PUBLISHED" + }, + "connect.default" : "TO_DO", + "connect.name" : "io.debezium.data.Enum" + }, + "default" : "TO_DO" + }, { + "name" : "tags", + "type" : [ "null", { + "type" : "string", + "connect.version" : 1, + "connect.name" : "io.debezium.data.Json" + } ], + "default" : null + }, { + "name" : "deleted", + "type" : { + "type" : "boolean", + "connect.default" : false + }, + "default" : false + }, { + "name" : "profile_id", + "type" : [ "null", "bytes" ], + "default" : null + } ], + "connect.name" : "cdc.test.inventory.item.Value" + } ], + "default" : null + }, { + "name" : "after", + "type" : [ "null", "Value" ], + "default" : null + }, { + "name" : "source", + "type" : { + "type" : "record", + "name" : "Source", + "namespace" : "io.debezium.connector.mysql", + "fields" : [ { + "name" : "version", + "type" : "string" + }, { + "name" : "connector", + "type" : "string" + }, { + "name" : "name", + "type" : "string" + }, { + "name" : "ts_ms", + "type" : "long" + }, { + "name" : "snapshot", + "type" : [ { + "type" : "string", + "connect.version" : 1, + "connect.parameters" : { + "allowed" : "true,last,false,incremental" + }, + "connect.default" : "false", + "connect.name" : "io.debezium.data.Enum" + }, "null" ], + "default" : "false" + }, { + "name" : "db", + "type" : "string" + }, { + "name" : "sequence", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "table", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "server_id", + "type" : "long" + }, { + "name" : "gtid", + "type" : [ "null", "string" ], + "default" : null + }, { + "name" : "file", + "type" : "string" + }, { + "name" : "pos", + "type" : "long" + }, { + "name" : "row", + "type" : "int" + }, { + "name" : "thread", + "type" : [ "null", "long" ], + "default" : null + }, { + "name" : "query", + "type" : [ "null", "string" ], + "default" : null + } ], + "connect.name" : "io.debezium.connector.mysql.Source" + } + }, { + "name" : "op", + "type" : "string" + }, { + "name" : "ts_ms", + "type" : [ "null", "long" ], + "default" : null + }, { + "name" : "transaction", + "type" : [ "null", { + "type" : "record", + "name" : "block", + "namespace" : "event", + "fields" : [ { + "name" : "id", + "type" : "string" + }, { + "name" : "total_order", + "type" : "long" + }, { + "name" : "data_collection_order", + "type" : "long" + } ], + "connect.version" : 1, + "connect.name" : "event.block" + } ], + "default" : null + } ], + "connect.version" : 1, + "connect.name" : "cdc.test.inventory.item.Envelope" +} diff --git a/packaging/bundle-validation/run_docker_java17.sh b/packaging/bundle-validation/run_docker_java17.sh index a380319a210ab..56d19bb147704 100755 --- a/packaging/bundle-validation/run_docker_java17.sh +++ b/packaging/bundle-validation/run_docker_java17.sh @@ -77,7 +77,7 @@ elif [[ ${SPARK_RUNTIME} == 'spark4.0.0' && ${SCALA_PROFILE} == 'scala-2.13' ]]; HADOOP_VERSION=3.4.0 HIVE_VERSION=3.1.3 DERBY_VERSION=10.14.1.0 - FLINK_VERSION=1.18.0 + FLINK_VERSION=1.20.0 SPARK_VERSION=4.0.0 SPARK_HADOOP_VERSION=3 CONFLUENT_VERSION=5.5.12 diff --git a/packaging/hudi-cli-bundle/pom.xml b/packaging/hudi-cli-bundle/pom.xml index f63b9edbc0739..e65c89e24d205 100644 --- a/packaging/hudi-cli-bundle/pom.xml +++ b/packaging/hudi-cli-bundle/pom.xml @@ -36,7 +36,7 @@ 2.0.2 3.21.0 2.11.0 - 2.11.0 + 2.15.0 diff --git a/packaging/hudi-flink-bundle/pom.xml b/packaging/hudi-flink-bundle/pom.xml index 25c3dff166c1e..9fb78c57e11f1 100644 --- a/packaging/hudi-flink-bundle/pom.xml +++ b/packaging/hudi-flink-bundle/pom.xml @@ -196,6 +196,15 @@ com.codahale.metrics. org.apache.hudi.com.codahale.metrics. + + + org.apache.flink.dropwizard. + ${flink.bundle.shade.prefix}org.apache.flink.dropwizard. + com.beust.jcommander. ${flink.bundle.shade.prefix}com.beust.jcommander. @@ -643,6 +652,106 @@ ${flink.bundle.hive.scope} + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + + + + + org.antlr:antlr-runtime + org.antlr:ST4 + + + + + + com.google.common. + ${flink.bundle.shade.prefix}com.google.common. + + + + com.google.protobuf. + ${flink.bundle.shade.prefix}com.google.protobuf. + + + org.apache.commons.lang3. + ${flink.bundle.shade.prefix}org.apache.commons.lang3. + + + org.apache.commons.lang. + ${flink.bundle.shade.prefix}org.apache.commons.lang. + + + org.apache.thrift. + ${flink.bundle.shade.prefix}org.apache.thrift. + + + org.apache.orc. + ${flink.bundle.shade.prefix}org.apache.orc. + + + org.json. + ${flink.bundle.shade.prefix}org.json. + + + org.codehaus.jackson. + ${flink.bundle.shade.prefix}org.codehaus.jackson. + + + com.facebook.fb303. + ${flink.bundle.shade.prefix}com.facebook.fb303. + + + au.com.bytecode.opencsv. + ${flink.bundle.shade.prefix}au.com.bytecode.opencsv. + + + javaewah. + ${flink.bundle.shade.prefix}javaewah. + + + javolution. + ${flink.bundle.shade.prefix}javolution. + + + jodd. + ${flink.bundle.shade.prefix}jodd. + + + + + + org.apache.hive:hive-exec + + org/apache/avro/** + org/apache/orc/** + org/apache/parquet/** + com/google/protobuf/** + io/airlift/compress/** + + + + + + + + + flink-bundle-shade-hive3 @@ -664,6 +773,106 @@ ${flink.bundle.hive.scope} + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + + + + + org.antlr:antlr-runtime + org.antlr:ST4 + + + + + + com.google.common. + ${flink.bundle.shade.prefix}com.google.common. + + + + com.google.protobuf. + ${flink.bundle.shade.prefix}com.google.protobuf. + + + org.apache.commons.lang3. + ${flink.bundle.shade.prefix}org.apache.commons.lang3. + + + org.apache.commons.lang. + ${flink.bundle.shade.prefix}org.apache.commons.lang. + + + org.apache.thrift. + ${flink.bundle.shade.prefix}org.apache.thrift. + + + org.apache.orc. + ${flink.bundle.shade.prefix}org.apache.orc. + + + org.json. + ${flink.bundle.shade.prefix}org.json. + + + org.codehaus.jackson. + ${flink.bundle.shade.prefix}org.codehaus.jackson. + + + com.facebook.fb303. + ${flink.bundle.shade.prefix}com.facebook.fb303. + + + au.com.bytecode.opencsv. + ${flink.bundle.shade.prefix}au.com.bytecode.opencsv. + + + javaewah. + ${flink.bundle.shade.prefix}javaewah. + + + javolution. + ${flink.bundle.shade.prefix}javolution. + + + jodd. + ${flink.bundle.shade.prefix}jodd. + + + + + + org.apache.hive:hive-exec + + org/apache/avro/** + org/apache/orc/** + org/apache/parquet/** + com/google/protobuf/** + io/airlift/compress/** + + + + + + + + + hudi-platform-service diff --git a/packaging/hudi-integ-test-bundle/pom.xml b/packaging/hudi-integ-test-bundle/pom.xml index 1e27fe2c731f4..870a9ecadc2fd 100644 --- a/packaging/hudi-integ-test-bundle/pom.xml +++ b/packaging/hudi-integ-test-bundle/pom.xml @@ -296,6 +296,16 @@ **/*.proto + + + io.trino:trino-jdbc + + io/trino/jdbc/$internal/airlift/compress/v3/** + + @@ -669,7 +679,7 @@ org.apache.thrift libthrift - 0.14.0 + 0.23.0 diff --git a/packaging/hudi-metaserver-server-bundle/pom.xml b/packaging/hudi-metaserver-server-bundle/pom.xml index bfe8c10359ac1..d0f353644c2e6 100644 --- a/packaging/hudi-metaserver-server-bundle/pom.xml +++ b/packaging/hudi-metaserver-server-bundle/pom.xml @@ -67,7 +67,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl ${log4j2.version} compile @@ -106,7 +106,7 @@ org.apache.logging.log4j:log4j-api org.apache.logging.log4j:log4j-core org.apache.logging.log4j:log4j-1.2-api - org.apache.logging.log4j:log4j-slf4j-impl + org.apache.logging.log4j:log4j-slf4j2-impl org.slf4j:slf4j-api org.slf4j:jul-to-slf4j org.mybatis:mybatis diff --git a/packaging/hudi-trino-bundle/pom.xml b/packaging/hudi-trino-bundle/pom.xml deleted file mode 100644 index 083805d304903..0000000000000 --- a/packaging/hudi-trino-bundle/pom.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - hudi - org.apache.hudi - 1.2.0 - ../../pom.xml - - 4.0.0 - hudi-trino-bundle - jar - - - true - ${project.parent.basedir} - true - - - - - - org.apache.rat - apache-rat-plugin - - - org.apache.maven.plugins - maven-shade-plugin - ${maven-shade-plugin.version} - - - package - - shade - - - ${shadeSources} - ${project.build.directory}/dependency-reduced-pom.xml - - - - - - true - - - META-INF/LICENSE - target/classes/META-INF/LICENSE - - - - - - org.apache.hudi:hudi-hadoop-common - org.apache.hudi:hudi-common - org.apache.hudi:hudi-client-common - org.apache.hudi:hudi-java-client - org.apache.hudi:hudi-hadoop-mr - - - com.esotericsoftware:kryo-shaded - com.esotericsoftware:minlog - org.objenesis:objenesis - - org.apache.parquet:parquet-avro - org.apache.avro:avro - com.github.ben-manes.caffeine:caffeine - org.codehaus.jackson:* - com.yammer.metrics:metrics-core - commons-io:commons-io - com.google.protobuf:protobuf-java - org.openjdk.jol:jol-core - - - - - - com.esotericsoftware.kryo. - org.apache.hudi.com.esotericsoftware.kryo. - - - com.esotericsoftware.reflectasm. - org.apache.hudi.com.esotericsoftware.reflectasm. - - - com.esotericsoftware.minlog. - org.apache.hudi.com.esotericsoftware.minlog. - - - org.objenesis. - org.apache.hudi.org.objenesis. - - - - org.apache.parquet.avro. - org.apache.hudi.org.apache.parquet.avro. - - - org.apache.avro. - org.apache.hudi.org.apache.avro. - - - org.apache.commons.io. - org.apache.hudi.org.apache.commons.io. - - - com.yammer.metrics. - org.apache.hudi.com.yammer.metrics. - - - com.google.common. - ${trino.bundle.bootstrap.shade.prefix}com.google.common. - - - org.apache.commons.lang. - ${trino.bundle.bootstrap.shade.prefix}org.apache.commons.lang. - - - com.google.protobuf. - ${trino.bundle.bootstrap.shade.prefix}com.google.protobuf. - - - org.openjdk.jol. - org.apache.hudi.org.openjdk.jol. - - - false - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - META-INF/services/javax.* - **/*.proto - - - - ${project.artifactId}-${project.version} - - - - - - - - src/main/resources - - - - - - - - org.apache.hudi - hudi-hadoop-mr-bundle - ${project.version} - - - org.apache.hudi - hudi-client-common - ${project.version} - - - guava - com.google.guava - - - - - org.apache.hudi - hudi-java-client - ${project.version} - - - - - com.esotericsoftware - kryo-shaded - ${kryo.shaded.version} - compile - - - - - org.apache.parquet - parquet-avro - ${trino.parquet.version} - compile - - - - - org.apache.avro - avro - ${avro.version} - compile - - - - - com.google.protobuf - protobuf-java - ${proto.version} - ${trino.bundle.bootstrap.scope} - - - - - - trino-shade-unbundle-bootstrap - - provided - - - - - diff --git a/pom.xml b/pom.xml index cd26cc509f36c..184d3ffadc4d6 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,6 @@ packaging/hudi-utilities-bundle packaging/hudi-utilities-slim-bundle packaging/hudi-timeline-server-bundle - packaging/hudi-trino-bundle hudi-examples hudi-flink-datasource hudi-kafka-connect @@ -121,7 +120,7 @@ 1.14.1 5.21.0 2.25.4 - 1.7.36 + 2.0.7 2.9.9 2.10.2 org.apache.hive @@ -129,13 +128,13 @@ 1.10.1 1.11.4 0.273 - 390 + 481 core 4.1.1 1.6.0 1.5.6 0.9.47 - 0.27 + 2.0.3 0.13.0 0.16.0 4.5.13 @@ -153,14 +152,14 @@ 1.19.2 1.18.1 1.17.1 - ${flink1.20.version} - hudi-flink1.20.x - 1.20 + ${flink2.1.version} + hudi-flink2.1.x + 2.1 1.11.4 - 1.13.1 + 1.15.2 - 3.3.0-1.20 + 4.0.1-2.0 flink-runtime flink-table-runtime flink-table-planner_2.12 @@ -170,7 +169,7 @@ flink-streaming-java flink-clients flink-connector-kafka - flink-hadoop-compatibility_2.12 + flink-hadoop-compatibility 7.5.3 3.3.4 3.4.3 @@ -479,6 +478,9 @@ com.fasterxml.jackson.module:jackson-module-afterburner com.fasterxml.jackson.module:jackson-module-scala_${scala.binary.version} + + org.apache.parquet:parquet-variant @@ -683,7 +685,6 @@ **/*.iml .mvn/** - hudi-trino-plugin/** @@ -830,7 +831,7 @@ org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl ${log4j2.version} provided @@ -1023,6 +1024,11 @@ org.slf4j * + + + org.apache.logging.log4j + log4j-slf4j-impl + log4j log4j @@ -1071,6 +1077,11 @@ org.slf4j * + + + org.apache.logging.log4j + log4j-slf4j-impl + log4j log4j @@ -1464,6 +1475,11 @@ org.pentaho * + + + org.apache.logging.log4j + log4j-slf4j-impl + @@ -1484,6 +1500,11 @@ org.slf4j slf4j-log4j12 + + + org.apache.logging.log4j + log4j-slf4j-impl + log4j log4j @@ -1500,6 +1521,11 @@ javax.mail mail + + + org.apache.logging.log4j + log4j-slf4j-impl + @@ -1528,6 +1554,11 @@ log4j log4j + + + org.apache.logging.log4j + log4j-slf4j-impl + org.apache.hbase * @@ -1977,6 +2008,16 @@ false + + confluent + https://packages.confluent.io/maven/ + + + + jitpack.io + https://jitpack.io + cloudera-repo-releases https://repository.cloudera.com/artifactory/public/ @@ -1987,10 +2028,6 @@ false - - confluent - https://packages.confluent.io/maven/ - @@ -2272,6 +2309,13 @@ packaging/hudi-metaserver-server-bundle + + + hudi-trino + + hudi-trino + + integration-tests @@ -2331,6 +2375,25 @@ + + + org.jacoco + jacoco-maven-plugin + + + + prepare-agent + + + ${project.build.directory}/jacoco-agent/${jacoco.agent.dest.filename} + + + + @@ -2508,14 +2571,11 @@ *:*_2.12 org.apache.flink:*_2.12 @@ -2899,6 +2959,7 @@ hudi-flink-datasource/hudi-flink2.1.x + true flink2.1 @@ -2942,7 +3003,6 @@ hudi-flink-datasource/hudi-flink1.20.x - true flink1.20 diff --git a/release/release_guide.md b/release/release_guide.md index 14efd12860afd..ed78098f66c1a 100644 --- a/release/release_guide.md +++ b/release/release_guide.md @@ -408,8 +408,8 @@ Set up a few environment variables to simplify Maven commands that follow. This 1. This will deploy jar artifacts to the Apache Nexus Repository, which is the staging area for deploying jars to Maven Central. 2. Review all staged artifacts (https://repository.apache.org/). They should contain all relevant parts for each module, including pom.xml, jar, test jar, source, test source, javadoc, etc. Carefully review any new artifacts. 3. git checkout ${RELEASE_BRANCH} - 4. Given that certain bundle jars are built by Java 17 (Spark 4 bundle), multiple - scripts need to be run. Run each from the repository root directory (the one containing `packaging/`). + 4. Given that certain artifacts require newer JDKs (Java 17 for the Spark 4 bundles, Java 25 for hudi-trino), + multiple scripts need to be run. Run each from the repository root directory (the one containing `packaging/`). 1. For most modules with Java 11 build, run `export JAVA_HOME=$(/usr/libexec/java_home -v 11)` and `./scripts/release/deploy_staging_jars.sh 2>&1 | tee -a "/tmp/${RELEASE_VERSION}-${RC_NUM}.deploy1.log"` 1. when prompted for the passphrase, if you have multiple gpg keys in your keyring, make sure that you enter @@ -425,23 +425,29 @@ Set up a few environment variables to simplify Maven commands that follow. This module. See [checklist](#checklist-to-proceed-to-the-next-step). 2. Continue with Java 17 build for Spark 4 bundle, run `export JAVA_HOME=$(/usr/libexec/java_home -v 17)` and `./scripts/release/deploy_staging_jars_java17.sh 2>&1 | tee -a "/tmp/${RELEASE_VERSION}-${RC_NUM}.deploy2.log"` - 5. Note that the artifacts from Java 17 build are uploaded to a separate staging repo. Use the - `copy_staging_repo.sh` script to copy all artifacts from the Java 17 staging repo into the Java 11 staging repo + 3. Continue with Java 25 build for the hudi-trino connector, run `export JAVA_HOME=$(/usr/libexec/java_home -v 25)` + and `./scripts/release/deploy_staging_jars_java25.sh 2>&1 | tee -a "/tmp/${RELEASE_VERSION}-${RC_NUM}.deploy3.log"`. + This step must run after the Java 11 step in 9.4.1, which installs the upstream Hudi modules that hudi-trino + resolves from the local m2 (the script does not pass `-am` because Lombok cannot run on JDK 25). + 5. Note that each of the Java 17 and Java 25 builds uploads its artifacts to its own separate staging repo. Use the + `copy_staging_repo.sh` script once per extra staging repo to copy all artifacts into the Java 11 staging repo so that all artifacts stay in the same repo. - 1. Identify both staging repo IDs from [Apache Nexus Staging Repositories](https://repository.apache.org/#stagingRepositories) - (e.g., `orgapachehudi-1177` for Java 17, `orgapachehudi-1176` for Java 11). Make sure both repos are still in - the "open" state (not closed). + 1. Identify all staging repo IDs from [Apache Nexus Staging Repositories](https://repository.apache.org/#stagingRepositories) + (e.g., `orgapachehudi-1177` for Java 17, `orgapachehudi-1178` for Java 25, `orgapachehudi-1176` for Java 11). + Make sure all repos are still in the "open" state (not closed). 2. First do a dry-run to verify the list of artifacts to be copied: ```shell ./scripts/release/copy_staging_repo.sh --dry-run + ./scripts/release/copy_staging_repo.sh --dry-run ``` - 3. Then run the actual copy: + 3. Then run the actual copies: ```shell ./scripts/release/copy_staging_repo.sh 2>&1 | tee -a "/tmp/${RELEASE_VERSION}-${RC_NUM}.copy_staging.log" + ./scripts/release/copy_staging_repo.sh 2>&1 | tee -a "/tmp/${RELEASE_VERSION}-${RC_NUM}.copy_staging.log" ``` 4. The script reads Nexus credentials from `~/.m2/settings.xml` (server id `apache.releases.https`), downloads - every artifact from the source repo, and re-uploads them to the target repo. After it finishes, drop the - Java 17 staging repo on Apache Nexus. + every artifact from the source repo, and re-uploads them to the target repo. After it finishes, drop both the + Java 17 and the Java 25 staging repos on Apache Nexus. 6. Review all staged artifacts by logging into Apache Nexus and clicking on "Staging Repositories" link on left pane. Then find a "open" entry for apachehudi 7. Ensure it contains all 2 (2.12 and 2.13) artifacts, mainly hudi-spark-bundle-2.12/2.13, diff --git a/rfc/README.md b/rfc/README.md index 20b1cd53529b1..6e059e2ad0be8 100644 --- a/rfc/README.md +++ b/rfc/README.md @@ -34,108 +34,114 @@ The list of all RFCs can be found here. > Older RFC content is still [here](https://cwiki.apache.org/confluence/display/HUDI/RFC+Process). -| RFC Number | Title | Status | -|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| -| 1 | [CSV Source Support for Delta Streamer](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+01+%3A+CSV+Source+Support+for+Delta+Streamer) | :white_check_mark: `COMPLETED` | -| 2 | [ORC Storage in Hudi](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=113708439) | :white_check_mark: `COMPLETED` | -| 3 | [Timeline Service with Incremental File System View Syncing](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=113708965) | :white_check_mark: `COMPLETED` | -| 4 | [Faster Hive incremental pull queries](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=115513622) | :white_check_mark: `COMPLETED` | -| 5 | [HUI (Hudi WebUI)](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=130027233) | :x: `ABANDONED` | -| 6 | [Add indexing support to the log file](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+06+%3A+Add+indexing+support+to+the+log+file) | :x: `ABANDONED` | -| 7 | [Point in time Time-Travel queries on Hudi table](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+07+%3A+Point+in+time+Time-Travel+queries+on+Hudi+table) | :white_check_mark: `COMPLETED` | -| 8 | [Metadata based Record Index](./rfc-8/rfc-8.md) | :white_check_mark: `COMPLETED` | -| 9 | [Hudi Dataset Snapshot Exporter](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+09+%3A+Hudi+Dataset+Snapshot+Exporter) | :white_check_mark: `COMPLETED` | -| 10 | [Restructuring and auto-generation of docs](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+10+%3A+Restructuring+and+auto-generation+of+docs) | :white_check_mark: `COMPLETED` | -| 11 | [Refactor of the configuration framework of hudi project](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+11+%3A+Refactor+of+the+configuration+framework+of+hudi+project) | :x: `ABANDONED` | -| 12 | [Efficient Migration of Large Parquet Tables to Apache Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+12+%3A+Efficient+Migration+of+Large+Parquet+Tables+to+Apache+Hudi) | :white_check_mark: `COMPLETED` | -| 13 | [Integrate Hudi with Flink](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=141724520) | :white_check_mark: `COMPLETED` | -| 14 | [JDBC incremental puller](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+14+%3A+JDBC+incremental+puller) | :white_check_mark: `COMPLETED` | -| 15 | [HUDI File Listing Improvements](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+15%3A+HUDI+File+Listing+Improvements) | :white_check_mark: `COMPLETED` | -| 16 | [Abstraction for HoodieInputFormat and RecordReader](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+16+Abstraction+for+HoodieInputFormat+and+RecordReader) | :white_check_mark: `COMPLETED` | -| 17 | [Abstract common meta sync module support multiple meta service](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+17+Abstract+common+meta+sync+module+support+multiple+meta+service) | :white_check_mark: `COMPLETED` | -| 18 | [Insert Overwrite API](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+18+Insert+Overwrite+API) | :white_check_mark: `COMPLETED` | -| 19 | [Clustering data for freshness and query performance](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+19+Clustering+data+for+freshness+and+query+performance) | :white_check_mark: `COMPLETED` | -| 20 | [handle failed records](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+20+%3A+handle+failed+records) | :arrows_counterclockwise: `ONGOING` | -| 21 | [Allow HoodieRecordKey to be Virtual](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+21+%3A+Allow+HoodieRecordKey+to+be+Virtual) | :white_check_mark: `COMPLETED` | -| 22 | [Snapshot Isolation using Optimistic Concurrency Control for multi-writers](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+22+%3A+Snapshot+Isolation+using+Optimistic+Concurrency+Control+for+multi-writers) | :white_check_mark: `COMPLETED` | -| 23 | [Hudi Observability metrics collection](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+23+%3A+Hudi+Observability+metrics+collection) | :x: `ABANDONED` | -| 24 | [Hoodie Flink Writer Proposal](https://cwiki.apache.org/confluence/display/HUDI/RFC-24%3A+Hoodie+Flink+Writer+Proposal) | :white_check_mark: `COMPLETED` | -| 25 | [Spark SQL Extension For Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+25%3A+Spark+SQL+Extension+For+Hudi) | :white_check_mark: `COMPLETED` | -| 26 | [Optimization For Hudi Table Query](https://cwiki.apache.org/confluence/display/HUDI/RFC-26+Optimization+For+Hudi+Table+Query) | :white_check_mark: `COMPLETED` | -| 27 | [Data skipping index to improve query performance](https://cwiki.apache.org/confluence/display/HUDI/RFC-27+Data+skipping+index+to+improve+query+performance) | :white_check_mark: `COMPLETED` | -| 28 | [Support Z-order curve](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=181307144) | :white_check_mark: `COMPLETED` | -| 29 | [Hash Index](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+29%3A+Hash+Index) | :white_check_mark: `COMPLETED` | -| 30 | [Batch operation](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+30%3A+Batch+operation) | :x: `ABANDONED` | -| 31 | [Hive integration Improvement](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+31%3A+Hive+integration+Improvment) | :x: `ABANDONED` | -| 32 | [Kafka Connect Sink for Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC-32+Kafka+Connect+Sink+for+Hudi) | :arrows_counterclockwise: `ONGOING` | -| 33 | [Hudi supports more comprehensive Schema Evolution](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+33++Hudi+supports+more+comprehensive+Schema+Evolution) | :white_check_mark: `COMPLETED` | -| 34 | [Hudi BigQuery Integration](./rfc-34/rfc-34.md) | :white_check_mark: `COMPLETED` | -| 35 | [Make Flink MOR table writing streaming friendly](https://cwiki.apache.org/confluence/display/HUDI/RFC-35%3A+Make+Flink+MOR+table+writing+streaming+friendly) | :white_check_mark: `COMPLETED` | -| 36 | [HUDI Metastore Server](https://cwiki.apache.org/confluence/display/HUDI/%5BWIP%5D+RFC-36%3A+HUDI+Metastore+Server) | :arrows_counterclockwise: `ONGOING` | -| 37 | [Hudi Metadata based Bloom Index](rfc-37/rfc-37.md) | :white_check_mark: `COMPLETED` | -| 38 | [Spark Datasource V2 Integration](./rfc-38/rfc-38.md) | :white_check_mark: `COMPLETED` | -| 39 | [Incremental source for Debezium](./rfc-39/rfc-39.md) | :white_check_mark: `COMPLETED` | -| 40 | [Connector for Trino](./rfc-40/rfc-40.md) | :white_check_mark: `COMPLETED` | -| 41 | [Snowflake Integration](./rfc-41/rfc-41.md), supported via [Apache XTable (Incubating)](https://xtable.apache.org/) | :x: `ABANDONED` | -| 42 | [Consistent Hashing Index](./rfc-42/rfc-42.md) | :arrows_counterclockwise: `ONGOING` | -| 43 | [Table Management Service](./rfc-43/rfc-43.md) | :x: `ABANDONED` | -| 44 | [Hudi Connector for Presto](./rfc-44/rfc-44.md) | :white_check_mark: `COMPLETED` | -| 45 | [Asynchronous Metadata Indexing](./rfc-45/rfc-45.md) | :white_check_mark: `COMPLETED` | -| 46 | [Optimizing Record Payload Handling](./rfc-46/rfc-46.md) | :white_check_mark: `COMPLETED` | -| 47 | [Add Call Produce Command for Spark SQL](./rfc-47/rfc-47.md) | :white_check_mark: `COMPLETED` | -| 48 | [LogCompaction for MOR tables](./rfc-48/rfc-48.md) | :white_check_mark: `COMPLETED` | -| 49 | [Support sync with DataHub](./rfc-49/rfc-49.md) | :white_check_mark: `COMPLETED` | -| 50 | [Improve Timeline Server](./rfc-50/rfc-50.md) | :x: `ABANDONED` | -| 51 | [Change Data Capture](./rfc-51/rfc-51.md) | :arrows_counterclockwise: `ONGOING` | -| 52 | [Introduce Secondary Index to Improve HUDI Query Performance](./rfc-52/rfc-52.md) | :x: `ABANDONED` | -| 53 | [Use Lock-Free Message Queue Improving Hoodie Writing Efficiency](./rfc-53/rfc-53.md) | :white_check_mark: `COMPLETED` | -| 54 | [New Table APIs and Streamline Hudi Configs](./rfc-54/rfc-54.md) | :x: `ABANDONED` | -| 55 | [Improve Hive/Meta sync class design and hierarchies](./rfc-55/rfc-55.md) | :white_check_mark: `COMPLETED` | -| 56 | [Early Conflict Detection For Multi-Writer](./rfc-56/rfc-56.md) | :white_check_mark: `COMPLETED` | -| 57 | [DeltaStreamer Protobuf Support](./rfc-57/rfc-57.md) | :white_check_mark: `COMPLETED` | -| 58 | [Integrate column stats index with all query engines](./rfc-58/rfc-58.md) | :white_check_mark: `COMPLETED` | -| 59 | [Multiple event_time Fields Latest Verification in a Single Table](./rfc-59/rfc-59.md) | :eyes: `UNDER REVIEW` | -| 60 | [Federated Storage Layer](./rfc-60/rfc-60.md) | :eyes: `UNDER REVIEW` | -| 61 | [Snapshot view management](./rfc-61/rfc-61.md) | :eyes: `UNDER REVIEW` | -| 62 | [Diagnostic Reporter](./rfc-62/rfc-62.md) | :eyes: `UNDER REVIEW` | -| 63 | [Expression Indexes](./rfc-63/rfc-63.md) | :arrows_counterclockwise: `ONGOING` | -| 64 | [New Hudi Table Spec API for Query Integrations](./rfc-64/rfc-64.md) | :eyes: `UNDER REVIEW` | -| 65 | [Partition TTL Management](./rfc-65/rfc-65.md) | :white_check_mark: `COMPLETED` | -| 66 | [Non Blocking Concurrency Control](./rfc-66/rfc-66.md) | :white_check_mark: `COMPLETED` | -| 67 | [Hudi Bundle Standards](./rfc-67/rfc-67.md) | :white_check_mark: `COMPLETED` | -| 68 | [A More Effective HoodieMergeHandler for COW Table with Parquet](./rfc-68/rfc-68.md) | :x: `ABANDONED` | -| 69 | [Hudi 1.x](./rfc-69/rfc-69.md) | :white_check_mark: `COMPLETED` | -| 70 | [Hudi Reverse Streamer](./rfc/rfc-70/rfc-70.md) | :eyes: `UNDER REVIEW` | -| 71 | [Enhance OCC conflict detection](./rfc/rfc-71/rfc-71.md) | :eyes: `UNDER REVIEW` | -| 72 | [Redesign Hudi-Spark Integration](./rfc/rfc-72/rfc-72.md) | :arrows_counterclockwise: `ONGOING` | -| 73 | [Multi-Table Transactions](./rfc-73/rfc-73.md) | :eyes: `UNDER REVIEW` | -| 74 | [`HoodieStorage`: Hudi Storage Abstraction and APIs](./rfc-74/rfc-74.md) | :arrows_counterclockwise: `ONGOING` | -| 75 | [Hudi-Native HFile Reader and Writer](./rfc-75/rfc-75.md) | :white_check_mark: `COMPLETED` | -| 76 | [Auto Record key generation](./rfc-76/rfc-76.md) | :white_check_mark: `COMPLETED` | -| 77 | [Secondary Index](./rfc-77/rfc-77.md) | :white_check_mark: `COMPLETED` | -| 78 | [1.0 Migration](./rfc-78/rfc-78.md) | :hammer_and_wrench: `IN PROGRESS` | -| 79 | [Robust handling of spark task retries and failures](./rfc-79/rfc-79.md) | :x: `ABANDONED` | -| 80 | [Column Groups](./rfc-80/rfc-80.md) | :hammer_and_wrench: `IN PROGRESS` | -| 81 | [Introduce Primary Key Sorted Table](./rfc-81/rfc-81.md) | :eyes: `UNDER REVIEW` | -| 82 | [Concurrent schema evolution detection](./rfc-82/rfc-82.md) | :white_check_mark: `COMPLETED` | -| 83 | [Incremental Table Service](./rfc-83/rfc-83.md) | :white_check_mark: `COMPLETED` | -| 84 | [Optimized SerDe of `DataStream` in Flink operators](./rfc-84/rfc-84.md) | :white_check_mark: `COMPLETED` | -| 85 | [Hudi Issue and Sprint Management in Jira](./rfc-85/rfc-85.md) | :white_check_mark: `COMPLETED` | -| 86 | [DataFrame Implementation of HUDI write path](./rfc-86/rfc-86.md) | :eyes: `UNDER REVIEW` | -| 87 | [Avro elimination for Flink writer](./rfc-87/rfc-87.md) | :hammer_and_wrench: `IN PROGRESS` | -| 88 | [New Schema/DataType/Expression Abstractions](./rfc-88/rfc-88.md) | :eyes: `UNDER REVIEW` | -| 89 | [Dynamic Partition Level Bucket Index](./rfc-89/rfc-89.md) | :eyes: `UNDER REVIEW` | -| 90 | Add support for cancellable clustering table service plans | :eyes: `UNDER REVIEW` | -| 91 | Storage-based lock provider using conditional writes | :hammer_and_wrench: `IN PROGRESS` | -| 92 | Support Bitmap Index | :hammer_and_wrench: `IN PROGRESS` | -| 93 | [Pluggable Table Formats in Hudi](./rfc-93/rfc-93.md) | :hammer_and_wrench: `IN PROGRESS` | -| 94 | Hudi Timeline User Interface (UI) | :eyes: `UNDER REVIEW` | -| 95 | Hudi Flink Source Based on FLIP-27 | :eyes: `UNDER REVIEW` | -| 96 | Introduce Unified Bucket Index | :eyes: `UNDER REVIEW` | -| 97 | Deprecate Hudi Payload Class Usage | :eyes: `UNDER REVIEW` | -| 98 | [Spark Datasource V2 Read](./rfc-98/rfc-98.md) | :eyes: `UNDER REVIEW` | -| 99 | [Hudi Type System Redesign](./rfc-99/rfc-99.md) | :eyes: `UNDER REVIEW` | -| 100 | [Unstructured Data Storage in Hudi](./rfc-100/rfc-100.md) | :eyes: `UNDER REVIEW` | -| 101 | [Updates to the HoodieRecordMerger API](./rfc-101/rfc-101.md) | :hammer_and_wrench: `IN PROGRESS` | -| 102 | RLI support for Flink streaming | :eyes: `UNDER REVIEW` | -| 103 | Hudi LSM tree layout | :eyes: `UNDER REVIEW` | \ No newline at end of file +| RFC Number | Title | Status | +| ------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ----------------------------------- | +| 1 | [CSV Source Support for Delta Streamer](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+01+%3A+CSV+Source+Support+for+Delta+Streamer) | :white_check_mark: `COMPLETED` | +| 2 | [ORC Storage in Hudi](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=113708439) | :white_check_mark: `COMPLETED` | +| 3 | [Timeline Service with Incremental File System View Syncing](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=113708965) | :white_check_mark: `COMPLETED` | +| 4 | [Faster Hive incremental pull queries](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=115513622) | :white_check_mark: `COMPLETED` | +| 5 | [HUI (Hudi WebUI)](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=130027233) | :x: `ABANDONED` | +| 6 | [Add indexing support to the log file](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+06+%3A+Add+indexing+support+to+the+log+file) | :x: `ABANDONED` | +| 7 | [Point in time Time-Travel queries on Hudi table](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+07+%3A+Point+in+time+Time-Travel+queries+on+Hudi+table) | :white_check_mark: `COMPLETED` | +| 8 | [Metadata based Record Index](./rfc-8/rfc-8.md) | :white_check_mark: `COMPLETED` | +| 9 | [Hudi Dataset Snapshot Exporter](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+09+%3A+Hudi+Dataset+Snapshot+Exporter) | :white_check_mark: `COMPLETED` | +| 10 | [Restructuring and auto-generation of docs](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+10+%3A+Restructuring+and+auto-generation+of+docs) | :white_check_mark: `COMPLETED` | +| 11 | [Refactor of the configuration framework of hudi project](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+11+%3A+Refactor+of+the+configuration+framework+of+hudi+project) | :x: `ABANDONED` | +| 12 | [Efficient Migration of Large Parquet Tables to Apache Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+12+%3A+Efficient+Migration+of+Large+Parquet+Tables+to+Apache+Hudi) | :white_check_mark: `COMPLETED` | +| 13 | [Integrate Hudi with Flink](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=141724520) | :white_check_mark: `COMPLETED` | +| 14 | [JDBC incremental puller](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+14+%3A+JDBC+incremental+puller) | :white_check_mark: `COMPLETED` | +| 15 | [HUDI File Listing Improvements](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+15%3A+HUDI+File+Listing+Improvements) | :white_check_mark: `COMPLETED` | +| 16 | [Abstraction for HoodieInputFormat and RecordReader](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+16+Abstraction+for+HoodieInputFormat+and+RecordReader) | :white_check_mark: `COMPLETED` | +| 17 | [Abstract common meta sync module support multiple meta service](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+17+Abstract+common+meta+sync+module+support+multiple+meta+service) | :white_check_mark: `COMPLETED` | +| 18 | [Insert Overwrite API](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+18+Insert+Overwrite+API) | :white_check_mark: `COMPLETED` | +| 19 | [Clustering data for freshness and query performance](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+19+Clustering+data+for+freshness+and+query+performance) | :white_check_mark: `COMPLETED` | +| 20 | [handle failed records](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+20+%3A+handle+failed+records) | :arrows_counterclockwise: `ONGOING` | +| 21 | [Allow HoodieRecordKey to be Virtual](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+21+%3A+Allow+HoodieRecordKey+to+be+Virtual) | :white_check_mark: `COMPLETED` | +| 22 | [Snapshot Isolation using Optimistic Concurrency Control for multi-writers](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+22+%3A+Snapshot+Isolation+using+Optimistic+Concurrency+Control+for+multi-writers) | :white_check_mark: `COMPLETED` | +| 23 | [Hudi Observability metrics collection](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+23+%3A+Hudi+Observability+metrics+collection) | :x: `ABANDONED` | +| 24 | [Hoodie Flink Writer Proposal](https://cwiki.apache.org/confluence/display/HUDI/RFC-24%3A+Hoodie+Flink+Writer+Proposal) | :white_check_mark: `COMPLETED` | +| 25 | [Spark SQL Extension For Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+25%3A+Spark+SQL+Extension+For+Hudi) | :white_check_mark: `COMPLETED` | +| 26 | [Optimization For Hudi Table Query](https://cwiki.apache.org/confluence/display/HUDI/RFC-26+Optimization+For+Hudi+Table+Query) | :white_check_mark: `COMPLETED` | +| 27 | [Data skipping index to improve query performance](https://cwiki.apache.org/confluence/display/HUDI/RFC-27+Data+skipping+index+to+improve+query+performance) | :white_check_mark: `COMPLETED` | +| 28 | [Support Z-order curve](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=181307144) | :white_check_mark: `COMPLETED` | +| 29 | [Hash Index](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+29%3A+Hash+Index) | :white_check_mark: `COMPLETED` | +| 30 | [Batch operation](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+30%3A+Batch+operation) | :x: `ABANDONED` | +| 31 | [Hive integration Improvement](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+31%3A+Hive+integration+Improvment) | :x: `ABANDONED` | +| 32 | [Kafka Connect Sink for Hudi](https://cwiki.apache.org/confluence/display/HUDI/RFC-32+Kafka+Connect+Sink+for+Hudi) | :arrows_counterclockwise: `ONGOING` | +| 33 | [Hudi supports more comprehensive Schema Evolution](https://cwiki.apache.org/confluence/display/HUDI/RFC+-+33++Hudi+supports+more+comprehensive+Schema+Evolution) | :white_check_mark: `COMPLETED` | +| 34 | [Hudi BigQuery Integration](./rfc-34/rfc-34.md) | :white_check_mark: `COMPLETED` | +| 35 | [Make Flink MOR table writing streaming friendly](https://cwiki.apache.org/confluence/display/HUDI/RFC-35%3A+Make+Flink+MOR+table+writing+streaming+friendly) | :white_check_mark: `COMPLETED` | +| 36 | [HUDI Metastore Server](https://cwiki.apache.org/confluence/display/HUDI/%5BWIP%5D+RFC-36%3A+HUDI+Metastore+Server) | :arrows_counterclockwise: `ONGOING` | +| 37 | [Hudi Metadata based Bloom Index](rfc-37/rfc-37.md) | :white_check_mark: `COMPLETED` | +| 38 | [Spark Datasource V2 Integration](./rfc-38/rfc-38.md) | :white_check_mark: `COMPLETED` | +| 39 | [Incremental source for Debezium](./rfc-39/rfc-39.md) | :white_check_mark: `COMPLETED` | +| 40 | [Connector for Trino](./rfc-40/rfc-40.md) | :white_check_mark: `COMPLETED` | +| 41 | [Snowflake Integration](./rfc-41/rfc-41.md), supported via [Apache XTable (Incubating)](https://xtable.apache.org/) | :x: `ABANDONED` | +| 42 | [Consistent Hashing Index](./rfc-42/rfc-42.md) | :arrows_counterclockwise: `ONGOING` | +| 43 | [Table Management Service](./rfc-43/rfc-43.md) | :x: `ABANDONED` | +| 44 | [Hudi Connector for Presto](./rfc-44/rfc-44.md) | :white_check_mark: `COMPLETED` | +| 45 | [Asynchronous Metadata Indexing](./rfc-45/rfc-45.md) | :white_check_mark: `COMPLETED` | +| 46 | [Optimizing Record Payload Handling](./rfc-46/rfc-46.md) | :white_check_mark: `COMPLETED` | +| 47 | [Add Call Produce Command for Spark SQL](./rfc-47/rfc-47.md) | :white_check_mark: `COMPLETED` | +| 48 | [LogCompaction for MOR tables](./rfc-48/rfc-48.md) | :white_check_mark: `COMPLETED` | +| 49 | [Support sync with DataHub](./rfc-49/rfc-49.md) | :white_check_mark: `COMPLETED` | +| 50 | [Improve Timeline Server](./rfc-50/rfc-50.md) | :x: `ABANDONED` | +| 51 | [Change Data Capture](./rfc-51/rfc-51.md) | :arrows_counterclockwise: `ONGOING` | +| 52 | [Introduce Secondary Index to Improve HUDI Query Performance](./rfc-52/rfc-52.md) | :x: `ABANDONED` | +| 53 | [Use Lock-Free Message Queue Improving Hoodie Writing Efficiency](./rfc-53/rfc-53.md) | :white_check_mark: `COMPLETED` | +| 54 | [New Table APIs and Streamline Hudi Configs](./rfc-54/rfc-54.md) | :x: `ABANDONED` | +| 55 | [Improve Hive/Meta sync class design and hierarchies](./rfc-55/rfc-55.md) | :white_check_mark: `COMPLETED` | +| 56 | [Early Conflict Detection For Multi-Writer](./rfc-56/rfc-56.md) | :white_check_mark: `COMPLETED` | +| 57 | [DeltaStreamer Protobuf Support](./rfc-57/rfc-57.md) | :white_check_mark: `COMPLETED` | +| 58 | [Integrate column stats index with all query engines](./rfc-58/rfc-58.md) | :white_check_mark: `COMPLETED` | +| 59 | [Multiple event_time Fields Latest Verification in a Single Table](./rfc-59/rfc-59.md) | :eyes: `UNDER REVIEW` | +| 60 | [Federated Storage Layer](./rfc-60/rfc-60.md) | :eyes: `UNDER REVIEW` | +| 61 | [Snapshot view management](./rfc-61/rfc-61.md) | :eyes: `UNDER REVIEW` | +| 62 | [Diagnostic Reporter](./rfc-62/rfc-62.md) | :eyes: `UNDER REVIEW` | +| 63 | [Expression Indexes](./rfc-63/rfc-63.md) | :arrows_counterclockwise: `ONGOING` | +| 64 | [New Hudi Table Spec API for Query Integrations](./rfc-64/rfc-64.md) | :eyes: `UNDER REVIEW` | +| 65 | [Partition TTL Management](./rfc-65/rfc-65.md) | :white_check_mark: `COMPLETED` | +| 66 | [Non Blocking Concurrency Control](./rfc-66/rfc-66.md) | :white_check_mark: `COMPLETED` | +| 67 | [Hudi Bundle Standards](./rfc-67/rfc-67.md) | :white_check_mark: `COMPLETED` | +| 68 | [A More Effective HoodieMergeHandler for COW Table with Parquet](./rfc-68/rfc-68.md) | :x: `ABANDONED` | +| 69 | [Hudi 1.x](./rfc-69/rfc-69.md) | :white_check_mark: `COMPLETED` | +| 70 | [Hudi Reverse Streamer](./rfc/rfc-70/rfc-70.md) | :eyes: `UNDER REVIEW` | +| 71 | [Enhance OCC conflict detection](./rfc/rfc-71/rfc-71.md) | :eyes: `UNDER REVIEW` | +| 72 | [Redesign Hudi-Spark Integration](./rfc/rfc-72/rfc-72.md) | :arrows_counterclockwise: `ONGOING` | +| 73 | [Multi-Table Transactions](./rfc-73/rfc-73.md) | :eyes: `UNDER REVIEW` | +| 74 | [`HoodieStorage`: Hudi Storage Abstraction and APIs](./rfc-74/rfc-74.md) | :arrows_counterclockwise: `ONGOING` | +| 75 | [Hudi-Native HFile Reader and Writer](./rfc-75/rfc-75.md) | :white_check_mark: `COMPLETED` | +| 76 | [Auto Record key generation](./rfc-76/rfc-76.md) | :white_check_mark: `COMPLETED` | +| 77 | [Secondary Index](./rfc-77/rfc-77.md) | :white_check_mark: `COMPLETED` | +| 78 | [1.0 Migration](./rfc-78/rfc-78.md) | :hammer_and_wrench: `IN PROGRESS` | +| 79 | [Robust handling of spark task retries and failures](./rfc-79/rfc-79.md) | :x: `ABANDONED` | +| 80 | [Column Groups](./rfc-80/rfc-80.md) | :hammer_and_wrench: `IN PROGRESS` | +| 81 | [Introduce Primary Key Sorted Table](./rfc-81/rfc-81.md) | :eyes: `UNDER REVIEW` | +| 82 | [Concurrent schema evolution detection](./rfc-82/rfc-82.md) | :white_check_mark: `COMPLETED` | +| 83 | [Incremental Table Service](./rfc-83/rfc-83.md) | :white_check_mark: `COMPLETED` | +| 84 | [Optimized SerDe of `DataStream` in Flink operators](./rfc-84/rfc-84.md) | :white_check_mark: `COMPLETED` | +| 85 | [Hudi Issue and Sprint Management in Jira](./rfc-85/rfc-85.md) | :white_check_mark: `COMPLETED` | +| 86 | [DataFrame Implementation of HUDI write path](./rfc-86/rfc-86.md) | :eyes: `UNDER REVIEW` | +| 87 | [Avro elimination for Flink writer](./rfc-87/rfc-87.md) | :hammer_and_wrench: `IN PROGRESS` | +| 88 | [New Schema/DataType/Expression Abstractions](./rfc-88/rfc-88.md) | :eyes: `UNDER REVIEW` | +| 89 | [Dynamic Partition Level Bucket Index](./rfc-89/rfc-89.md) | :eyes: `UNDER REVIEW` | +| 90 | Add support for cancellable clustering table service plans | :eyes: `UNDER REVIEW` | +| 91 | [Storage-based lock provider using conditional writes](./rfc-91/rfc-91.md) | :white_check_mark: `COMPLETED` | +| 92 | Support Bitmap Index | :hammer_and_wrench: `IN PROGRESS` | +| 93 | [Pluggable Table Formats in Hudi](./rfc-93/rfc-93.md) | :hammer_and_wrench: `IN PROGRESS` | +| 94 | Hudi Timeline User Interface (UI) | :eyes: `UNDER REVIEW` | +| 95 | [Hudi Flink Source Based on FLIP-27](./rfc-95/rfc-95.md) | :white_check_mark: `COMPLETED` | +| 96 | Introduce Unified Bucket Index | :eyes: `UNDER REVIEW` | +| 97 | Deprecate Hudi Payload Class Usage | :eyes: `UNDER REVIEW` | +| 98 | [Spark Datasource V2 Read](./rfc-98/rfc-98.md) | :eyes: `UNDER REVIEW` | +| 99 | [Hudi Type System Redesign](./rfc-99/rfc-99.md) | :eyes: `UNDER REVIEW` | +| 100 | [Unstructured Data Storage in Hudi](./rfc-100/rfc-100.md) | :eyes: `UNDER REVIEW` | +| 101 | [Updates to the HoodieRecordMerger API](./rfc-101/rfc-101.md) | :hammer_and_wrench: `IN PROGRESS` | +| 102 | [Spark Batch Vector Search in Apache Hudi](./rfc-102/rfc-102/md) | :white_check_mark: `COMPLETED` | +| 103 | Hudi LSM tree layout | :eyes: `UNDER REVIEW` | +| 104 | [Unify schema evolution on schema-on-read](./rfc-104/rfc-104.md) | :eyes: `UNDER REVIEW` | +| 105 | [Trino Hudi Connector — Shim/Bundle Refactor](./rfc-105/rfc-105.md) | :eyes: `UNDER REVIEW` | +| 106 | [Record Level and Secondary Index Support for Flink Writers](./rfc-106/rfc-106.md) | :white_check_mark: `COMPLETED` | +| 107 | Dynamic Partitioned Cache for Flink upsert | :hammer_and_wrench: `IN PROGRESS` | +| 108 | [Multi-dataset incremental reads in Hudi Streamer](./rfc-108/rfc-108.md) | :eyes: `UNDER REVIEW` | +| 109 | Hudi Native Vector Index | :eyes: `UNDER REVIEW` | diff --git a/rfc/rfc-105/rfc-105.md b/rfc/rfc-105/rfc-105.md new file mode 100644 index 0000000000000..51c7aef2fb902 --- /dev/null +++ b/rfc/rfc-105/rfc-105.md @@ -0,0 +1,225 @@ + +# RFC-105: Trino Hudi Connector — Shim/Bundle Refactor + +## Proposers + +- @yihua +- @voonhous + +## Approvers + +- @codope +- @vinothchandar + +## Status + +Issue: [HUDI-18780](https://github.com/apache/hudi/issues/18780) + +> Please keep the status updated in `rfc/README.md`. + +## Motivation + +The Trino-Hudi connector currently lives in `trinodb/trino` at `plugin/trino-hudi`. Maintaining and evolving the connector through the Trino-OSS-only path has stalled in practice, and the cost falls on Hudi users: + +- **Hudi-side improvement PRs to the Trino Hudi connector are not landing.** Four stacked PRs targeting the Trino Hudi connector were closed by Trino's stale-bot for lack of review: + - [trinodb/trino#28518](https://github.com/trinodb/trino/pull/28518) + - [trinodb/trino#28533](https://github.com/trinodb/trino/pull/28533) + - [trinodb/trino#28644](https://github.com/trinodb/trino/pull/28644) + - [trinodb/trino#28645](https://github.com/trinodb/trino/pull/28645) +- **Significant Hudi-side work for the Trino connector is ready but cannot land** through the current path: metadata-table-driven partition listing, eight `HudiIndexSupport` strategies (column stats, partition stats, record-level, secondary, expression, bloom, bucket, partition bloom), MOR snapshot-isolation fixes (worker-side use of the latest commit time from the table handle), and file-system caching integration. +- **The current arrangement does not scale.** Connector evolution must go through Trino-side review for every change, while the expertise and the source-of-truth for Hudi internals live in this project. Hudi releases cannot directly deliver improvements to Hudi users querying via Trino. + +Following alignment between the Hudi and Trino communities, the agreed direction is to split the connector into a thin Trino-side shim plus a Hudi-published artifact carrying the connector logic. This lets the Hudi project ship Trino-Hudi improvements with each Hudi release, while Trino picks them up via a one-line dependency-version bump. + +The single requirement carried over from the Trino side is that a comprehensive test suite for the connector continues to be maintained on the Trino side. This RFC documents the agreed approach and the implementation plan. + +## Abstract + +We split the Trino-Hudi connector into two Maven artifacts: + +1. **`io.trino:trino-hudi`** stays in Trino OSS (`plugin/trino-hudi`) as a thin shim — a `HudiPlugin` class that registers the `io.trino.spi.Plugin` SPI entry point — plus the test harness (smoke tests, query runners, MinIO-backed integration tests). This module mostly does not change once landed. +2. **`org.apache.hudi:hudi-trino`** is a new Hudi-published Maven artifact (regular, non-shaded JAR) containing the actual connector logic at `io.trino.plugin.hudi.*` — `HudiConnectorFactory`, `HudiConnector`, `HudiMetadata`, `HudiSplitManager`, `HudiPageSourceProvider`, all index-support strategies, the `HoodieStorage`/`HoodieIOFactory` bridges to Trino's filesystem, etc. The artifact is built against the latest Trino release's SPI; it declares `hudi-common`, `hudi-io`, etc. as transitive dependencies and Trino's `trino-spi`, `trino-filesystem`, etc. as `provided`. + +The first publication ships in **Hudi 1.3.0**. The Trino-side shim PR pins `org.apache.hudi:hudi-trino:1.3.0`. Going forward, all Trino-Hudi connector evolution happens in Hudi OSS; Trino picks up changes by bumping the dependency version. To support this integration model, **Hudi will increase its release cadence**. + +## Background + +### State of the Trino-side connector today + +`plugin/trino-hudi` in `trinodb/trino` is the baseline: it implements the standard Trino SPI (`Plugin`, `ConnectorFactory`, `Connector`, `ConnectorMetadata`, `ConnectorSplitManager`, `ConnectorPageSourceProvider`, etc.), depends on `hudi-common` and `hudi-io`, and uses Hudi's `HoodieStorage` abstraction (RFC-74) over Trino's `TrinoFileSystem`. No direct Hadoop imports. + +### State of the Hudi-side `hudi-trino-plugin` work + +A more advanced version of the connector exists in Hudi-side branches under `hudi-trino-plugin/` (same `io.trino.plugin.hudi.*` package, built against a recent Trino release). On top of the Trino-OSS baseline it adds: + +- Eight `HudiIndexSupport` strategies (column stats, partition stats, record-level, secondary, expression, bloom, bucket, partition bloom) for file- and partition-level pruning via metadata tables. +- Metadata-table-driven partition discovery (async, resumable). +- MOR record-level merging via `HoodieFileGroupReader` (`HudiTrinoReaderContext`). +- Lazy commit-time on `HudiTableHandle` for snapshot-isolated MOR reads across workers. +- Background, weighted split generation; size-based split weighting; multi-reader routing (`HudiPageSource` for MOR, `HudiBaseFileOnlyPageSource` for COW/RO). +- File-system cache integration. +- HoodieStorage / HoodieIOFactory bridges over `TrinoFileSystem` (`HudiTrinoStorage`, `HudiTrinoInlineStorage`, `HudiTrinoIOFactory`). + +This is the body of code that will move into the `hudi-trino` Maven module on the Hudi side. + +### Why a "shim + Hudi-published artifact" pattern + +This pattern decouples Trino-Hudi connector evolution from the Trino-side release cycle: + +- The Hudi project can publish Trino-Hudi improvements with each Hudi release, without waiting for Trino-side reviews of every change. +- The Trino-side surface shrinks to a stable plugin-registration shim, so Trino-side review burden is minimal — typically a one-line version bump per Hudi release. +- All Hudi-Trino integration code (`io.trino.plugin.hudi.*`) is co-located with the Hudi core libraries it depends on. Changes that cross the Hudi-internal / connector boundary can land atomically. +- The artifact is **purpose-built for Trino** and implements Trino's SPI directly, so no intermediate adapter layer is needed between the published artifact and the Trino plugin. + +Trino's `trino-spi` is governed by `revapi-maven-plugin` (see `core/trino-spi/pom.xml`) which enforces backward compatibility on the SPI surface. This is what makes a single `hudi-trino` artifact targeting the latest Trino release viable across multiple subsequent Trino releases. + +Trino loads each plugin in an isolated `URLClassLoader`. Transitive dependencies of `hudi-trino` (Avro, Parquet, etc.) are isolated to the plugin's classloader and cannot conflict with other plugins. + +## Implementation + +### Architecture + +``` +trinodb/trino : plugin/trino-hudi (packaging = trino-plugin) + HudiPlugin.java ← thin shim: trivial Plugin SPI registration + META-INF/services/io.trino.spi.Plugin + src/test/java/... ← full Trino-side test suite + pom.xml ← depends on org.apache.hudi:hudi-trino:1.3.0 + │ + │ Maven Central + ▼ +apache/hudi : hudi-trino-plugin/ (Maven profile -Phudi-trino, + excluded from default reactor, + JDK 25 required) + io.trino.plugin.hudi.* ← all connector logic: + HudiConnectorFactory, HudiConnector, HudiMetadata, + HudiSplitManager, HudiPageSourceProvider, + cache/, file/, io/, partition/, + query/ (incl. 8 index-support strategies), + reader/, split/, stats/, storage/, util/ + src/test/java/... ← full duplicated + expanded suite + Published as: org.apache.hudi:hudi-trino:1.3.0 +``` + +### What lives where + +#### Trino-side `plugin/trino-hudi` (the shim) + +| File | Purpose | +|---|---| +| `src/main/java/io/trino/plugin/hudi/HudiPlugin.java` | Implements `io.trino.spi.Plugin`. Single method returning `new HudiConnectorFactory()` (from the `hudi-trino` artifact). ~10 lines. | +| `src/main/resources/META-INF/services/io.trino.spi.Plugin` | Service-loader pointer to `io.trino.plugin.hudi.HudiPlugin`. | +| `pom.xml` | `trino-plugin`; pins `org.apache.hudi:hudi-trino:`; SPI deps as `provided`. | +| `src/test/java/...` | All current Trino-side tests stay: `HudiQueryRunner`, `TestHudiSmokeTest`, `TestHudiMinioConnectorSmokeTest`, `TestHudiConnectorTest`, `TestHudiSharedMetastore`, `TestHudiSystemTables`, `TestHudiPlugin`, `TestHudiConfig`, plus data initializers. Required by the Trino-side test-coverage commitment. | + +#### Hudi-side `hudi-trino-plugin/` (the engine) + +Everything else from the current `hudi-trino-plugin/` work, organized exactly as it is today: + +| Subpackage | Responsibility | +|---|---| +| `io.trino.plugin.hudi` | `HudiConnectorFactory`, `HudiConnector`, `HudiMetadata`, `HudiSplitManager`, `HudiPageSourceProvider`, `HudiSplit`, `HudiTableHandle`, `HudiModule`, `HudiConfig`, `HudiSessionProperties`, `HudiTableProperties`, `HudiTransactionManager`, `HudiMetadataFactory`. | +| `.cache` | `HudiCacheKeyProvider` for file-system cache integration. | +| `.file` | `HudiBaseFile`, `HudiLogFile`, file metadata abstractions. | +| `.io` | `HudiTrinoIOFactory` (extends `HoodieIOFactory`), `HudiTrinoFileReaderFactory`, `TrinoSeekableDataInputStream`. | +| `.partition` | `HudiPartitionInfo`, `HiveHudiPartitionInfo`, `HudiPartitionInfoLoader` (async resumable task). | +| `.query` | `HudiDirectoryLister`, `HudiReadOptimizedDirectoryLister`, `HudiSnapshotDirectoryLister`; `query.index` package with 8 `HudiIndexSupport` strategies. | +| `.reader` | `HudiTrinoReaderContext extends HoodieReaderContext` for MOR record merging. | +| `.split` | `HudiSplitFactory`, `HudiBackgroundSplitLoader`, `HudiSplitSource`, `HudiSplitWeightProvider`, `SizeBasedSplitWeightProvider`. | +| `.stats` | `HudiTableStatistics`, `TableStatisticsReader`. | +| `.storage` | `HudiTrinoStorage` (extends `HoodieStorage`), `HudiTrinoInlineStorage`, `TrinoStorageConfiguration`. | +| `.util` | Serialization helpers, column synthesis, tuple-domain conversion, table-type utilities. | + +### API boundary + +The boundary between the shim and the published artifact is **Trino's SPI itself** — no intermediate API layer is introduced. + +- **Shim → artifact:** `HudiPlugin.getConnectorFactories()` returns `new HudiConnectorFactory()` defined in the artifact. Trino's runtime then calls `factory.create(catalogName, config, context)`. The `ConnectorContext` argument carries everything the artifact needs — `TypeManager`, `NodeManager`, `MetadataProvider`, `PageSorter`, `PageIndexerFactory`, `OpenTelemetry`, `Tracer`, `CatalogHandle` — without the artifact importing implementation classes. +- **Artifact → Trino:** the artifact's `HudiConnector` exposes the standard SPI providers (`ConnectorMetadata`, `ConnectorSplitManager`, `ConnectorPageSourceProvider`, etc.). Trino calls these. Classloader context is handled by the standard `ClassLoaderSafe*` wrappers (`io.trino.plugin.base.classloader.*`) — already used today. + +### Maven dependencies for `hudi-trino` + +- **`compile`:** Hudi libs (`hudi-common`, `hudi-io`, `hudi-hive-sync`, `hudi-sync-common`) and Trino libs (`trino-filesystem`, `trino-hive`, `trino-metastore`, `trino-parquet`, `trino-cache`), Guice, Airlift, Caffeine. +- **`provided`:** `trino-spi`, `slice`, Jackson, OpenTelemetry API, JOL (supplied by Trino at runtime). +- **`runtime`:** log-manager, Dropwizard metrics, OpenTelemetry SDK, `trino-hive-formats`. +- **`test`:** Trino testing libs (`trino-testing`, `trino-main`, `trino-testing-containers`, `trino-hdfs`), AssertJ, JUnit 5, Hudi test JARs. + +**Version alignment policy.** Trino versions are authoritative for shared libraries (Avro, Parquet, Jackson, Airlift). The `hudi-trino` POM pins these via `` to whatever the targeted Trino release uses. If Hudi internals need a newer version, the fix is on the Hudi side or via a Trino-version bump — never by shipping divergent classpath versions. + +### Build target on Hudi side + +Trino requires Java 25, while the rest of Hudi targets a lower Java floor. `hudi-trino-plugin` therefore lives behind a Maven profile (`-Phudi-trino`) and is **excluded from the default `mvn install` reactor**: + +```xml + + hudi-trino + + hudi-trino-plugin + + +``` + +Default build (`mvn install`) skips it; Trino-targeted build (`mvn install -Phudi-trino`) requires JDK 25. + +### CI + +Two new GitHub Actions on the Hudi side, required for any change touching `hudi-trino-plugin/**`: + +1. **`hudi-trino-ci.yml`** — runs the full test suite via `mvn verify -Phudi-trino` on JDK 25. Catches regressions before they ship in a Hudi release. +2. **`hudi-trino-compat.yml`** — nightly: pulls latest `trinodb/trino` master and latest `apache/hudi` master, builds Trino's relevant modules, then compiles `hudi-trino-plugin` against them and runs the `hudi-trino-plugin` test suite. Catches both SPI drift and behavioral incompatibilities before the next Trino release. + +On the Trino side, existing CI continues to build and test `plugin/trino-hudi`, exercising the published `hudi-trino` artifact end-to-end on every Trino PR. + +### Test strategy + +**Full test duplication.** The Trino-side smoke tests (`TestHudiSmokeTest`, `TestHudiMinioConnectorSmokeTest`, `TestHudiConnectorTest`, etc.) are mirrored on the Hudi side and additionally extended. + +- **Trino side runs them** on every Trino PR — fulfilling the Trino-side test-coverage commitment. +- **Hudi side runs them** on every Hudi PR touching `hudi-trino-plugin` — so Hudi contributors catch regressions before they ship in a Hudi release. The Hudi-side suite is also **expanded** with more granular unit tests covering split generation edge cases, all eight index-support strategies, the MOR record-merging path, lazy-commit-time snapshot isolation, and the cache-key provider. + +This duplication has a known cost — two places to update when adding tests — but is the right trade-off given: +- The Trino-side suite must remain comprehensive as agreed with the Trino community. +- Hudi-side contributors need fast feedback without waiting for a Trino-side PR cycle. + +### Risks & caveats + +- **Trino SPI drift.** A future Trino SPI change could break the pre-built `hudi-trino` artifact at runtime. Mitigation: the nightly compat CI compiles and runs the `hudi-trino-plugin` test suite against Trino master, flagging both compile-time and behavioral incompatibilities before a Trino release ships. +- **Avro / Parquet / Jackson version skew.** Resolved by policy: Trino's versions are authoritative, pinned via `` in the `hudi-trino` POM. Hudi-side fixes or Trino-version bumps adjust to it. +- **Test-infrastructure coupling.** `hudi-trino-plugin`'s test scope depends on `trino-testing`, `trino-main`, etc., coupling the Hudi build to Trino artifacts on Maven Central. Acceptable cost. +- **Release coordination.** A critical fix in `hudi-trino` ships only via a Hudi release. Mitigation: keep the Trino-side shim trivial so virtually all fixes can land in `hudi-trino`, and increase Hudi release cadence. +- **License / ASF process.** Cross-project releases between two ASF projects; covered by standard PMC announcements at first release. + +## Rollout/Adoption Plan + +**Step 1 — Hudi 1.3.0 publishes `hudi-trino`.** Land the `hudi-trino-plugin` work in `apache/hudi` master behind the `-Phudi-trino` profile, land the two CI workflows, then publish `org.apache.hudi:hudi-trino:1.3.0` to Maven Central as part of the 1.3.0 release. Hudi commits to a more frequent release cadence going forward: to start, a major release roughly every month, with more frequent minor releases to stabilize this module. A `hudi-trino` artifact is cut whenever there are enough improvements to ship to Trino users. Cadence is owned by the Hudi release cycle; Trino releases are not blocked on it. + +**Step 2 — Trino-side shim PR.** A small PR against `trinodb/trino` that replaces the contents of `plugin/trino-hudi/src/main/java/io/trino/plugin/hudi/` with a single `HudiPlugin.java`, keeps `META-INF/services/io.trino.spi.Plugin` and all current tests, and adds `org.apache.hudi:hudi-trino:1.3.0` as a `compile` dependency. The PR is small by design — deletes connector code, points at the published artifact — so Trino-maintainer review burden is minimal. + +**Step 3 — Steady state.** Trino-Hudi feature work and bug fixes happen on the Hudi side. Each Hudi release publishes a new `hudi-trino` artifact. Trino picks up changes by bumping the pinned version — a one-line PR per release. Because Trino pins `hudi-trino` as a compile-time dependency, the `hudi-trino` ↔ Trino release mapping is **one-to-one**: each `hudi-trino` version is the version that ships embedded in a specific Trino release. This one-to-one mapping makes feature support and bug-fix tracking easy to reason about for both users and maintainers. Hudi publishes and maintains this version-compatibility table on the Hudi website (hudi.apache.org), and the Trino-side `plugin/trino-hudi` README links to it as the single source of truth. + +**Impact on existing users.** No behavioral change: same `HudiPlugin` registration, same catalog config. The first Trino release picking up `hudi-trino:1.3.0` gains the features that the previously-stalled PRs covered (metadata-table partition listing, index support, MOR snapshot-isolation correctness, file-system caching, advanced split generation). No migration tools needed. + +## Test Plan + +The RFC is validated when: + +- [ ] `hudi-trino-plugin` builds and its full test suite passes on the Hudi side via `mvn verify -Phudi-trino` (JDK 25), covering smoke tests, MinIO/Alluxio caching tests, MOR/COW tests, page source tests, split-factory tests, index support tests, system-table tests, and plugin/config tests. +- [ ] The `hudi-trino-compat.yml` workflow compiles `hudi-trino-plugin` against `trinodb/trino` master successfully. +- [ ] `org.apache.hudi:hudi-trino:1.3.0` is published to Maven Central. +- [ ] The Trino-side shim PR is green: Trino's CI for `plugin/trino-hudi` passes against the published `1.3.0` artifact, including MinIO/S3 integration and plugin-loading tests. +- [ ] At least one subsequent Hudi-side patch release exercises the "bump version in Trino" steady-state flow end-to-end. diff --git a/rfc/rfc-106/index-compaction-flow.png b/rfc/rfc-106/index-compaction-flow.png new file mode 100644 index 0000000000000..a81e1d4f1f239 Binary files /dev/null and b/rfc/rfc-106/index-compaction-flow.png differ diff --git a/rfc/rfc-106/index-write-flow.png b/rfc/rfc-106/index-write-flow.png new file mode 100644 index 0000000000000..6259071a55f64 Binary files /dev/null and b/rfc/rfc-106/index-write-flow.png differ diff --git a/rfc/rfc-106/rfc-106.md b/rfc/rfc-106/rfc-106.md new file mode 100644 index 0000000000000..d344ccf579f9d --- /dev/null +++ b/rfc/rfc-106/rfc-106.md @@ -0,0 +1,260 @@ + +# RFC-106: Record Level and Secondary Index Support for Flink Writers + +## Proposers + +- @danny0405 + +## Approvers + - @geserdugarov + - @vinothchandar + - @cshuo + +## Status + +GH Discussion: https://github.com/apache/hudi/discussions/17452 + +> Please keep the status updated in `rfc/README.md`. + +## Abstract + +Apache Hudi provides multiple indexing strategies to efficiently locate records during upsert operations. +The **Record Level Index (RLI)** is a global index stored in Hudi's **Metadata Table (MDT)** that maps each record key to its +exact file group location, enabling O(1) lookups. The **Secondary Index (SI)** extends this capability to non-record-key, non-unique-key columns. +Currently, Spark reads/writes support RLI & SI while Flink does not, creating feature disparity between the two engines for Hudi table reads and writes. + +This RFC proposes adding RLI and SI support for Flink streaming writes. Throughout this document, the term **"index"** refers broadly to both RLI and SI; +when discussing behavior specific to one type, the terms "RLI" or "SI" will be used explicitly. + +The goals of this RFC are: + +- Provide reliable and performant write support for RLI/SI using Flink APIs +- Ensure cross-engine compatibility so that Flink can access and utilize indexes written by Spark, and vice versa +- Support global RLI for cross-partition upserts, as well as partition-level RLI for large fact tables +- Enable asynchronous compaction for MDT when indexing is enabled, either within the writer pipeline or via background table services +- Implement smart caching of index data for low-latency access during streaming writes +- Document scale and performance limits for write throughput supported by indexing (based on empirical benchmarks) +- Design the implementation to be extensible for arbitrary secondary indexing on different columns + +## Background + +Apache Hudi uses indexes to determine the location of existing records when processing upserts. Without an efficient index, Hudi would +need to scan the entire table to find whether a record already exists and where it is located. Different index types offer different +trade-offs between write performance, read performance, and resource consumption. + +Currently, Flink Hudi sink does not support RLI or SI, while Hudi Spark datasource does and proven at [massive production scale](https://hudi.apache.org/blog/2023/11/01/record-level-index/). +This inconsistency causes friction for users who migrate tables from Spark to Flink streaming. When migrating, users must switch +the index type from RLI/SI to either `bucket` (a hash-based partitioning scheme) or `flink_state` (which uses Flink's state backend to +maintain record-to-location mappings). This migration overhead complicates production deployments. + +Another key motivation is to provide scalable, efficient support for **cross-partition updates**—scenarios where a record's partition path changes between writes. +Currently, the only option for handling cross-partition updates in Flink is the `flink_state` index, which maintains a global view of all record locations. However, this approach has significant drawbacks: +it consumes substantial memory (proportional to the table size) and cannot be shared across different workloads or job restarts without state migration. + +## High Level Design + +The high-level design introduces the following components: + +- **MDT-based Index backend**: A new index implementation that can replace the current `flink_state` index, storing record-to-location mappings in the MDT rather than in Flink's state backend +- **Index cache with invalidation**: An in-memory cache to accelerate RLI lookups, along with a cache invalidation mechanism to maintain consistency with the committed state of the table +- **New Flink Index operator**: A separate Flink operator (`IndexWrite`) responsible for writing RLI/SI payloads to the MDT +- **Synchronous MDT writes**: The MDT's RLI and SI files are written synchronously with the data table files within the same commit boundary; the metadata is then sent to the coordinator for a final commit to the MDT (after the `FILES` partition update is computed) +- **Asynchronous MDT compaction**: MDT compaction is performed asynchronously, reusing the existing data file compaction pipeline to minimize task slot consumption + +![Index Write Flow](./index-write-flow.png) + +### Detailed Design + +### The Index Access + +In Hudi's Flink integration, the `BucketAssigner` operator is responsible for determining where each incoming record should be written. +It must identify whether each record is an insert (new record), update (existing record), or delete. To make this determination, the operator needs to look up whether +the record key already exists in the table and, if so, where it is located. + +With index support, the `BucketAssigner` operator will use the index metadata stored in the MDT as its backend. It will probe the index +with incoming record keys to determine the appropriate operation type (insert, update, or delete). In this design, the index serves the same role +that the `flink_state` index currently serves. Since the existing `BucketAssigner` already supports both **global** and **non-global** index types, +the global RLI will be used for **global** index configurations, while partitioned RLI will be used for **non-global** configurations. + +To optimize index access patterns and avoid caching all index shards in every `BucketAssigner` task, the input records will be shuffled +by `hash(record_key) % num_index_shards`. This uses the same hashing algorithm as the MDT's index partitioner, ensuring that +each `BucketAssigner` task only needs to read from a subset of index shards. + +#### Index Cache + +Streaming workloads require low-latency processing of each record to achieve high throughput. Thus, each record lookup against the index +should complete really fast. Reading a RLI entry each time for each record will incur 10+ms of latency per record and seriously affect throughput. + +**New index mappings cache:** Additionally, a separate memory cache is needed for index mappings created during the current checkpoint. +These mappings are not yet committed to the Hudi table and are therefore invisible to MDT queries. This cache must not be cleared until the +corresponding checkpoint/instant is committed to Hudi, which indicates that the index payloads have also been committed. This ensures multiple +records for the same record key (e,g insert to a key, followed by an update within the same commit boundary) are routed consistently to same +file group, preserving the 1:1 mapping from record key to file group. + +The cache stores `key -> location` mappings at the record level, the items are evicted by checkpoint level when the checkpoints are committed to Hudi. +(Note that the MDT reader also maintains its own native file-level cache.) + +The actual index writes occur in the `IndexWrite` operator and the location from the cache will be propagated downstream from the `BucketAssigner` operator, where cache lookups and MDT queries to determine record locations. +The cache is updated for new records and location changes, while the MDT is queried only for existing key locations. + +The cache update flow is as follows: + +1. Probe the cache for the key. If found, update the cache entry if the location has changed. +2. If the key is not in the cache, fall back to querying the MDT. If the key exists in the MDT, add it to the cache with its location. +3. If the key does not exist in the MDT either, add the new key and its assigned location to the cache. + +#### Index Access Consistency On Fail Cases + +Hudi uses a two-phase commit protocol where each Flink checkpoint corresponds to a Hudi completed instant. During a checkpoint, Flink completes the data writes and collects the Hudi commit metadata. +Once the checkpoint acknowledgment event is received, Flink knows the checkpoint completed successfully, and the corresponding Hudi instant can be committed. +However, the acknowledgment message is sent asynchronously on a best-effort basis and may be lost in corner cases. A Hudi instant cannot be committed without receiving this acknowledgment. + +During job restarts or task failover, there are scenarios where a Flink checkpoint succeeds but the corresponding Hudi instant remains uncommitted due to the two-phase commit mechanism: + +1. **Missing acknowledgment**: The acknowledgment message is lost entirely. +2. **Job restart during commit**: The acknowledgment is received, but the job restarts during the instant commit process: + 1. All write metadata from a checkpoint is collected in the coordinator and is ready for committing. + 2. The acknowledgment message is received. + 3. The coordinator begins committing the instant with the collected write metadata. + 4. The job restarts or crashes. + 5. The instant remains uncommitted even though the checkpoint succeeded. +3. **Task failover before acknowledgment**: A task fails over from a checkpoint before its acknowledgment is received: + 1. All write metadata from checkpoint `ckp_n` is collected in the coordinator and is ready for committing. + 2. The checkpoint succeeds, but the acknowledgment has not been received yet. + 3. A task fails and recovers from `ckp_n`. + 4. The instant remains uncommitted even though the checkpoint succeeded. + 5. The acknowledgment message is eventually received. + 6. During the gap between steps 4 and 5, the `BucketAssigner` must access the uncommitted instant because the checkpoint was successful and the data is valid. +4. **Task failover after acknowledgment but before commit completion**: A task fails over after the acknowledgment is received but before the instant commit completes: + 1. All write metadata from checkpoint `ckp_n` is collected in the coordinator and is ready for committing. + 2. The acknowledgment message is received. + 3. The coordinator begins committing the instant with the collected write metadata. + 4. A task fails and recovers from `ckp_n`. + 5. The instant remains uncommitted even though the checkpoint succeeded. + 6. Step 3 eventually completes and the instant is committed. + 7. During the gap between steps 4 and 6, the `BucketAssigner` must access the uncommitted instant because the checkpoint was successful and the data is valid. + +For data table (DT) metadata, the pipeline will recommit the instant using the recovered table metadata. However, since the `BucketAssigner` operator is upstream of the `StreamWrite` operator, there is a time gap before these inflight instants can be recommitted. +The gap between Flink checkpoint and Hudi instant commit will incur consistency issue on index read view, it is fixed by cases: + +- on task failover: the coordinator would recommit the pending instants with successful Flink checkpoints; +- on job restart: trigger an explicit job failover from coordinator after the recovered pending instant been recommitted. + +#### Add Event Time Ordering Value for RLI Payload +For cross-partition updates or deletes, we can not update the RLI directly based on the existing key-location mappings. Currently, the RLI payload only has the key to location mappings without actual ordering value. +we need to merge the data records to see if the incoming record is a valid update or delete(for valid, it means greater ordering value), that is the behavior for Spark RLI write path, but it is too costly for streaming. + +For e.g, for two records `r1:{key: k1, orderingValue: 2, partition: par1}` and `r2:{key: k1, orderingValue: 1, partition: par2}`, `r2` comes behind `r1` in a different commit, +comparison of just key existence is not enough, we need to also compare the ordering value to see that `r2` is not a valid update and +not sending the retraction record(delete record) into partition `par1` for payload delete. + +The suggested solution is to store the ordering value into the RLI payload, so that we can compare the ordering value when there is a match of exiting key lookup, to make the decision whether the incoming record is a valid +upserts or not. + +The query execution follows this order: first access the in-memory cache, then query the MDT index: + +![The RLI Access Pattern](./rli-access-pattern.png) + +### Shuffling Index Records + +In the `StreamWrite` operator, index records are derived from incoming data records and sent to the `IndexWrite` operator in a streaming +fashion. These index records are shuffled by `hash(record_key) % num_index_shards`, using the same hashing algorithm as the MDT's +index partitioner. This shuffling strategy is critical for avoiding a combinatorial explosion of files written to the MDT partition. +Without it, the number of files would be `N * M`, where `N` is the number of index partition buckets and `M` is the number of data table +buckets involved in the current write. + +To ensure that each data record and its corresponding index record always belong to the same commit/checkpoint, we leverage +Flink's barrier alignment mechanism. In Flink, checkpoint barriers flow together with records through the pipeline (see [how-does-state-snapshotting-work](https://nightlies.apache.org/flink/flink-docs-master/docs/learn-flink/fault_tolerance/#how-does-state-snapshotting-work)). +When the `StreamWrite` operator receives a record, it emits both the data record and its corresponding index record within a single `#processElement` call. +This ensures that the two records are never separated by a checkpoint barrier. + +For example: + +```text +// irN means an index record, drN means a data record +e.g: [r4 r3 r2 r1 ] => BucketAssignor => [ ir4 dr4 ir3 dr3 ir2 dr2 ir1 dr1] +``` + +The barrier propagation algorithm prevents the checkpoint barrier from being placed between an index record and its corresponding data record. +A placement like `[ ir4 dr4 ir3 dr3 ir2 dr2 ir1 dr1]` cannot occur. + +### The Index Write + +In the `IndexWrite` operator, index records are buffered and then written to the MDT when triggered by a Flink checkpoint. +The write status metadata is then sent to the `coordinator`. This metadata includes two parts: + +- **A**: The written data file paths +- **B**: The written MDT file paths (specifically those under the index partitions) + +#### Committing MDT (including Index Partitions) + +When committing to the data table, the MDT is committed first with the index write metadata (the MDT index partition file handles). +The `RLI` and `SI` partition file handles are committed together in the `FILES` partition. + +During a Flink checkpoint, each index-writing and data-writing task flushes all its records to the index and data files respectively. +This ensures that the index and data files are always consistent. Both are committed together from the Coordinator as a single Hudi commit, +following the current commit protocol. + +To maintain exactly-once semantics during job recovery, the write status metadata must be stored in multiple locations: the `StreamWrite` operator, the `IndexWrite` operator, and the `coordinator`. +This follows the same pattern as the current approach for maintaining data table metadata. + +### The Compaction + +To minimize task slot consumption, the implementation reuses the existing data file compaction sub-pipeline for MDT compaction. +This asynchronous compaction is automatically enabled when indexing is active. + +![Index Compaction Flow](./index-compaction-flow.png) + +## Implementation Plan + +The umbrella issue: [Support record index for Flink writer](https://github.com/apache/hudi/issues/17647) + +- [Add RLI access cache to support efficient lookup](https://github.com/apache/hudi/issues/17697) +- [Adapter the BucketAssign function with the MDT backed index](https://github.com/apache/hudi/issues/17699) +- [Support mini-batch access to the MDT index for bucket assign function](https://github.com/apache/hudi/issues/17842) +- [Add basic infra to bookeep the mappings between checkpoint id to instant](https://github.com/apache/hudi/issues/17700) +- [Add a new index write function](https://github.com/apache/hudi/issues/17701) +- [Integrate the MDT compaction with existing compaction sub-pipeline and offline job](https://github.com/apache/hudi/issues/17702) + +## Rollout/Adoption Plan + + - What impact (if any) will there be on existing users? + - No impact because this is a new feature. Existing Flink users can continue using their current index types, and + adoption of RLI/SI is optional. + +## Test Plan + +1. Verify that all write and read scenarios work correctly with indexing enabled. +2. Validate that asynchronous compaction works correctly with mixed DT/MDT workloads. +3. Add new tests to cover job recovery scenarios with uncommitted DT/MDT commits. +4. Conduct benchmarks to determine the upper throughput threshold at which indexing is recommended for streaming workloads. + +## Appendix + +### The Job/Task failover + +To maintain exactly-once semantics, the implementation includes infrastructure to support job and task recovery: + +1. In the `coordinator`, historical uncommitted metadata is persisted in the checkpoint state. +2. In the `StreamWrite` operator, the current write metadata list is persisted in the checkpoint state. + +When the job is restarted, the following steps are triggered to recover uncommitted instants: + +1. The `StreamWrite` operator checks for pending instants in the checkpoint state and resends an event to the `coordinator` to collect the uncommitted metadata. +2. The `coordinator` recovers its write metadata list from the checkpoint state. +3. The `coordinator` recommits the uncommitted instants using the combined metadata from steps 1 and 2. diff --git a/rfc/rfc-106/rli-access-pattern.png b/rfc/rfc-106/rli-access-pattern.png new file mode 100644 index 0000000000000..00888f9c5a4a9 Binary files /dev/null and b/rfc/rfc-106/rli-access-pattern.png differ diff --git a/scripts/pr_compliance.py b/scripts/pr_compliance.py index aafeb9c2a0d0d..5bf1a23b69e19 100644 --- a/scripts/pr_compliance.py +++ b/scripts/pr_compliance.py @@ -46,7 +46,7 @@ class Outcomes: #parsing the next section NEXTSECTION = 2 - #parsing has concluded succesfully, exit with no error + #parsing has concluded successfully, exit with no error SUCCESS = 3 diff --git a/scripts/release/deploy_staging_jars_java25.sh b/scripts/release/deploy_staging_jars_java25.sh new file mode 100755 index 0000000000000..bd58f63b08a59 --- /dev/null +++ b/scripts/release/deploy_staging_jars_java25.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +## +## Variables with defaults (if not overwritten by environment) +## +MVN=${MVN:-mvn} +# fail immediately +set -o errexit +set -o nounset + +CURR_DIR=$(pwd) +if [ ! -d "$CURR_DIR/packaging" ] ; then + echo "You have to call the script from the repository root dir that contains 'packaging/'" + exit 1 +fi + +# Validate Java version (hudi-trino requires Java 25) +EXPECTED_JAVA_VERSION=25 +JAVA_VERSION_OUTPUT=$("${JAVA_HOME:+$JAVA_HOME/bin/}java" -version 2>&1) +JAVA_MAJOR_VERSION=$(echo "$JAVA_VERSION_OUTPUT" | awk -F[\".] '/version/ {print ($2 == "1" ? $3 : $2); exit}') +if [ "$JAVA_MAJOR_VERSION" != "$EXPECTED_JAVA_VERSION" ]; then + echo "Error: Java $EXPECTED_JAVA_VERSION is required for this script, but found:" + echo "$JAVA_VERSION_OUTPUT" + echo "Set JAVA_HOME to a Java $EXPECTED_JAVA_VERSION installation and retry." + exit 1 +fi + +if [ "$#" -gt "1" ]; then + echo "Only accept 0 or 1 argument. Use -h to see examples." + exit 1 +fi + +declare -a ALL_VERSION_OPTS=( +# org.apache.hudi:hudi-trino (RFC-105), a plain library jar compiled with JDK 25. +# No -am: Lombok cannot run on JDK 25, so upstream Hudi modules (hudi-common, +# hudi-hive-sync, hudi-io:shaded, hudi-sync-common) must already be in the local m2 +# from a prior deploy_staging_jars.sh (JDK 11) run. +"-Phudi-trino -pl hudi-trino" +) +printf -v joined "'%s'\n" "${ALL_VERSION_OPTS[@]}" + +if [ "${1:-}" == "-h" ]; then + echo " +Usage: $(basename "$0") [OPTIONS] + +Options: + One of the version options below +${joined} +-h, --help +" + exit 0 +fi + +VERSION_OPT=${1:-} +valid_version_opt=false +for v in "${ALL_VERSION_OPTS[@]}"; do + [[ $VERSION_OPT == "$v" ]] && valid_version_opt=true +done + +if [ "$valid_version_opt" = true ]; then + # run deploy for only specified version option + ALL_VERSION_OPTS=("$VERSION_OPT") +elif [ "$#" == "1" ]; then + echo "Version option $VERSION_OPT is invalid. Use -h to see examples." + exit 1 +fi + +# -Dmaven.test.skip=true, not -DskipTests: test-compile needs hudi-trino-tests profile deps that are absent here. +COMMON_OPTIONS="-DdeployArtifacts=true -Dmaven.test.skip=true -DretryFailedDeploymentCount=10" +for v in "${ALL_VERSION_OPTS[@]}" +do + echo "Deploying to repository.apache.org with options ${v}" + # Single pass: unlike deploy_staging_jars.sh there is no -am here, so a separate + # install pass would rebuild exactly what deploy builds. + $MVN clean deploy $COMMON_OPTIONS ${v} +done diff --git a/scripts/release/validate_source_copyright.sh b/scripts/release/validate_source_copyright.sh index 1d1c6bf4506bb..5068e588f2bb5 100755 --- a/scripts/release/validate_source_copyright.sh +++ b/scripts/release/validate_source_copyright.sh @@ -47,10 +47,11 @@ echo -e "\t\tNotice file exists ? [OK]\n" ### Licensing Check echo "Performing custom Licensing Check " # --- -# Exclude the 'hudi-trino-plugin' directory. Its license checks are handled by airlift: -# https://github.com/airlift/airbase/blob/823101482dbc60600d7862f0f5c93aded6190996/airbase/pom.xml#L1239 +# Exclude the 'hudi-trino' directory: its files carry the short AL header (Trino convention), +# which the ASF wording this grep matches does not cover. RAT still checks the module via its +# default AL matchers. Drop this prune once apache/hudi#19412 converts the module to ASF headers. # --- -numfilesWithNoLicense=$(find . -path './hudi-trino-plugin' -prune -o -type f -iname '*' | grep -v './hudi-trino-plugin' | grep -v NOTICE | grep -v LICENSE | grep -v '.jpg' | grep -v '.json' | grep -v '.zip' | grep -v '.hfile' | grep -v '.data' | grep -v '.commit' | grep -v emptyFile | grep -v DISCLAIMER | grep -v '.sqltemplate' | grep -v KEYS | grep -v '.mailmap' | grep -v 'banner.txt' | grep -v '.txt' | grep -v "fixtures" | xargs grep -L "Licensed to the Apache Software Foundation (ASF)") +numfilesWithNoLicense=$(find . -path './hudi-trino' -prune -o -type f -iname '*' | grep -v './hudi-trino' | grep -v NOTICE | grep -v LICENSE | grep -v '.jpg' | grep -v '.json' | grep -v '.zip' | grep -v '.hfile' | grep -v '.data' | grep -v '.commit' | grep -v emptyFile | grep -v DISCLAIMER | grep -v '.sqltemplate' | grep -v KEYS | grep -v '.mailmap' | grep -v 'banner.txt' | grep -v '.txt' | grep -v "fixtures" | xargs grep -L "Licensed to the Apache Software Foundation (ASF)") # Check if the variable holding the list of files is non-empty if [ -n "$numfilesWithNoLicense" ]; then # If the list isn't empty, count the files and report the error diff --git a/scripts/release/validate_staged_bundles.sh b/scripts/release/validate_staged_bundles.sh index 36e25ca6aea55..2257d2fd6cbb6 100755 --- a/scripts/release/validate_staged_bundles.sh +++ b/scripts/release/validate_staged_bundles.sh @@ -83,12 +83,14 @@ declare -a extensions=("-javadoc.jar" "-javadoc.jar.asc" "-javadoc.jar.md5" "-ja "-sources.jar.asc" "-sources.jar.md5" "-sources.jar.sha1" ".jar" ".jar.asc" ".jar.md5" ".jar.sha1" ".pom" ".pom.asc" ".pom.md5" ".pom.sha1") +# hudi-trino is a plain library jar, not a bundle, but it is staged and validated like the bundles. declare -a bundles=("hudi-aws-bundle" "hudi-azure-bundle" "hudi-cli-bundle_2.12" "hudi-cli-bundle_2.13" "hudi-datahub-sync-bundle" "hudi-flink1.17-bundle" "hudi-flink1.18-bundle" "hudi-flink1.19-bundle" "hudi-flink1.20-bundle" "hudi-flink2.0-bundle" "hudi-flink2.1-bundle" "hudi-gcp-bundle" "hudi-hadoop-mr-bundle" "hudi-hive-sync-bundle" "hudi-integ-test-bundle" "hudi-kafka-connect-bundle" "hudi-metaserver-server-bundle" "hudi-presto-bundle" "hudi-spark3.3-bundle_2.12" "hudi-spark3.4-bundle_2.12" "hudi-spark3.5-bundle_2.12" -"hudi-spark3.5-bundle_2.13" "hudi-spark4.0-bundle_2.13" "hudi-spark4.1-bundle_2.13" "hudi-timeline-server-bundle" "hudi-trino-bundle" +"hudi-spark3.5-bundle_2.13" "hudi-spark4.0-bundle_2.13" "hudi-spark4.1-bundle_2.13" "hudi-timeline-server-bundle" +"hudi-trino" "hudi-utilities-bundle_2.12" "hudi-utilities-bundle_2.13" "hudi-utilities-slim-bundle_2.12" "hudi-utilities-slim-bundle_2.13") diff --git a/style/checkstyle-suppressions.xml b/style/checkstyle-suppressions.xml index c31a9ff97dce8..39ccbf1aa460c 100644 --- a/style/checkstyle-suppressions.xml +++ b/style/checkstyle-suppressions.xml @@ -33,7 +33,7 @@ - - + +