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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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: + * + * 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. + * The target is before the MDT timeline start (the relevant history was archived away). + * + * 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
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); + * }
Key namespacing: + *
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
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
* 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
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.
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).
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.