From 86c28af622cb1785af478445f0919bca06950bce Mon Sep 17 00:00:00 2001 From: yuqi Date: Wed, 23 Sep 2026 11:16:52 +0800 Subject: [PATCH 01/17] [#13459] improvement(ci): Split build and Backend IT into sharded sub-jobs --- .../backend-integration-test-action.yml | 17 +- .../workflows/backend-integration-test.yml | 51 +++--- .github/workflows/build.yml | 96 ++++++++--- dev/ci/test-shards.sh | 158 ++++++++++++++++++ 4 files changed, 271 insertions(+), 51 deletions(-) create mode 100755 dev/ci/test-shards.sh diff --git a/.github/workflows/backend-integration-test-action.yml b/.github/workflows/backend-integration-test-action.yml index 3d530026cfd..33ecb0c447e 100644 --- a/.github/workflows/backend-integration-test-action.yml +++ b/.github/workflows/backend-integration-test-action.yml @@ -20,12 +20,16 @@ on: required: true description: 'run on embedded or deploy mode' type: string + shard: + required: true + description: 'Test shard defined in dev/ci/test-shards.sh' + type: string jobs: start-runner: - name: JDK${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }} + name: JDK${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }}-${{ inputs.shard }} runs-on: ubuntu-22.04 - timeout-minutes: 120 + timeout-minutes: 90 env: PLATFORM: ${{ inputs.architecture }} steps: @@ -58,7 +62,7 @@ jobs: wget https://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb - - name: Backend Integration Test (JDK${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }}) + - name: Backend Integration Test (JDK${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }}-${{ inputs.shard }}) id: integrationTest run: | EXCLUDE_CONTRIB_TESTS="" @@ -66,7 +70,10 @@ jobs: EXCLUDE_CONTRIB_TESTS="$EXCLUDE_CONTRIB_TESTS -x :catalogs-contrib:$module:test" done - ./gradlew test -PskipTests -PtestMode=${{ inputs.test-mode }} -PjdbcBackend=${{ inputs.backend }} -PskipDockerTests=false -PskipWeb=true \ + shard_args_text="$(dev/ci/test-shards.sh backend-it "${{ inputs.shard }}")" + mapfile -t shard_args <<< "${shard_args_text}" + + ./gradlew "${shard_args[@]}" -PskipTests -PtestMode=${{ inputs.test-mode }} -PjdbcBackend=${{ inputs.backend }} -PskipDockerTests=false -PskipWeb=true \ -x :web:web:test -x :web:integration-test:test -x :web-v2:web:test -x :web-v2:integration-test:test -x :clients:client-python:test \ -x :flink-connector:flink-common:test \ -x :flink-connector:flink-1.18:test -x :flink-connector:flink-runtime-1.18:test \ @@ -85,7 +92,7 @@ jobs: uses: actions/upload-artifact@v7 if: ${{ (failure() && steps.integrationTest.outcome == 'failure') || contains(github.event.pull_request.labels.*.name, 'upload log') }} with: - name: integrate-test-reports-${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }} + name: integrate-test-reports-${{ inputs.java-version }}-${{ inputs.test-mode }}-${{ inputs.backend }}-${{ inputs.shard }} path: | build/reports iceberg/iceberg-rest-server/build/*.log diff --git a/.github/workflows/backend-integration-test.yml b/.github/workflows/backend-integration-test.yml index 85a700cb170..1508cd3761a 100644 --- a/.github/workflows/backend-integration-test.yml +++ b/.github/workflows/backend-integration-test.yml @@ -50,41 +50,25 @@ jobs: - gradle.properties - gradlew - settings.gradle.kts + - name: List backend integration test shards + id: shards + run: echo "backend_it_shards=$(dev/ci/test-shards.sh backend-it --list)" >> "${GITHUB_OUTPUT}" outputs: source_changes: ${{ steps.filter.outputs.source_changes }} + backend_it_shards: ${{ steps.shards.outputs.backend_it_shards }} - BackendIT-on-push: + BackendIT: needs: changes - if: (github.event_name == 'push' && needs.changes.outputs.source_changes == 'true') - strategy: - matrix: - architecture: [linux/amd64] - java-version: [ 17 ] - backend: [ h2, mysql, postgresql ] - test-mode: [ embedded, deploy ] - exclude: - - test-mode: 'embedded' - backend: 'mysql' - - test-mode: 'embedded' - backend: 'postgresql' - - test-mode: 'deploy' - backend: 'h2' - uses: ./.github/workflows/backend-integration-test-action.yml - with: - architecture: ${{ matrix.architecture }} - java-version: ${{ matrix.java-version }} - backend: ${{ matrix.backend }} - test-mode: ${{ matrix.test-mode }} - - BackendIT-on-pr: - needs: changes - if: (github.event_name == 'pull_request' && needs.changes.outputs.source_changes == 'true') + if: needs.changes.outputs.source_changes == 'true' strategy: + fail-fast: false matrix: architecture: [ linux/amd64 ] java-version: [ 17 ] backend: [ h2, mysql, postgresql ] test-mode: [ embedded, deploy ] + # Shards are defined in dev/ci/test-shards.sh. + shard: ${{ fromJSON(needs.changes.outputs.backend_it_shards) }} exclude: - test-mode: 'embedded' backend: 'mysql' @@ -98,3 +82,20 @@ jobs: java-version: ${{ matrix.java-version }} backend: ${{ matrix.backend }} test-mode: ${{ matrix.test-mode }} + shard: ${{ matrix.shard }} + + # Single status check that summarizes every backend integration test shard, so branch protection + # does not need to track individual shard names. + BackendIT-result: + runs-on: ubuntu-22.04 + needs: [ changes, BackendIT ] + if: always() + steps: + - name: Check backend integration test results + run: | + results='${{ toJSON(needs.*.result) }}' + echo "${results}" + if echo "${results}" | grep -Eq '"(failure|cancelled)"'; then + echo "At least one backend integration test shard failed or was cancelled." + exit 1 + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1ad49fbac51..d7865afad1e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,7 +93,11 @@ jobs: fi echo "maintenance_module_only_changes=${maintenance_module_only}" >> "${GITHUB_OUTPUT}" + - name: List build shards + id: shards + run: echo "build_shards=$(dev/ci/test-shards.sh build --list)" >> "${GITHUB_OUTPUT}" outputs: + build_shards: ${{ steps.shards.outputs.build_shards }} source_changes: ${{ steps.filter.outputs.source_changes }} spark_connector_changes: ${{ steps.filter.outputs.spark_connector_changes }} mcp_server_changes: ${{ steps.filter.outputs.mcp_server_changes }} @@ -147,17 +151,18 @@ jobs: spark-connector/**/*.log build: - # The type of runner that the job will run on + name: build (${{ matrix.java-version }}, ${{ matrix.shard }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: java-version: [ 17 ] - timeout-minutes: 120 + # Shards are defined in dev/ci/test-shards.sh. + shard: ${{ fromJSON(needs.changes.outputs.build_shards) }} + timeout-minutes: 90 needs: changes if: needs.changes.outputs.source_changes == 'true' - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 - uses: ./.github/actions/setup-java-toolchains @@ -165,6 +170,7 @@ jobs: java-version: ${{ matrix.java-version }} - name: Test publish to local + if: matrix.shard == 'others' run: ./gradlew publishToMavenLocal -PskipWeb=true -x test - name: Free up disk space @@ -179,6 +185,10 @@ jobs: - name: Build with Gradle run: | if [ "${{ needs.changes.outputs.maintenance_module_only_changes }}" = "true" ]; then + if [ "${{ matrix.shard }}" != "others" ]; then + echo "Only maintenance modules changed; they are built by the 'others' shard." + exit 0 + fi ./gradlew \ :maintenance:optimizer-api:build \ :maintenance:updaters:build \ @@ -190,8 +200,11 @@ jobs: exit 0 fi + shard_args_text="$(dev/ci/test-shards.sh build "${{ matrix.shard }}")" + mapfile -t shard_args <<< "${shard_args_text}" + gradle_args=( - build + "${shard_args[@]}" --max-workers=2 -PskipWeb=true -PskipITs @@ -217,13 +230,50 @@ jobs: ./gradlew "${gradle_args[@]}" - - name: Fetch base branch for coverage diff + - name: Upload coverage data if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: jacoco-${{ matrix.shard }} + # build.gradle.kts anchors the artifact at the repository root, so the report paths keep + # their module prefix, which jacoco_report.py uses to name modules. + path: | + build.gradle.kts + **/build/reports/jacoco/test/jacocoTestReport.xml + if-no-files-found: ignore + retention-days: 1 + + - name: Upload unit tests report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: unit test report ${{ matrix.shard }} + path: | + build/reports + catalogs-contrib/**/*.log + catalogs-contrib/**/*.tar + catalogs/**/*.log + catalogs/**/*.tar + + coverage: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build + if: github.event_name == 'pull_request' && needs.build.result == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Fetch base branch for coverage diff run: git fetch origin ${{ github.base_ref }} --depth=1 + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: jacoco-* + merge-multiple: true + - name: Generate Coverage Report id: coverage - if: github.event_name == 'pull_request' run: | python3 dev/ci/jacoco_report.py \ --base-ref "${{ github.base_ref }}" \ @@ -234,11 +284,11 @@ jobs: --output coverage-report.md - name: Save PR number - if: github.event_name == 'pull_request' && steps.coverage.outputs.has_reports == 'true' + if: steps.coverage.outputs.has_reports == 'true' run: echo "${{ github.event.pull_request.number }}" > pr-number.txt - name: Upload Coverage Report - if: github.event_name == 'pull_request' && steps.coverage.outputs.has_reports == 'true' + if: steps.coverage.outputs.has_reports == 'true' uses: actions/upload-artifact@v7 with: name: coverage-report @@ -247,19 +297,23 @@ jobs: pr-number.txt - name: Output Coverage Info - if: github.event_name == 'pull_request' && steps.coverage.outputs.has_reports == 'true' + if: steps.coverage.outputs.has_reports == 'true' run: | echo "Total coverage ${{ steps.coverage.outputs.coverage-overall }}" echo "Changed Files coverage ${{ steps.coverage.outputs.coverage-changed-files }}" - - name: Upload unit tests report - uses: actions/upload-artifact@v7 - if: failure() - with: - name: unit test report - path: | - build/reports - catalogs-contrib/**/*.log - catalogs-contrib/**/*.tar - catalogs/**/*.log - catalogs/**/*.tar + # Single status check that summarizes every build shard, so branch protection does not need to + # track individual shard names. + build-result: + runs-on: ubuntu-latest + needs: [ changes, compile-check, spark-connector-build, build, coverage ] + if: always() + steps: + - name: Check build results + run: | + results='${{ toJSON(needs.*.result) }}' + echo "${results}" + if echo "${results}" | grep -Eq '"(failure|cancelled)"'; then + echo "At least one build job failed or was cancelled." + exit 1 + fi diff --git a/dev/ci/test-shards.sh b/dev/ci/test-shards.sh new file mode 100755 index 00000000000..a9188a79534 --- /dev/null +++ b/dev/ci/test-shards.sh @@ -0,0 +1,158 @@ +#!/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. +# +# Single source of truth for how CI test suites are split into parallel shards. +# +# Usage: +# dev/ci/test-shards.sh --list Print the suite's shard names as a JSON array. +# dev/ci/test-shards.sh Print the Gradle task arguments of a shard, one per line. +# +# Suites: +# build Unit tests run by .github/workflows/build.yml. +# backend-it Integration tests run by .github/workflows/backend-integration-test.yml. +# +# Every suite ends with a catch-all `others` shard that excludes the projects of all named +# shards, so a new module is always tested by `others` until it is moved to a named shard. +# To rebalance, move a project between the lists below; the workflows need no change. + +set -euo pipefail + +# ---- build suite ------------------------------------------------------------------------------- +# `core` holds the shared test environment lock for its whole run, so it gets its own shard. +BUILD_CORE=( + :core +) + +# Projects with `gravitino-docker-test` tests. Gradle runs them one by one under the shared test +# environment lock, so they are kept away from the parallel unit tests in `others`. +BUILD_DOCKER=( + :authorizations:authorization-chain + :authorizations:authorization-ranger + :catalogs:catalog-fileset + :catalogs:catalog-glue + :catalogs:catalog-hive + :catalogs:catalog-jdbc-doris + :catalogs:catalog-jdbc-mysql + :catalogs:catalog-jdbc-postgresql + :catalogs:catalog-jdbc-starrocks + :catalogs:catalog-kafka + :catalogs:catalog-lakehouse-hudi + :catalogs:catalog-lakehouse-iceberg + :catalogs:catalog-lakehouse-paimon + :catalogs:hive-metastore-common + :clients:client-java + :clients:filesystem-hadoop3 + :flink-connector:flink-common + :iceberg:iceberg-rest-server + :maintenance:jobs + :maintenance:optimizer + :plugins:idp-basic + :spark-connector:spark-3.5 +) + +# ---- backend-it suite -------------------------------------------------------------------------- +BACKEND_IT_HIVE=( + :catalogs:catalog-hive + :catalogs:catalog-glue + :catalogs:catalog-lakehouse-hudi +) + +BACKEND_IT_CLIENT=( + :clients:client-java + :catalogs:catalog-fileset + :clients:filesystem-hadoop3 +) + +BACKEND_IT_LAKEHOUSE=( + :iceberg:iceberg-rest-server + :catalogs:catalog-lakehouse-iceberg + :catalogs:catalog-lakehouse-paimon + :lance:lance-rest-server +) + +usage() { + sed -n '/^# Usage:/,/^# To rebalance/p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 1 +} + +# Prints the shard names of a suite, in matrix order. +shards_of() { + case "$1" in + build) echo "core docker others" ;; + backend-it) echo "hive client lakehouse others" ;; + *) echo "Unknown suite: $1" >&2; usage ;; + esac +} + +# Prints the variable name holding the projects of a named shard. +projects_var() { + case "$1/$2" in + build/core) echo BUILD_CORE ;; + build/docker) echo BUILD_DOCKER ;; + backend-it/hive) echo BACKEND_IT_HIVE ;; + backend-it/client) echo BACKEND_IT_CLIENT ;; + backend-it/lakehouse) echo BACKEND_IT_LAKEHOUSE ;; + *) echo "Unknown shard '$2' for suite '$1'" >&2; usage ;; + esac +} + +# Prints `:test` for every project in the array named by $1. +print_test_tasks() { + local project + eval 'for project in "${'"$1"'[@]}"; do echo "${project}:test"; done' +} + +# `others` runs the suite's root task with every named shard's test task excluded. +print_others() { + local suite="$1" root_task="$2" shard task + echo "${root_task}" + for shard in $(shards_of "${suite}"); do + [ "${shard}" = "others" ] && continue + for task in $(print_test_tasks "$(projects_var "${suite}" "${shard}")"); do + printf -- '-x\n%s\n' "${task}" + done + done +} + +[ $# -eq 2 ] || usage +suite="$1" +shard="$2" +shard_names="$(shards_of "${suite}")" + +if [ "${shard}" = "--list" ]; then + printf '[' + sep="" + for name in ${shard_names}; do + printf '%s"%s"' "${sep}" "${name}" + sep="," + done + printf ']\n' + exit 0 +fi + +if [ "${shard}" = "others" ]; then + case "${suite}" in + build) print_others build build ;; + backend-it) print_others backend-it test ;; + *) echo "Unknown suite: ${suite}" >&2; usage ;; + esac +else + projects="$(projects_var "${suite}" "${shard}")" + print_test_tasks "${projects}" +fi From 87b7431d477faef312ecc1214ecf4aae88868acf Mon Sep 17 00:00:00 2001 From: yuqi Date: Thu, 24 Sep 2026 20:19:32 +0800 Subject: [PATCH 02/17] [#13459] improvement(ci): Use three Backend IT shards and drop the aggregate result jobs - Fold the `client` Backend IT shard into `others`. Both are short, and the merged shard still finishes before the slowest build shard, so PR feedback time is unchanged while each backend runs one job less. - Remove `build-result` and `BackendIT-result`. No branch protection rule requires them, so they only added checks. --- .github/workflows/backend-integration-test.yml | 16 ---------------- .github/workflows/build.yml | 16 ---------------- dev/ci/test-shards.sh | 11 +++-------- 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/.github/workflows/backend-integration-test.yml b/.github/workflows/backend-integration-test.yml index 1508cd3761a..74d72df1eef 100644 --- a/.github/workflows/backend-integration-test.yml +++ b/.github/workflows/backend-integration-test.yml @@ -83,19 +83,3 @@ jobs: backend: ${{ matrix.backend }} test-mode: ${{ matrix.test-mode }} shard: ${{ matrix.shard }} - - # Single status check that summarizes every backend integration test shard, so branch protection - # does not need to track individual shard names. - BackendIT-result: - runs-on: ubuntu-22.04 - needs: [ changes, BackendIT ] - if: always() - steps: - - name: Check backend integration test results - run: | - results='${{ toJSON(needs.*.result) }}' - echo "${results}" - if echo "${results}" | grep -Eq '"(failure|cancelled)"'; then - echo "At least one backend integration test shard failed or was cancelled." - exit 1 - fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7865afad1e..19f01c99364 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -301,19 +301,3 @@ jobs: run: | echo "Total coverage ${{ steps.coverage.outputs.coverage-overall }}" echo "Changed Files coverage ${{ steps.coverage.outputs.coverage-changed-files }}" - - # Single status check that summarizes every build shard, so branch protection does not need to - # track individual shard names. - build-result: - runs-on: ubuntu-latest - needs: [ changes, compile-check, spark-connector-build, build, coverage ] - if: always() - steps: - - name: Check build results - run: | - results='${{ toJSON(needs.*.result) }}' - echo "${results}" - if echo "${results}" | grep -Eq '"(failure|cancelled)"'; then - echo "At least one build job failed or was cancelled." - exit 1 - fi diff --git a/dev/ci/test-shards.sh b/dev/ci/test-shards.sh index a9188a79534..61eece991d7 100755 --- a/dev/ci/test-shards.sh +++ b/dev/ci/test-shards.sh @@ -67,18 +67,14 @@ BUILD_DOCKER=( ) # ---- backend-it suite -------------------------------------------------------------------------- +# client-java, catalog-fileset and filesystem-hadoop3 stay in `others`: together with the remaining +# modules they still finish before the slowest shard, and one less shard saves a job per backend. BACKEND_IT_HIVE=( :catalogs:catalog-hive :catalogs:catalog-glue :catalogs:catalog-lakehouse-hudi ) -BACKEND_IT_CLIENT=( - :clients:client-java - :catalogs:catalog-fileset - :clients:filesystem-hadoop3 -) - BACKEND_IT_LAKEHOUSE=( :iceberg:iceberg-rest-server :catalogs:catalog-lakehouse-iceberg @@ -95,7 +91,7 @@ usage() { shards_of() { case "$1" in build) echo "core docker others" ;; - backend-it) echo "hive client lakehouse others" ;; + backend-it) echo "hive lakehouse others" ;; *) echo "Unknown suite: $1" >&2; usage ;; esac } @@ -106,7 +102,6 @@ projects_var() { build/core) echo BUILD_CORE ;; build/docker) echo BUILD_DOCKER ;; backend-it/hive) echo BACKEND_IT_HIVE ;; - backend-it/client) echo BACKEND_IT_CLIENT ;; backend-it/lakehouse) echo BACKEND_IT_LAKEHOUSE ;; *) echo "Unknown shard '$2' for suite '$1'" >&2; usage ;; esac From 95948e20a72f98e797d1c10fc85534a55b57a81c Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sat, 26 Sep 2026 22:27:17 -0700 Subject: [PATCH 03/17] test(core): define explicit database test lanes --- build.gradle.kts | 17 +- core/build.gradle.kts | 104 ++++ .../TestJdbcPartitionStatisticStorageIT.java | 5 + .../storage/AbstractEntityStorageTest.java | 24 +- .../storage/TestBackendTestSelector.java | 138 ++++ .../relational/BackendTestExtension.java | 18 +- .../relational/BackendTestSelector.java | 71 +++ .../storage/relational/TestJDBCBackend.java | 2 + .../service/TestJobMetaService.java | 48 -- .../service/TestJobMetaServiceValidation.java | 78 +++ dev/ci/core_test_identity.py | 587 ++++++++++++++++++ .../core_test_identity/h2/TEST-backend.xml | 27 + .../core_test_identity/legacy/TEST-legacy.xml | 49 ++ .../core_test_identity/mysql/TEST-backend.xml | 27 + .../postgresql/TEST-backend.xml | 29 + .../core_test_identity/unit/TEST-unit.xml | 26 + dev/ci/tests/test_core_test_identity.py | 407 ++++++++++++ 17 files changed, 1596 insertions(+), 61 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java create mode 100644 dev/ci/core_test_identity.py create mode 100644 dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml create mode 100644 dev/ci/tests/test_core_test_identity.py diff --git a/build.gradle.kts b/build.gradle.kts index f6bf7855ea1..7ba3b64cdd8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -514,9 +514,12 @@ allprojects { val dockerTest = project.rootProject.extra["dockerTest"] as? Boolean ?: false param.environment("dockerTest", dockerTest.toString()) + val includeDockerTaggedTests = + param.extensions.extraProperties.properties["includeDockerTaggedTests"] as? Boolean + ?: dockerTest val dorisMultiVersion = project.hasProperty("dorisMultiVersionTest") param.useJUnitPlatform { - if (!dockerTest) { + if (!includeDockerTaggedTests) { excludeTags("gravitino-docker-test") } if (!dorisMultiVersion) { @@ -1019,7 +1022,17 @@ subprojects { val extraArgs = project.property("extraJvmArgs") as List jvmArgs = listOf("-Xmx4G") + extraArgs useJUnitPlatform() - finalizedBy(tasks.getByName("jacocoTestReport")) + val isCoreSuiteTask = + project.path == ":core" && + name in setOf( + "coreUnitTest", + "coreH2Test", + "coreMySQLTest", + "corePostgreSQLTest" + ) + if (!isCoreSuiteTask) { + finalizedBy(tasks.getByName("jacocoTestReport")) + } } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a8511341f00..16d8aa7638e 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,7 @@ import net.ltgt.gradle.errorprone.errorprone +import org.gradle.api.tasks.testing.Test +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport /* * Licensed to the Apache Software Foundation (ASF) under one @@ -103,6 +106,107 @@ artifacts { add("testArtifacts", testJar) } +val coreDatabaseTestTag = "gravitino-core-database-test" +val coreH2TestTag = "gravitino-core-h2-test" +val coreMySQLTestTag = "gravitino-core-mysql-test" +val corePostgreSQLTestTag = "gravitino-core-postgresql-test" +val coreTestBackendProperty = "gravitino.core.test.backend" + +fun registerCoreTestTask( + taskName: String, + backend: String? = null +) = tasks.register(taskName) { + group = "verification" + description = + if (backend == null) { + "Runs core unit tests." + } else { + "Runs core database tests against $backend." + } + + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + + inputs.property("coreTestSuite", backend ?: "unit") + inputs.property("coreTestBackend", backend ?: "none") + inputs.property("includeDockerTaggedTests", backend != null) + reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) + reports.html.outputLocation.set( + rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") + ) + + extensions.configure { + destinationFile = layout.buildDirectory.file("jacoco/$taskName.exec").get().asFile + } + + useJUnitPlatform { + if (backend == null) { + excludeTags(coreDatabaseTestTag, "gravitino-docker-test") + } else { + includeTags(coreDatabaseTestTag) + when (backend) { + "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) + "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) + "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) + else -> throw GradleException("Unsupported core test backend: $backend") + } + } + } + + if (backend != null) { + systemProperty(coreTestBackendProperty, backend) + extensions.extraProperties["includeDockerTaggedTests"] = true + + // Database tests mutate process-wide state and must remain sequential within each lane. + maxParallelForks = 1 + systemProperty("junit.jupiter.execution.parallel.enabled", "false") + + if (backend != "h2") { + doFirst { + if (rootProject.extra["dockerTest"] != true) { + throw GradleException( + "$path requires Docker; use -PskipDockerTests=false with Docker running." + ) + } + } + } + } +} + +registerCoreTestTask("coreUnitTest") +registerCoreTestTask("coreH2Test", "h2") +registerCoreTestTask("coreMySQLTest", "mysql") +registerCoreTestTask("corePostgreSQLTest", "postgresql") + +val coreSuiteCoverage = + providers.gradleProperty("coreSuiteCoverage").map(String::toBoolean).orElse(false) +val coreSuiteTaskNames = + listOf("coreUnitTest", "coreH2Test", "coreMySQLTest", "corePostgreSQLTest") +val coreSuiteExecutionData = + coreSuiteTaskNames.map { layout.buildDirectory.file("jacoco/$it.exec") } +val validateCoreSuiteCoverage by tasks.registering { + inputs.files(coreSuiteExecutionData) + + doLast { + val missingExecutionData = + coreSuiteExecutionData + .map { it.get().asFile } + .filterNot { it.isFile && it.length() > 0L } + if (missingExecutionData.isNotEmpty()) { + throw GradleException( + "Missing core JaCoCo execution data: ${missingExecutionData.joinToString()}" + ) + } + } +} + +tasks.named("jacocoTestReport") { + if (coreSuiteCoverage.get()) { + dependsOn(tasks.named("classes"), validateCoreSuiteCoverage) + executionData.setFrom(coreSuiteExecutionData) + } +} + tasks.test { val testMode = project.properties["testMode"] as? String ?: "embedded" if (testMode == "embedded") { diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index b1cac18a986..42b5938596a 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -85,6 +85,7 @@ *
  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ +@Tag("gravitino-core-database-test") @Tag("gravitino-docker-test") public class TestJdbcPartitionStatisticStorageIT { @@ -95,6 +96,7 @@ public class TestJdbcPartitionStatisticStorageIT { * Abstract base class containing all test logic. Each database-specific test class extends this * and implements the database setup. */ + @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { @@ -585,6 +587,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested + @Tag("gravitino-core-mysql-test") @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -655,6 +658,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested + @Tag("gravitino-core-postgresql-test") @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -728,6 +732,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested + @Tag("gravitino-core-h2-test") static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index cac53eed61c..ab1c8f76811 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -49,6 +49,7 @@ import java.sql.Statement; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -92,6 +93,7 @@ import org.apache.gravitino.meta.TopicEntity; import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; +import org.apache.gravitino.storage.relational.BackendTestSelector; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -103,10 +105,12 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +@Tag("gravitino-core-database-test") abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); @@ -116,14 +120,18 @@ abstract class AbstractEntityStorageTest { protected static final String H2_FILE = DB_DIR + ".mv.db"; static Object[][] storageProvider() { - return new Object[][] { - {"h2", true}, - {"h2", false}, - {"mysql", true}, - {"mysql", false}, - {"postgresql", true}, - {"postgresql", false} - }; + Object[][] backends = + new Object[][] { + {"h2", true}, + {"h2", false}, + {"mysql", true}, + {"mysql", false}, + {"postgresql", true}, + {"postgresql", false} + }; + return Arrays.stream(backends) + .filter(arguments -> BackendTestSelector.isSelected((String) arguments[0])) + .toArray(Object[][]::new); } @AfterEach diff --git a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java new file mode 100644 index 00000000000..68e57488392 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.apache.gravitino.storage.relational.BackendTestExtension; +import org.apache.gravitino.storage.relational.BackendTestSelector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestTemplateInvocationContext; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import org.mockito.Mockito; + +/** Tests backend selection for the core database test suites. */ +@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE) +public class TestBackendTestSelector { + + private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + + private Optional originalBackend = Optional.empty(); + + @BeforeEach + void saveAndClearBackendProperty() { + originalBackend = Optional.ofNullable(System.getProperty(BACKEND_PROPERTY)); + System.clearProperty(BACKEND_PROPERTY); + } + + @AfterEach + void restoreBackendProperty() { + System.clearProperty(BACKEND_PROPERTY); + originalBackend.ifPresent(value -> System.setProperty(BACKEND_PROPERTY, value)); + } + + @Test + void testAbsentSelectionPreservesLegacyBehavior() { + assertEquals(Optional.empty(), BackendTestSelector.selectedBackend()); + assertTrue(BackendTestSelector.isSelected("h2")); + assertTrue(BackendTestSelector.isSelected("mysql")); + assertTrue(BackendTestSelector.isSelected("postgresql")); + } + + @Test + void testSelectionIsNormalizedAndValidated() { + System.setProperty(BACKEND_PROPERTY, " MySQL "); + + assertEquals(Optional.of("mysql"), BackendTestSelector.selectedBackend()); + assertTrue(BackendTestSelector.isSelected("MYSQL")); + assertFalse(BackendTestSelector.isSelected("h2")); + + System.setProperty(BACKEND_PROPERTY, "unsupported"); + assertThrows(IllegalArgumentException.class, BackendTestSelector::selectedBackend); + } + + @Test + void testTemplateProviderUsesSelectedBackendAndMethodName() throws NoSuchMethodException { + System.setProperty(BACKEND_PROPERTY, "mysql"); + BackendTestExtension extension = new BackendTestExtension(); + + String firstDisplayName = + selectedInvocation(extension, "firstTemplateMethod").getDisplayName(1); + String secondDisplayName = + selectedInvocation(extension, "secondTemplateMethod").getDisplayName(1); + + assertEquals("firstTemplateMethod()[MYSQL Backend]", firstDisplayName); + assertEquals("secondTemplateMethod()[MYSQL Backend]", secondDisplayName); + assertNotEquals(firstDisplayName, secondDisplayName); + } + + @Test + void testStorageProviderPreservesLegacyMatrix() { + assertArrayEquals( + new Object[][] { + {"h2", true}, + {"h2", false}, + {"mysql", true}, + {"mysql", false}, + {"postgresql", true}, + {"postgresql", false} + }, + AbstractEntityStorageTest.storageProvider()); + } + + @Test + void testStorageProviderUsesSelectedBackend() { + System.setProperty(BACKEND_PROPERTY, "postgresql"); + + assertArrayEquals( + new Object[][] {{"postgresql", true}, {"postgresql", false}}, + AbstractEntityStorageTest.storageProvider()); + } + + private static TestTemplateInvocationContext selectedInvocation( + BackendTestExtension extension, String methodName) throws NoSuchMethodException { + ExtensionContext context = Mockito.mock(ExtensionContext.class); + Mockito.when(context.getRequiredTestMethod()) + .thenReturn(TemplateMethods.class.getDeclaredMethod(methodName)); + + List invocations = + extension.provideTestTemplateInvocationContexts(context).toList(); + + assertEquals(1, invocations.size()); + return invocations.get(0); + } + + private static class TemplateMethods { + void firstTemplateMethod() {} + + void secondTemplateMethod() {} + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index a9377c8b599..4f32ec11e63 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java @@ -37,6 +37,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; @@ -108,6 +109,14 @@ public boolean supportsTestTemplate(ExtensionContext context) { @Override public Stream provideTestTemplateInvocationContexts( ExtensionContext context) { + String testMethodName = context.getRequiredTestMethod().getName(); + Optional selectedBackend = BackendTestSelector.selectedBackend(); + if (selectedBackend.isPresent()) { + LOG.info("Running tests with the selected {} backend.", selectedBackend.get()); + return Stream.of(selectedBackend.get()) + .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + } + List backendsToTest = new ArrayList<>(); backendsToTest.add("h2"); // Always test with H2 @@ -121,19 +130,22 @@ public Stream provideTestTemplateInvocationContex "Running tests with H2 backend only. Set env var 'dockerTest=true' to include all backends."); } - return backendsToTest.stream().map(BackendInvocationContext::new); + return backendsToTest.stream() + .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); } private static class BackendInvocationContext implements TestTemplateInvocationContext { + private final String testMethodName; private final String backendType; - public BackendInvocationContext(String backendType) { + public BackendInvocationContext(String testMethodName, String backendType) { + this.testMethodName = testMethodName; this.backendType = backendType; } @Override public String getDisplayName(int invocationIndex) { - return String.format("[%s Backend]", backendType.toUpperCase()); + return String.format("%s()[%s Backend]", testMethodName, backendType.toUpperCase()); } @Override diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java new file mode 100644 index 00000000000..3f794dbea80 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** Selects a single JDBC backend for the core database test suites. */ +public final class BackendTestSelector { + + private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + private static final Set SUPPORTED_BACKENDS = Set.of("h2", "mysql", "postgresql"); + + private BackendTestSelector() {} + + /** + * Returns the selected backend, or an empty value when the legacy all-applicable-backends + * behavior should be used. + * + * @return the normalized selected backend + * @throws IllegalArgumentException if the configured backend is unsupported + */ + public static Optional selectedBackend() { + String configuredBackend = System.getProperty(BACKEND_PROPERTY); + if (configuredBackend == null) { + return Optional.empty(); + } + + return Optional.of(validate(configuredBackend)); + } + + /** + * Returns whether a backend should run under the current selection. + * + * @param backend backend to test + * @return true when no backend is selected or the backend matches the selection + * @throws IllegalArgumentException if either backend value is unsupported + */ + public static boolean isSelected(String backend) { + String normalizedBackend = validate(backend); + return selectedBackend().map(normalizedBackend::equals).orElse(true); + } + + private static String validate(String backend) { + String normalizedBackend = backend.trim().toLowerCase(Locale.ROOT); + if (!SUPPORTED_BACKENDS.contains(normalizedBackend)) { + throw new IllegalArgumentException( + String.format( + "Unsupported core test backend '%s'; expected one of %s", + backend, SUPPORTED_BACKENDS)); + } + return normalizedBackend; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 1e4fe8c884f..ac7cc18fec1 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,9 +81,11 @@ import org.apache.gravitino.utils.NamespaceUtil; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; +@Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java index 9ddbfe51c4b..abd5802a537 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java @@ -35,7 +35,6 @@ import org.apache.gravitino.utils.NameIdentifierUtil; import org.apache.gravitino.utils.NamespaceUtil; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; public class TestJobMetaService extends TestJDBCBackend { @@ -492,51 +491,4 @@ public void testUpdateJobWithMismatchedIdThrowsIllegalArgumentException() throws .withFinishedAt(oldJob.finishedAt()) .build())); } - - @Test - public void testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"), e -> e)); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .updateJob( - NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX), e -> e)); - } - - @Test - public void testGetJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .getJobByIdentifier( - NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); - } - - @Test - public void testDeleteJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); - } } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java new file mode 100644 index 00000000000..2d7f6ea6176 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational.service; + +import org.apache.gravitino.exceptions.NoSuchEntityException; +import org.apache.gravitino.job.JobHandle; +import org.apache.gravitino.utils.NameIdentifierUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests job identifier validation that does not require a JDBC backend. */ +public class TestJobMetaServiceValidation { + + private static final String METALAKE_NAME = "metalake_test_job_meta_service"; + + @Test + void testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"), e -> e)); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .updateJob( + NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX), e -> e)); + } + + @Test + void testGetJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .getJobByIdentifier( + NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); + } + + @Test + void testDeleteJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); + } +} diff --git a/dev/ci/core_test_identity.py b/dev/ci/core_test_identity.py new file mode 100644 index 00000000000..84b888126be --- /dev/null +++ b/dev/ci/core_test_identity.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Build and reconcile normalized core-test identity manifests. + +Gradle writes one JUnit XML directory per Test task. This tool turns those +reports into stable, backend-neutral identity multisets so the H2, MySQL, and +PostgreSQL lanes can prove that they exercised the same test contract. It also +records status counts and elapsed test time for CI artifacts. A separate +legacy comparison proves that the four split lanes preserve the exact test +multiset previously exercised by ``:core:test``. +""" + +import argparse +from collections import Counter +from decimal import Decimal, InvalidOperation +import hashlib +import json +import math +from pathlib import Path +import re +import sys +import xml.etree.ElementTree as ET + + +SCHEMA_VERSION = 1 +LANES = ("unit", "h2", "mysql", "postgresql") +DATABASE_LANES = LANES[1:] +MANIFEST_LANES = LANES + ("legacy",) +STATUS_KEYS = ("passed", "skipped", "failures", "errors") + +BACKEND_NAME_PATTERN = r"h2|mysql|postgresql" +TEST_TEMPLATE_BACKEND_RE = re.compile( + rf"\[(?P{BACKEND_NAME_PATTERN})\s+Backend\]", re.IGNORECASE +) +STATS_BACKEND_CLASS_RE = re.compile( + rf"(?PTestJdbcPartitionStatisticStorageIT)\$" + rf"(?P{BACKEND_NAME_PATTERN})Test(?=$|\$)", + re.IGNORECASE, +) +BACKEND_TOKEN_RE = re.compile( + rf"(?{BACKEND_NAME_PATTERN})(?![A-Za-z0-9])", + re.IGNORECASE, +) +TRAILING_INVOCATION_INDEX_RE = re.compile( + r"\[(?:#)?\d+\](?=(?:\s*\[BACKEND Backend\])?\s*$)", re.IGNORECASE +) + + +class ManifestError(ValueError): + """Raised when test results cannot form a trustworthy manifest.""" + + +def _local_name(tag): + """Return an XML element name without its optional namespace.""" + return tag.rsplit("}", 1)[-1] + + +def _canonical_backend(value): + """Return the canonical spelling of a recognized backend.""" + return value.lower() + + +def _classname_backend_markers(value): + """Find backend markers in the backend-specific nested stats classes.""" + return { + _canonical_backend(match.group("backend")) + for match in STATS_BACKEND_CLASS_RE.finditer(value) + } + + +def _test_name_backend_markers(value): + """Find structured backend markers in a testcase name.""" + markers = { + _canonical_backend(match.group("backend")) + for match in TEST_TEMPLATE_BACKEND_RE.finditer(value) + } + markers.update( + _canonical_backend(match.group("backend")) + for match in BACKEND_TOKEN_RE.finditer(value) + ) + return markers + + +def _normalize_classname(value): + """Normalize backend-specific nested stats class names.""" + return STATS_BACKEND_CLASS_RE.sub( + lambda match: f"{match.group('prefix')}$BackendTest", value + ) + + +def _normalize_test_name(value): + """Normalize backend markers and trailing parameterized indices.""" + normalized = TEST_TEMPLATE_BACKEND_RE.sub("[BACKEND Backend]", value) + normalized = BACKEND_TOKEN_RE.sub("BACKEND", normalized) + return TRAILING_INVOCATION_INDEX_RE.sub("[INDEX]", normalized) + + +def normalize_identity(lane, classname, test_name): + """Validate lane markers and return a normalized test identity pair.""" + classname = (classname or "").strip() + test_name = (test_name or "").strip() + if not classname or not test_name: + raise ManifestError("Every must have non-empty classname and name attributes") + + markers = _classname_backend_markers(classname) | _test_name_backend_markers(test_name) + if lane == "unit" and markers: + raise ManifestError( + "Unit test result contains an explicit backend marker " + f"{sorted(markers)}: {classname}::{test_name}" + ) + if lane in DATABASE_LANES: + foreign_markers = markers - {lane} + if foreign_markers: + raise ManifestError( + f"{lane} test result contains foreign backend marker(s) " + f"{sorted(foreign_markers)}: {classname}::{test_name}" + ) + + return ( + _normalize_classname(classname), + _normalize_test_name(test_name), + ) + + +def _testcase_status(testcase): + """Classify one JUnit testcase element.""" + child_tags = {_local_name(child.tag) for child in testcase} + if "failure" in child_tags: + return "failures" + if "error" in child_tags: + return "errors" + if "skipped" in child_tags: + return "skipped" + return "passed" + + +def _testcase_duration(testcase, source_file): + """Parse one JUnit testcase duration as a non-negative Decimal.""" + value = testcase.get("time", "0") + try: + duration = Decimal(value) + except InvalidOperation as error: + raise ManifestError( + f"Invalid testcase duration {value!r} in {source_file}" + ) from error + if not duration.is_finite() or duration < 0: + raise ManifestError(f"Invalid testcase duration {value!r} in {source_file}") + return duration + + +def _identities_as_json(identities): + """Convert an identity Counter to deterministic JSON records.""" + return [ + {"classname": classname, "name": name, "count": count} + for (classname, name), count in sorted(identities.items()) + ] + + +def _identities_from_json(manifest, source_file): + """Validate and restore an identity Counter from a manifest.""" + records = manifest.get("identities") + if not isinstance(records, list): + raise ManifestError(f"Manifest {source_file} has no identities list") + + identities = Counter() + for record in records: + if not isinstance(record, dict): + raise ManifestError(f"Manifest {source_file} has an invalid identity record") + classname = record.get("classname") + name = record.get("name") + count = record.get("count") + if not isinstance(classname, str) or not classname: + raise ManifestError(f"Manifest {source_file} has an invalid classname") + if not isinstance(name, str) or not name: + raise ManifestError(f"Manifest {source_file} has an invalid testcase name") + if not isinstance(count, int) or isinstance(count, bool) or count <= 0: + raise ManifestError(f"Manifest {source_file} has an invalid identity count") + identity = (classname, name) + if identity in identities: + raise ManifestError(f"Manifest {source_file} repeats identity {identity}") + identities[identity] = count + return identities + + +def _identity_digest(identities): + """Return a stable digest of an identity multiset.""" + digest = hashlib.sha256() + for (classname, name), count in sorted(identities.items()): + digest.update(classname.encode("utf-8")) + digest.update(b"\0") + digest.update(name.encode("utf-8")) + digest.update(b"\0") + digest.update(str(count).encode("ascii")) + digest.update(b"\n") + return digest.hexdigest() + + +def build_manifest(lane, results_directory): + """Parse Gradle JUnit XML reports and return a normalized manifest.""" + if lane not in MANIFEST_LANES: + raise ManifestError( + f"Unknown lane {lane!r}; expected one of {', '.join(MANIFEST_LANES)}" + ) + + results_directory = Path(results_directory) + if not results_directory.is_dir(): + raise ManifestError(f"Results directory does not exist: {results_directory}") + + xml_files = sorted(results_directory.rglob("TEST-*.xml")) + if not xml_files: + raise ManifestError(f"No TEST-*.xml files found under {results_directory}") + + identities = Counter() + statuses = Counter({key: 0 for key in STATUS_KEYS}) + duration = Decimal("0") + source_files = [] + + for xml_file in xml_files: + relative_source = xml_file.relative_to(results_directory).as_posix() + source_files.append(relative_source) + try: + root = ET.parse(xml_file).getroot() + except (ET.ParseError, OSError) as error: + raise ManifestError(f"Could not parse {xml_file}: {error}") from error + + testcases = ( + element + for element in root.iter() + if _local_name(element.tag) == "testcase" + ) + for testcase in testcases: + identity = normalize_identity( + lane, testcase.get("classname"), testcase.get("name") + ) + identities[identity] += 1 + statuses[_testcase_status(testcase)] += 1 + duration += _testcase_duration(testcase, relative_source) + + test_count = sum(identities.values()) + if test_count == 0: + raise ManifestError(f"No entries found under {results_directory}") + if statuses["failures"] or statuses["errors"]: + raise ManifestError( + f"Lane {lane} contains {statuses['failures']} failure(s) and " + f"{statuses['errors']} error(s)" + ) + + return { + "schema_version": SCHEMA_VERSION, + "lane": lane, + "successful": True, + "test_count": test_count, + "unique_identity_count": len(identities), + "duration_seconds": float(duration), + "status_counts": {key: statuses[key] for key in STATUS_KEYS}, + "source_files": source_files, + "identity_digest": _identity_digest(identities), + "identities": _identities_as_json(identities), + } + + +def write_json(document, output_file): + """Write one deterministic JSON document.""" + output_file = Path(output_file) + output_file.parent.mkdir(parents=True, exist_ok=True) + with output_file.open("w", encoding="utf-8") as output: + json.dump(document, output, indent=2, sort_keys=True) + output.write("\n") + + +def _load_manifest(manifest_file): + """Load and validate the common fields of one manifest.""" + manifest_file = Path(manifest_file) + try: + with manifest_file.open(encoding="utf-8") as source: + manifest = json.load(source) + except (OSError, json.JSONDecodeError) as error: + raise ManifestError(f"Could not read manifest {manifest_file}: {error}") from error + + if not isinstance(manifest, dict): + raise ManifestError(f"Manifest {manifest_file} must contain a JSON object") + if manifest.get("schema_version") != SCHEMA_VERSION: + raise ManifestError(f"Manifest {manifest_file} has an unsupported schema version") + lane = manifest.get("lane") + if lane not in MANIFEST_LANES: + raise ManifestError(f"Manifest {manifest_file} has invalid lane {lane!r}") + if manifest.get("successful") is not True: + raise ManifestError(f"Manifest {manifest_file} is not successful") + + identities = _identities_from_json(manifest, manifest_file) + test_count = manifest.get("test_count") + if not isinstance(test_count, int) or isinstance(test_count, bool) or test_count <= 0: + raise ManifestError(f"Manifest {manifest_file} has invalid test_count") + if sum(identities.values()) != test_count: + raise ManifestError(f"Manifest {manifest_file} identity counts do not match test_count") + + unique_identity_count = manifest.get("unique_identity_count") + if ( + not isinstance(unique_identity_count, int) + or isinstance(unique_identity_count, bool) + or unique_identity_count <= 0 + or unique_identity_count != len(identities) + ): + raise ManifestError( + f"Manifest {manifest_file} has invalid unique_identity_count" + ) + + statuses = manifest.get("status_counts") + if not isinstance(statuses, dict): + raise ManifestError(f"Manifest {manifest_file} has invalid status_counts") + for key in STATUS_KEYS: + value = statuses.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ManifestError(f"Manifest {manifest_file} has invalid status {key}") + if sum(statuses[key] for key in STATUS_KEYS) != test_count: + raise ManifestError(f"Manifest {manifest_file} statuses do not match test_count") + if statuses["failures"] or statuses["errors"]: + raise ManifestError(f"Manifest {manifest_file} contains failed tests") + + duration = manifest.get("duration_seconds") + if ( + not isinstance(duration, (int, float)) + or isinstance(duration, bool) + or not math.isfinite(duration) + or duration < 0 + ): + raise ManifestError(f"Manifest {manifest_file} has invalid duration_seconds") + + source_files = manifest.get("source_files") + if ( + not isinstance(source_files, list) + or not source_files + or any(not isinstance(source, str) or not source for source in source_files) + or len(set(source_files)) != len(source_files) + ): + raise ManifestError(f"Manifest {manifest_file} has invalid source_files") + + identity_digest = manifest.get("identity_digest") + if identity_digest != _identity_digest(identities): + raise ManifestError(f"Manifest {manifest_file} has invalid identity_digest") + + return lane, manifest, identities + + +def _format_identity_difference(reference, actual): + """Format a bounded explanation of a Counter mismatch.""" + differences = [] + for label, values in (("missing", reference - actual), ("extra", actual - reference)): + for (classname, name), count in sorted(values.items())[:5]: + differences.append(f"{label} {count} x {classname}::{name}") + return "; ".join(differences) + + +def _format_identities(identities): + """Format a bounded identity Counter for an error message.""" + return "; ".join( + f"{count} x {classname}::{name}" + for (classname, name), count in sorted(identities.items())[:5] + ) + + +def _load_split_manifests(manifest_files): + """Load exactly one trustworthy manifest for each split lane.""" + manifest_files = [Path(path) for path in manifest_files] + if len(manifest_files) != len(LANES): + raise ManifestError(f"Expected exactly {len(LANES)} manifests, got {len(manifest_files)}") + + by_lane = {} + counters = {} + for manifest_file in manifest_files: + lane, manifest, identities = _load_manifest(manifest_file) + if lane not in LANES: + raise ManifestError( + f"Expected a split-lane manifest, got lane {lane} from {manifest_file}" + ) + if lane in by_lane: + raise ManifestError(f"Received more than one manifest for lane {lane}") + by_lane[lane] = manifest + counters[lane] = identities + + missing_lanes = set(LANES) - set(by_lane) + if missing_lanes: + raise ManifestError(f"Missing manifest lane(s): {', '.join(sorted(missing_lanes))}") + return by_lane, counters + + +def _combine_counters(counters): + """Combine identity Counters without changing their inputs.""" + combined = Counter() + for counter in counters: + combined.update(counter) + return combined + + +def _lane_summary(manifest, identities): + """Return the evidence retained for one successfully loaded lane.""" + return { + "test_count": manifest["test_count"], + "unique_identity_count": manifest["unique_identity_count"], + "duration_seconds": manifest["duration_seconds"], + "status_counts": manifest["status_counts"], + "source_file_count": len(manifest["source_files"]), + "source_files": manifest["source_files"], + "identity_digest": _identity_digest(identities), + } + + +def reconcile_manifests(manifest_files): + """Require four lanes and reconcile the three database identity multisets.""" + by_lane, counters = _load_split_manifests(manifest_files) + + reference = counters["h2"] + for lane in DATABASE_LANES[1:]: + if counters[lane] != reference: + difference = _format_identity_difference(reference, counters[lane]) + raise ManifestError( + f"Database identity mismatch between h2 and {lane}: {difference}" + ) + + unit_database_overlap = counters["unit"] & reference + if unit_database_overlap: + raise ManifestError( + "Unit/database identity overlap: " + f"{_format_identities(unit_database_overlap)}" + ) + + combined_statuses = { + key: sum(by_lane[lane]["status_counts"][key] for lane in LANES) + for key in STATUS_KEYS + } + lane_summaries = { + lane: _lane_summary(by_lane[lane], counters[lane]) for lane in LANES + } + + return { + "schema_version": SCHEMA_VERSION, + "successful": True, + "database_identities_equal": True, + "unit_database_disjoint": True, + "database_test_count_per_lane": sum(reference.values()), + "database_unique_identity_count": len(reference), + "database_identity_digest": _identity_digest(reference), + "combined_test_count": sum(by_lane[lane]["test_count"] for lane in LANES), + "combined_duration_seconds": float( + sum( + ( + Decimal(str(by_lane[lane]["duration_seconds"])) + for lane in LANES + ), + Decimal("0"), + ) + ), + "combined_status_counts": combined_statuses, + "lanes": lane_summaries, + } + + +def compare_legacy_partition(legacy_manifest_file, split_manifest_files): + """Prove that legacy core:test equals the exact sum of the split lanes.""" + legacy_lane, legacy_manifest, legacy_identities = _load_manifest( + legacy_manifest_file + ) + if legacy_lane != "legacy": + raise ManifestError( + f"Expected a legacy manifest, got lane {legacy_lane} from {legacy_manifest_file}" + ) + + reconciliation = reconcile_manifests(split_manifest_files) + split_manifests, split_counters = _load_split_manifests(split_manifest_files) + split_identities = _combine_counters(split_counters[lane] for lane in LANES) + if legacy_identities != split_identities: + difference = _format_identity_difference(legacy_identities, split_identities) + raise ManifestError(f"Legacy/split identity mismatch: {difference}") + + split_statuses = { + key: sum(split_manifests[lane]["status_counts"][key] for lane in LANES) + for key in STATUS_KEYS + } + return { + "schema_version": SCHEMA_VERSION, + "successful": True, + "partition_equal": True, + "legacy": _lane_summary(legacy_manifest, legacy_identities), + "split": { + "test_count": sum(split_identities.values()), + "unique_identity_count": len(split_identities), + "duration_seconds": reconciliation["combined_duration_seconds"], + "status_counts": split_statuses, + "source_file_count": sum( + len(split_manifests[lane]["source_files"]) for lane in LANES + ), + "source_files": { + lane: split_manifests[lane]["source_files"] for lane in LANES + }, + "identity_digest": _identity_digest(split_identities), + }, + "lanes": reconciliation["lanes"], + } + + +def _create_argument_parser(): + """Create the command-line parser.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest_parser = subparsers.add_parser( + "manifest", help="Create one normalized manifest from Gradle JUnit XML" + ) + manifest_parser.add_argument("--lane", required=True, choices=MANIFEST_LANES) + manifest_parser.add_argument( + "--results", required=True, type=Path, help="Directory containing TEST-*.xml" + ) + manifest_parser.add_argument( + "--output", required=True, type=Path, help="JSON manifest to write" + ) + + reconcile_parser = subparsers.add_parser( + "reconcile", help="Reconcile unit and database lane manifests" + ) + reconcile_parser.add_argument( + "--manifests", required=True, nargs="+", type=Path, help="The four lane manifests" + ) + reconcile_parser.add_argument( + "--output", required=True, type=Path, help="Combined JSON summary to write" + ) + + comparison_parser = subparsers.add_parser( + "compare-legacy", + help="Compare a legacy core:test manifest with the four split lanes", + ) + comparison_parser.add_argument( + "--legacy-manifest", + required=True, + type=Path, + help="Manifest generated from the legacy :core:test results", + ) + comparison_parser.add_argument( + "--split-manifests", + required=True, + nargs="+", + type=Path, + help="The unit, H2, MySQL, and PostgreSQL manifests", + ) + comparison_parser.add_argument( + "--output", required=True, type=Path, help="Partition comparison JSON to write" + ) + return parser + + +def main(argv=None): + """Run the command-line interface.""" + parser = _create_argument_parser() + args = parser.parse_args(argv) + try: + if args.command == "manifest": + document = build_manifest(args.lane, args.results) + elif args.command == "reconcile": + document = reconcile_manifests(args.manifests) + else: + document = compare_legacy_partition( + args.legacy_manifest, args.split_manifests + ) + write_json(document, args.output) + except ManifestError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml new file mode 100644 index 00000000000..7b6bfdd4bbc --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml b/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml new file mode 100644 index 00000000000..e1b8809b57e --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml new file mode 100644 index 00000000000..47a84c52d1e --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml new file mode 100644 index 00000000000..dd1aad76ca3 --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml b/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml new file mode 100644 index 00000000000..7c169da3478 --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py new file mode 100644 index 00000000000..045f60034e6 --- /dev/null +++ b/dev/ci/tests/test_core_test_identity.py @@ -0,0 +1,407 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT 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 copy +from collections import Counter +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).parents[1] / "core_test_identity.py" +SPEC = importlib.util.spec_from_file_location("core_test_identity", SCRIPT_PATH) +core_test_identity = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(core_test_identity) + +FIXTURES = Path(__file__).parent / "fixtures" / "core_test_identity" + + +def identity_counter(manifest): + """Return the manifest identities in their natural Counter form.""" + return Counter( + { + (record["classname"], record["name"]): record["count"] + for record in manifest["identities"] + } + ) + + +def write_report(directory, testcases): + """Write a minimal Gradle-compatible JUnit XML report.""" + directory.mkdir(parents=True, exist_ok=True) + (directory / "TEST-fixture.xml").write_text( + "\n" + f"{testcases}\n", + encoding="utf-8", + ) + + +class TestCoreTestIdentity(unittest.TestCase): + def test_manifest_normalizes_database_identities_as_multisets(self): + manifests = { + lane: core_test_identity.build_manifest(lane, FIXTURES / lane) + for lane in core_test_identity.LANES + } + + database_counters = [ + identity_counter(manifests[lane]) + for lane in core_test_identity.DATABASE_LANES + ] + self.assertEqual(database_counters[0], database_counters[1]) + self.assertEqual(database_counters[0], database_counters[2]) + self.assertEqual( + database_counters[0][ + ( + "org.apache.gravitino.stats.storage." + "TestJdbcPartitionStatisticStorageIT$BackendTest", + "writesPartitionStats()[INDEX]", + ) + ], + 2, + ) + self.assertIn( + ( + "org.apache.gravitino.TestCatalogMetaService", + "testCreateCatalog()[BACKEND Backend]", + ), + database_counters[0], + ) + self.assertIn( + ( + "org.apache.gravitino.TestCatalogMetaService", + "testDropCatalog()[BACKEND Backend]", + ), + database_counters[0], + ) + self.assertIn( + ("org.apache.gravitino.BackendTokenTest", "roundTrip[BACKEND]"), + database_counters[0], + ) + self.assertIn( + ( + "org.apache.gravitino.UnmarkedBackendTest", + "unmarkedSharedCase()", + ), + database_counters[0], + ) + + unit = manifests["unit"] + self.assertEqual(unit["test_count"], 3) + self.assertEqual(unit["duration_seconds"], 0.7) + self.assertEqual(unit["status_counts"]["passed"], 2) + self.assertEqual(unit["status_counts"]["skipped"], 1) + self.assertEqual(unit["source_files"], ["TEST-unit.xml"]) + self.assertIn( + ( + "org.apache.gravitino.TestH2ExceptionConverter", + "testH2Converter()", + ), + identity_counter(unit), + ) + self.assertIn( + ( + "org.apache.gravitino.storage.relational.mapper.provider.postgresql." + "TestCatalogMetaPostgreSQLProvider", + "testInsertSql()", + ), + identity_counter(unit), + ) + + def test_lane_validation_rejects_explicit_wrong_backend_markers(self): + cases = ( + ( + "unit", + '', + "Unit test result contains an explicit backend marker", + ), + ( + "h2", + '', + "foreign backend marker", + ), + ( + "postgresql", + '', + "foreign backend marker", + ), + ) + for lane, testcase, message in cases: + with self.subTest(lane=lane), tempfile.TemporaryDirectory() as temp_dir: + results = Path(temp_dir) + write_report(results, testcase) + with self.assertRaisesRegex(core_test_identity.ManifestError, message): + core_test_identity.build_manifest(lane, results) + + def test_manifest_fails_closed_on_missing_or_untrustworthy_results(self): + with tempfile.TemporaryDirectory() as temp_dir: + with self.assertRaisesRegex(core_test_identity.ManifestError, "No TEST-"): + core_test_identity.build_manifest("unit", temp_dir) + + invalid_cases = ( + ("", "No "), + ("", "Could not parse"), + ( + '', + "contains 1 failure", + ), + ( + '', + "and 1 error", + ), + ) + for xml, message in invalid_cases: + with self.subTest(message=message), tempfile.TemporaryDirectory() as temp_dir: + results = Path(temp_dir) + (results / "TEST-invalid.xml").write_text(xml, encoding="utf-8") + with self.assertRaisesRegex(core_test_identity.ManifestError, message): + core_test_identity.build_manifest("unit", results) + + def test_reconcile_emits_combined_timing_and_identity_summary(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + summary = core_test_identity.reconcile_manifests(manifest_files) + + self.assertTrue(summary["successful"]) + self.assertTrue(summary["database_identities_equal"]) + self.assertTrue(summary["unit_database_disjoint"]) + self.assertEqual(summary["database_test_count_per_lane"], 6) + self.assertEqual(summary["database_unique_identity_count"], 5) + self.assertEqual(summary["combined_test_count"], 21) + self.assertEqual(summary["combined_duration_seconds"], 7.3) + self.assertEqual(summary["combined_status_counts"]["skipped"], 2) + self.assertEqual(set(summary["lanes"]), set(core_test_identity.LANES)) + self.assertEqual( + summary["lanes"]["h2"]["source_files"], ["TEST-backend.xml"] + ) + + def test_reconcile_requires_exactly_four_matching_lanes(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifests = {} + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifests[lane] = manifest_file + + with self.assertRaisesRegex(core_test_identity.ManifestError, "exactly 4"): + core_test_identity.reconcile_manifests(list(manifests.values())[:3]) + + mismatched = copy.deepcopy( + core_test_identity.build_manifest("mysql", FIXTURES / "mysql") + ) + mismatched["identities"][0]["name"] += "-different" + mismatched["identity_digest"] = core_test_identity._identity_digest( + identity_counter(mismatched) + ) + mismatched_file = output_directory / "mysql-mismatched.json" + core_test_identity.write_json(mismatched, mismatched_file) + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Database identity mismatch" + ): + core_test_identity.reconcile_manifests( + [ + manifests["unit"], + manifests["h2"], + mismatched_file, + manifests["postgresql"], + ] + ) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "more than one manifest for lane h2" + ): + core_test_identity.reconcile_manifests( + [ + manifests["unit"], + manifests["h2"], + manifests["h2"], + manifests["postgresql"], + ] + ) + + def test_reconcile_rejects_unit_database_overlap(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + overlapping_results = output_directory / "overlapping-unit-results" + write_report( + overlapping_results, + '', + ) + + manifest_files = [] + for lane in core_test_identity.LANES: + results = ( + overlapping_results if lane == "unit" else FIXTURES / lane + ) + manifest = core_test_identity.build_manifest(lane, results) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Unit/database identity overlap" + ): + core_test_identity.reconcile_manifests(manifest_files) + + def test_reconcile_rejects_tampered_identity_evidence(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + if lane == "h2": + manifest["identity_digest"] = "0" * 64 + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "invalid identity_digest" + ): + core_test_identity.reconcile_manifests(manifest_files) + + def test_compare_legacy_partition_requires_exact_split_multiset(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + split_manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + split_manifest_files.append(manifest_file) + + legacy = core_test_identity.build_manifest( + "legacy", FIXTURES / "legacy" + ) + legacy_file = output_directory / "legacy.json" + core_test_identity.write_json(legacy, legacy_file) + comparison = core_test_identity.compare_legacy_partition( + legacy_file, split_manifest_files + ) + + self.assertTrue(comparison["successful"]) + self.assertTrue(comparison["partition_equal"]) + self.assertEqual(comparison["legacy"]["test_count"], 21) + self.assertEqual(comparison["split"]["test_count"], 21) + self.assertEqual( + comparison["legacy"]["identity_digest"], + comparison["split"]["identity_digest"], + ) + self.assertEqual( + comparison["legacy"]["source_files"], ["TEST-legacy.xml"] + ) + + mismatched = copy.deepcopy(legacy) + mismatched["identities"][0]["name"] += "-different" + mismatched["identity_digest"] = core_test_identity._identity_digest( + identity_counter(mismatched) + ) + mismatched_file = output_directory / "legacy-mismatched.json" + core_test_identity.write_json(mismatched, mismatched_file) + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Legacy/split identity mismatch" + ): + core_test_identity.compare_legacy_partition( + mismatched_file, split_manifest_files + ) + + def test_cli_writes_manifest_and_reconciliation_output(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest_file = output_directory / f"{lane}.json" + return_code = core_test_identity.main( + [ + "manifest", + "--lane", + lane, + "--results", + str(FIXTURES / lane), + "--output", + str(manifest_file), + ] + ) + self.assertEqual(return_code, 0) + self.assertTrue(manifest_file.is_file()) + manifest_files.append(manifest_file) + + summary_file = output_directory / "summary.json" + return_code = core_test_identity.main( + [ + "reconcile", + "--manifests", + *(str(path) for path in manifest_files), + "--output", + str(summary_file), + ] + ) + self.assertEqual(return_code, 0) + with summary_file.open(encoding="utf-8") as source: + summary = json.load(source) + self.assertTrue(summary["database_identities_equal"]) + + legacy_manifest_file = output_directory / "legacy.json" + return_code = core_test_identity.main( + [ + "manifest", + "--lane", + "legacy", + "--results", + str(FIXTURES / "legacy"), + "--output", + str(legacy_manifest_file), + ] + ) + self.assertEqual(return_code, 0) + + comparison_file = output_directory / "legacy-comparison.json" + return_code = core_test_identity.main( + [ + "compare-legacy", + "--legacy-manifest", + str(legacy_manifest_file), + "--split-manifests", + *(str(path) for path in manifest_files), + "--output", + str(comparison_file), + ] + ) + self.assertEqual(return_code, 0) + with comparison_file.open(encoding="utf-8") as source: + comparison = json.load(source) + self.assertTrue(comparison["partition_equal"]) + + +if __name__ == "__main__": + unittest.main() From 6412c3e02f580669eda8fadca5bdd3e192cdf63f Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sat, 26 Sep 2026 22:27:34 -0700 Subject: [PATCH 04/17] ci: run core database tests in independent shards --- .github/workflows/build.yml | 204 +++++++++++++++++++++++++++++++++++- dev/ci/test-shards.sh | 44 ++++++-- 2 files changed, 233 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19f01c99364..ea7caca5c8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -203,12 +203,17 @@ jobs: shard_args_text="$(dev/ci/test-shards.sh build "${{ matrix.shard }}")" mapfile -t shard_args <<< "${shard_args_text}" + skip_docker_tests=false + case "${{ matrix.shard }}" in + core-unit|core-h2) skip_docker_tests=true ;; + esac + gradle_args=( "${shard_args[@]}" --max-workers=2 -PskipWeb=true -PskipITs - -PskipDockerTests=false + "-PskipDockerTests=${skip_docker_tests}" -x :clients:client-python:build -x :catalogs-contrib:catalog-jdbc-clickhouse:test -x :catalogs-contrib:catalog-jdbc-hologres:test @@ -230,8 +235,74 @@ jobs: ./gradlew "${gradle_args[@]}" + - name: Resolve core test lane + id: core-lane + if: >- + success() && + needs.changes.outputs.maintenance_module_only_changes != 'true' && + startsWith(matrix.shard, 'core-') + env: + CORE_SHARD: ${{ matrix.shard }} + run: | + lane="${CORE_SHARD#core-}" + task_path="$(dev/ci/test-shards.sh build "${CORE_SHARD}")" + if [ -z "${lane}" ] || [ "${lane}" = "${CORE_SHARD}" ] || \ + [ -z "${task_path}" ] || [[ "${task_path}" == *$'\n'* ]] || \ + [[ "${task_path}" != :core:* ]]; then + echo "Invalid core shard mapping: ${CORE_SHARD} -> ${task_path}" >&2 + exit 1 + fi + task="${task_path#:core:}" + if [ -z "${task}" ] || [[ "${task}" == *:* ]]; then + echo "Invalid core task path: ${task_path}" >&2 + exit 1 + fi + echo "lane=${lane}" >> "${GITHUB_OUTPUT}" + echo "task=${task}" >> "${GITHUB_OUTPUT}" + + - name: Generate core test manifest + if: steps.core-lane.outcome == 'success' + env: + CORE_LANE: ${{ steps.core-lane.outputs.lane }} + CORE_TASK: ${{ steps.core-lane.outputs.task }} + run: | + mkdir -p core/build/test-manifests + python3 dev/ci/core_test_identity.py manifest \ + --lane "${CORE_LANE}" \ + --results "core/build/test-results/${CORE_TASK}" \ + --output "core/build/test-manifests/${CORE_LANE}.json" + + - name: Validate core test evidence + if: steps.core-lane.outcome == 'success' + env: + CORE_LANE: ${{ steps.core-lane.outputs.lane }} + CORE_TASK: ${{ steps.core-lane.outputs.task }} + run: | + test -s "core/build/test-manifests/${CORE_LANE}.json" + junit_xml="$(find "core/build/test-results/${CORE_TASK}" \ + -type f -name 'TEST-*.xml' -print -quit)" + test -n "${junit_xml}" + test -s "${junit_xml}" + test -s "build/reports/tests/core/${CORE_TASK}/index.html" + test -s "core/build/jacoco/${CORE_TASK}.exec" + + - name: Upload core test evidence + if: steps.core-lane.outcome == 'success' + uses: actions/upload-artifact@v7 + with: + name: core-${{ steps.core-lane.outputs.lane }}-test-evidence + path: | + core/build/test-manifests/${{ steps.core-lane.outputs.lane }}.json + core/build/test-results/${{ steps.core-lane.outputs.task }} + build/reports/tests/core/${{ steps.core-lane.outputs.task }} + core/build/jacoco/${{ steps.core-lane.outputs.task }}.exec + if-no-files-found: error + retention-days: 1 + - name: Upload coverage data - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'pull_request' && + !startsWith(matrix.shard, 'core-') uses: actions/upload-artifact@v7 with: name: jacoco-${{ matrix.shard }} @@ -250,19 +321,91 @@ jobs: name: unit test report ${{ matrix.shard }} path: | build/reports + build/reports/tests/core + core/build/test-results + core/build/test-manifests + core/build/jacoco/*.exec catalogs-contrib/**/*.log catalogs-contrib/**/*.tar catalogs/**/*.log catalogs/**/*.tar + core-test-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [ changes, build ] + if: >- + always() && + needs.changes.outputs.source_changes == 'true' && + needs.changes.outputs.maintenance_module_only_changes != 'true' && + needs.build.result == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Test core identity tool + run: python3 -B -m unittest discover -s dev/ci/tests -p 'test_core_test_identity.py' + + - name: Download unit evidence + uses: actions/download-artifact@v4 + with: + name: core-unit-test-evidence + path: core-test-evidence/unit + + - name: Download H2 evidence + uses: actions/download-artifact@v4 + with: + name: core-h2-test-evidence + path: core-test-evidence/h2 + + - name: Download MySQL evidence + uses: actions/download-artifact@v4 + with: + name: core-mysql-test-evidence + path: core-test-evidence/mysql + + - name: Download PostgreSQL evidence + uses: actions/download-artifact@v4 + with: + name: core-postgresql-test-evidence + path: core-test-evidence/postgresql + + - name: Reconcile core test identities + run: | + mkdir -p core/build/test-manifests + python3 dev/ci/core_test_identity.py reconcile \ + --manifests \ + core-test-evidence/unit/core/build/test-manifests/unit.json \ + core-test-evidence/h2/core/build/test-manifests/h2.json \ + core-test-evidence/mysql/core/build/test-manifests/mysql.json \ + core-test-evidence/postgresql/core/build/test-manifests/postgresql.json \ + --output core/build/test-manifests/summary.json + + - name: Upload core test contract + uses: actions/upload-artifact@v7 + with: + name: core-test-contract + path: core/build/test-manifests/summary.json + if-no-files-found: error + retention-days: 1 + coverage: runs-on: ubuntu-latest - timeout-minutes: 15 - needs: build - if: github.event_name == 'pull_request' && needs.build.result == 'success' + timeout-minutes: 30 + needs: [ changes, build, core-test-contract ] + if: >- + always() && + github.event_name == 'pull_request' && + needs.build.result == 'success' && + (needs.changes.outputs.maintenance_module_only_changes == 'true' || + needs['core-test-contract'].result == 'success') steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-java-toolchains + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + with: + java-version: 17 + - name: Fetch base branch for coverage diff run: git fetch origin ${{ github.base_ref }} --depth=1 @@ -272,6 +415,56 @@ jobs: pattern: jacoco-* merge-multiple: true + - name: Download unit evidence + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + uses: actions/download-artifact@v4 + with: + name: core-unit-test-evidence + path: core-test-evidence/unit + + - name: Download H2 evidence + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + uses: actions/download-artifact@v4 + with: + name: core-h2-test-evidence + path: core-test-evidence/h2 + + - name: Download MySQL evidence + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + uses: actions/download-artifact@v4 + with: + name: core-mysql-test-evidence + path: core-test-evidence/mysql + + - name: Download PostgreSQL evidence + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + uses: actions/download-artifact@v4 + with: + name: core-postgresql-test-evidence + path: core-test-evidence/postgresql + + - name: Generate combined core coverage + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + run: | + mkdir -p core/build/jacoco + install -m 0644 \ + core-test-evidence/unit/core/build/jacoco/coreUnitTest.exec \ + core/build/jacoco/coreUnitTest.exec + install -m 0644 \ + core-test-evidence/h2/core/build/jacoco/coreH2Test.exec \ + core/build/jacoco/coreH2Test.exec + install -m 0644 \ + core-test-evidence/mysql/core/build/jacoco/coreMySQLTest.exec \ + core/build/jacoco/coreMySQLTest.exec + install -m 0644 \ + core-test-evidence/postgresql/core/build/jacoco/corePostgreSQLTest.exec \ + core/build/jacoco/corePostgreSQLTest.exec + ./gradlew \ + :core:jacocoTestReport \ + -PcoreSuiteCoverage=true \ + -PskipWeb=true + test -s core/build/reports/jacoco/test/jacocoTestReport.xml + - name: Generate Coverage Report id: coverage run: | @@ -295,6 +488,7 @@ jobs: path: | coverage-report.md pr-number.txt + core/build/reports/jacoco/test/jacocoTestReport.xml - name: Output Coverage Info if: steps.coverage.outputs.has_reports == 'true' diff --git a/dev/ci/test-shards.sh b/dev/ci/test-shards.sh index 61eece991d7..0c868c75a69 100755 --- a/dev/ci/test-shards.sh +++ b/dev/ci/test-shards.sh @@ -27,16 +27,22 @@ # build Unit tests run by .github/workflows/build.yml. # backend-it Integration tests run by .github/workflows/backend-integration-test.yml. # -# Every suite ends with a catch-all `others` shard that excludes the projects of all named -# shards, so a new module is always tested by `others` until it is moved to a named shard. -# To rebalance, move a project between the lists below; the workflows need no change. +# Every suite ends with a catch-all `others` shard that excludes the test tasks owned by its named +# shards, so a new module is always tested by `others` until it is moved to a named shard. Build's +# core lanes map directly to dedicated tasks; project-based shards remain in the lists below. +# To rebalance, update the task mappings or project lists below; the workflows need no change. set -euo pipefail # ---- build suite ------------------------------------------------------------------------------- -# `core` holds the shared test environment lock for its whole run, so it gets its own shard. -BUILD_CORE=( - :core +# Core separates its unit and database contracts into explicit tasks. Database lanes remain +# sequential internally; CI gives each lane its own shard so their results and coverage inputs are +# independently inspectable. +BUILD_CORE_SHARDS=( + core-unit + core-h2 + core-mysql + core-postgresql ) # Projects with `gravitino-docker-test` tests. Gradle runs them one by one under the shared test @@ -90,7 +96,7 @@ usage() { # Prints the shard names of a suite, in matrix order. shards_of() { case "$1" in - build) echo "core docker others" ;; + build) echo "${BUILD_CORE_SHARDS[*]} docker others" ;; backend-it) echo "hive lakehouse others" ;; *) echo "Unknown suite: $1" >&2; usage ;; esac @@ -99,7 +105,6 @@ shards_of() { # Prints the variable name holding the projects of a named shard. projects_var() { case "$1/$2" in - build/core) echo BUILD_CORE ;; build/docker) echo BUILD_DOCKER ;; backend-it/hive) echo BACKEND_IT_HIVE ;; backend-it/lakehouse) echo BACKEND_IT_LAKEHOUSE ;; @@ -117,6 +122,17 @@ print_test_tasks() { print_others() { local suite="$1" root_task="$2" shard task echo "${root_task}" + + if [ "${suite}" = "build" ]; then + # The explicit core shards replace the legacy task. Keep Docker-tagged projects in their own + # shard as before so `others` cannot execute either group a second time through root `build`. + printf -- '-x\n:core:test\n' + for task in $(print_test_tasks BUILD_DOCKER); do + printf -- '-x\n%s\n' "${task}" + done + return + fi + for shard in $(shards_of "${suite}"); do [ "${shard}" = "others" ] && continue for task in $(print_test_tasks "$(projects_var "${suite}" "${shard}")"); do @@ -148,6 +164,14 @@ if [ "${shard}" = "others" ]; then *) echo "Unknown suite: ${suite}" >&2; usage ;; esac else - projects="$(projects_var "${suite}" "${shard}")" - print_test_tasks "${projects}" + case "${suite}/${shard}" in + build/core-unit) echo :core:coreUnitTest ;; + build/core-h2) echo :core:coreH2Test ;; + build/core-mysql) echo :core:coreMySQLTest ;; + build/core-postgresql) echo :core:corePostgreSQLTest ;; + *) + projects="$(projects_var "${suite}" "${shard}")" + print_test_tasks "${projects}" + ;; + esac fi From 654cfe33a5cbf479885b3d4c9596172dc15ce502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:52:01 +0000 Subject: [PATCH 05/17] fix(ci): address #13517 review nits from jerryshao - Revert build.yml timeout from 90 to 120 minutes; 90 was too tight given the MySQL lane alone measures ~69 min locally with no margin. - Rename the coreH2Test/coreMySQLTest/corePostgreSQLTest inputs.property key from includeDockerTaggedTests to coreTestIncludesDockerTaggedTests, distinguishing it from the unrelated extensions.extraProperties[includeDockerTaggedTests] flag consumed by root build.gradle.kts's shared test setup. - Drop the hardcoded "()" from BackendTestExtension's getDisplayName(): @TestTemplate methods can declare real parameters, so an empty parameter list misrepresented the method's signature in JUnit XML/HTML reports. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- .github/workflows/build.yml | 2 +- core/build.gradle.kts | 5 ++++- .../gravitino/storage/relational/BackendTestExtension.java | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98d1fa691f1..31a769daaea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ jobs: java-version: [ 17 ] # Shards are defined in dev/ci/test-shards.sh. shard: ${{ fromJSON(needs.changes.outputs.build_shards) }} - timeout-minutes: 90 + timeout-minutes: 120 needs: changes if: needs.changes.outputs.source_changes == 'true' steps: diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 16d8aa7638e..12ddee1eaa0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -129,7 +129,10 @@ fun registerCoreTestTask( inputs.property("coreTestSuite", backend ?: "unit") inputs.property("coreTestBackend", backend ?: "none") - inputs.property("includeDockerTaggedTests", backend != null) + // Distinct from the extensions.extraProperties["includeDockerTaggedTests"] flag set below, + // which is a different mechanism (read by root build.gradle.kts's shared test-environment + // setup to decide JUnit tag filtering) - this is only a Gradle up-to-date-check input. + inputs.property("coreTestIncludesDockerTaggedTests", backend != null) reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) reports.html.outputLocation.set( rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index 4f32ec11e63..7dc09519136 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java @@ -145,7 +145,10 @@ public BackendInvocationContext(String testMethodName, String backendType) { @Override public String getDisplayName(int invocationIndex) { - return String.format("%s()[%s Backend]", testMethodName, backendType.toUpperCase()); + // No trailing "()" here: @TestTemplate methods can declare parameters (e.g. an injected + // DatabaseTestContext), and a hardcoded empty parameter list would misrepresent the + // method's actual signature in JUnit XML/HTML reports. + return String.format("%s[%s Backend]", testMethodName, backendType.toUpperCase()); } @Override From efee6a4c9e93ea88e4820288f53ef9a75263251b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:51:35 +0000 Subject: [PATCH 06/17] improvement(test): replace per-backend exclude-tags with @DatabaseTest + BackendLaneCondition The three excludeTags(otherTwoBackends) clauses in registerCoreTestTask allowed two silent failure modes with no build-time check: a class carrying two backend tags was excluded from every lane, and a class missing its restriction tag ran redundantly in all three. Replace them with a single typed source of truth: - DatabaseBackend: the three core database backends. - DatabaseTest: annotation declaring backends() (defaults to all three), meta- annotated with the lane-inclusion tag and @ExtendWith(BackendLaneCondition). - BackendLaneCondition: an ExecutionCondition that reads the lane's gravitino.core.test.backend system property (already published by registerCoreTestTask) and enables/disables the class accordingly, producing a visible, reported skip rather than silent exclusion from Gradle's discovery. - TestDatabaseTestClassification: a build-time reflection check (runs in coreUnitTest, no database, no Docker) asserting no @DatabaseTest(backends={}) and no leftover raw per-backend @Tag usages anywhere in core. core/build.gradle.kts's three-way excludeTags table collapses to a single includeTags(coreDatabaseTestTag); backend restriction now lives entirely in test source. Migrated all three existing usage patterns: AbstractEntityStorageTest (parameter-driven multi-backend via storageProvider()/BackendTestSelector), TestJDBCBackend (@TestTemplate-driven via BackendTestExtension), and TestJdbcPartitionStatisticStorageIT's three single-backend @Nested classes. Verified via TestBackendLaneCondition (direct unit coverage) and a real :core:coreH2Test run before/after the change: identical 520 executed tests and outcomes: the only delta is 2 classes (14 tests) that were previously excluded from Gradle's own discovery and now appear as an explicit, reported skip. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 18 +-- .../TestJdbcPartitionStatisticStorageIT.java | 15 ++- .../storage/AbstractEntityStorageTest.java | 6 +- .../relational/BackendLaneCondition.java | 64 +++++++++ .../storage/relational/DatabaseBackend.java | 48 +++++++ .../storage/relational/DatabaseTest.java | 64 +++++++++ .../relational/TestBackendLaneCondition.java | 122 ++++++++++++++++++ .../TestDatabaseTestClassification.java | 116 +++++++++++++++++ .../storage/relational/TestJDBCBackend.java | 5 +- 9 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 12ddee1eaa0..20b38f37f24 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -106,10 +106,11 @@ artifacts { add("testArtifacts", testJar) } +// Must match DatabaseTest.TAG (core/src/test/java/.../storage/relational/DatabaseTest.java) - +// build scripts can't see a project's own test-source classes at configuration time, so this +// string is kept in sync by hand; TestDatabaseTestClassificationCheck pins the literal. val coreDatabaseTestTag = "gravitino-core-database-test" -val coreH2TestTag = "gravitino-core-h2-test" -val coreMySQLTestTag = "gravitino-core-mysql-test" -val corePostgreSQLTestTag = "gravitino-core-postgresql-test" +val coreSupportedBackends = setOf("h2", "mysql", "postgresql") val coreTestBackendProperty = "gravitino.core.test.backend" fun registerCoreTestTask( @@ -146,17 +147,16 @@ fun registerCoreTestTask( if (backend == null) { excludeTags(coreDatabaseTestTag, "gravitino-docker-test") } else { + // Lane membership only; which backend(s) a @DatabaseTest class actually runs under is + // decided per-class by BackendLaneCondition, reading the system property set below. includeTags(coreDatabaseTestTag) - when (backend) { - "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) - "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) - "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) - else -> throw GradleException("Unsupported core test backend: $backend") - } } } if (backend != null) { + if (backend !in coreSupportedBackends) { + throw GradleException("Unsupported core test backend: $backend") + } systemProperty(coreTestBackendProperty, backend) extensions.extraProperties["includeDockerTaggedTests"] = true diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index 42b5938596a..c5ed94fc53d 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -58,6 +58,8 @@ import org.apache.gravitino.stats.PartitionStatisticsUpdate; import org.apache.gravitino.stats.StatisticValue; import org.apache.gravitino.stats.StatisticValues; +import org.apache.gravitino.storage.relational.DatabaseBackend; +import org.apache.gravitino.storage.relational.DatabaseTest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; @@ -85,8 +87,8 @@ *
  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ -@Tag("gravitino-core-database-test") -@Tag("gravitino-docker-test") +// Not itself a @DatabaseTest: this class holds no @Test methods of its own, only the @Nested +// classes below, each of which declares its own single-backend @DatabaseTest. public class TestJdbcPartitionStatisticStorageIT { private static final Logger LOG = @@ -94,9 +96,8 @@ public class TestJdbcPartitionStatisticStorageIT { /** * Abstract base class containing all test logic. Each database-specific test class extends this - * and implements the database setup. + * and implements the database setup, declaring its own single-backend {@code @DatabaseTest}. */ - @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { @@ -587,7 +588,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-mysql-test") + @DatabaseTest(backends = DatabaseBackend.MYSQL) @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -658,7 +659,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-postgresql-test") + @DatabaseTest(backends = DatabaseBackend.POSTGRESQL) @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -732,7 +733,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested - @Tag("gravitino-core-h2-test") + @DatabaseTest(backends = DatabaseBackend.H2) static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index ab1c8f76811..fc2e0278fc9 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -94,6 +94,7 @@ import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; import org.apache.gravitino.storage.relational.BackendTestSelector; +import org.apache.gravitino.storage.relational.DatabaseTest; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -105,12 +106,13 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@Tag("gravitino-core-database-test") +// No backends() given: every concrete subclass here runs against every backend (h2/mysql/ +// postgresql), selected per-invocation by storageProvider()'s own BackendTestSelector filtering. +@DatabaseTest abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java new file mode 100644 index 00000000000..69bf9791638 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * Restricts a {@link DatabaseTest} class to the backends it declares. Replaces the three {@code + * excludeTags(...)} clauses {@code registerCoreTestTask} used to hand-maintain per lane: with this + * condition, {@code core/build.gradle.kts} only needs {@code includeTags(coreDatabaseTestTag)} plus + * the {@code gravitino.core.test.backend} system property each lane already publishes for {@link + * BackendTestSelector}. + */ +public class BackendLaneCondition implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + Optional annotation = + context + .getTestClass() + .flatMap(c -> AnnotationSupport.findAnnotation(c, DatabaseTest.class)); + if (!annotation.isPresent()) { + return ConditionEvaluationResult.enabled("Not a @DatabaseTest class"); + } + + Optional selectedBackend = BackendTestSelector.selectedBackend(); + if (!selectedBackend.isPresent()) { + // No lane-specific backend was selected (e.g. the legacy :core:test invocation) - run + // under every backend this class declares, same as before this condition existed. + return ConditionEvaluationResult.enabled("No lane backend selected"); + } + + boolean applicable = + Arrays.stream(annotation.get().backends()) + .anyMatch(backend -> backend.propertyValue().equals(selectedBackend.get())); + + return applicable + ? ConditionEvaluationResult.enabled("Applies to backend " + selectedBackend.get()) + : ConditionEvaluationResult.disabled( + "@DatabaseTest does not declare backend " + selectedBackend.get()); + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java new file mode 100644 index 00000000000..02c94ce83b9 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +/** + * The core database backends exercised by {@code core/build.gradle.kts}'s database test lanes. The + * enum constant name is what a test declares in {@link DatabaseTest#backends()}; {@link + * #propertyValue()} is the wire value the lane tasks publish via the {@code + * gravitino.core.test.backend} system property and {@link BackendTestSelector} reads back. + */ +public enum DatabaseBackend { + H2("h2"), + MYSQL("mysql"), + POSTGRESQL("postgresql"); + + private final String propertyValue; + + DatabaseBackend(String propertyValue) { + this.propertyValue = propertyValue; + } + + /** + * Returns the value this backend is identified by in the {@code gravitino.core.test.backend} + * system property. + * + * @return the property value + */ + public String propertyValue() { + return propertyValue; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java new file mode 100644 index 00000000000..4b4b8e93dd1 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Marks a test class as a core database test and declares which backends it applies to. + * + *

    This is the single source of truth for both halves of lane membership that used to be + * expressed with two disconnected mechanisms: the {@code gravitino-core-database-test} tag (whether + * {@code core/build.gradle.kts}'s database lanes pick this class up at all, still carried via the + * meta-annotation below) and, previously, three separate backend-exclusion tags (which of the + * h2/mysql/postgresql lanes a class actually runs in). {@link #backends()} now answers both + * questions at once through {@link BackendLaneCondition}, so a class can no longer silently end up + * excluded from every lane (two conflicting restriction tags) or redundantly re-run in every lane + * (a missing restriction tag) - both are structural properties of a single annotation instead of + * independently-maintained tag sets. + * + *

    Omitting {@link #backends()} means "runs under every backend", which is an explicit, visible + * default rather than something inferred from the absence of any backend-specific tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Tag(DatabaseTest.TAG) +@ExtendWith(BackendLaneCondition.class) +public @interface DatabaseTest { + + /** The JUnit tag published for {@code core/build.gradle.kts}'s lane-inclusion filter. */ + String TAG = "gravitino-core-database-test"; + + /** + * The backends this test class applies to. Defaults to every backend. + * + * @return the applicable backends + */ + DatabaseBackend[] backends() default { + DatabaseBackend.H2, DatabaseBackend.MYSQL, DatabaseBackend.POSTGRESQL + }; +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java new file mode 100644 index 00000000000..3f252a08d4d --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Direct unit test of {@link BackendLaneCondition}, independent of any real Gradle lane execution: + * exercises the exact matrix that used to be encoded across three separate hand-maintained {@code + * excludeTags(...)} clauses in {@code core/build.gradle.kts}. + */ +class TestBackendLaneCondition { + + private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + + private final BackendLaneCondition condition = new BackendLaneCondition(); + + private String previousBackendProperty; + + @DatabaseTest + private static class AllBackendsFixture {} + + @DatabaseTest(backends = DatabaseBackend.H2) + private static class H2OnlyFixture {} + + @DatabaseTest(backends = {DatabaseBackend.MYSQL, DatabaseBackend.POSTGRESQL}) + private static class MySQLAndPostgreSQLFixture {} + + private static class NotDatabaseTestFixture {} + + @BeforeEach + void saveBackendProperty() { + previousBackendProperty = System.getProperty(BACKEND_PROPERTY); + } + + @AfterEach + void restoreBackendProperty() { + if (previousBackendProperty == null) { + System.clearProperty(BACKEND_PROPERTY); + } else { + System.setProperty(BACKEND_PROPERTY, previousBackendProperty); + } + } + + @Test + void nonDatabaseTestClassIsAlwaysEnabled() { + System.setProperty(BACKEND_PROPERTY, "mysql"); + assertTrue(evaluate(NotDatabaseTestFixture.class)); + } + + @Test + void noBackendSelectedRunsUnderEveryDeclaredBackend() { + System.clearProperty(BACKEND_PROPERTY); + assertTrue(evaluate(H2OnlyFixture.class)); + assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); + assertTrue(evaluate(AllBackendsFixture.class)); + } + + @Test + void allBackendsFixtureIsEnabledUnderEveryLane() { + for (String backend : new String[] {"h2", "mysql", "postgresql"}) { + System.setProperty(BACKEND_PROPERTY, backend); + assertTrue(evaluate(AllBackendsFixture.class), "expected enabled for " + backend); + } + } + + @Test + void h2OnlyFixtureRunsOnlyInTheH2Lane() { + System.setProperty(BACKEND_PROPERTY, "h2"); + assertTrue(evaluate(H2OnlyFixture.class)); + + System.setProperty(BACKEND_PROPERTY, "mysql"); + assertFalse(evaluate(H2OnlyFixture.class)); + + System.setProperty(BACKEND_PROPERTY, "postgresql"); + assertFalse(evaluate(H2OnlyFixture.class)); + } + + @Test + void multiBackendFixtureRunsOnlyInItsDeclaredLanes() { + System.setProperty(BACKEND_PROPERTY, "h2"); + assertFalse(evaluate(MySQLAndPostgreSQLFixture.class)); + + System.setProperty(BACKEND_PROPERTY, "mysql"); + assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); + + System.setProperty(BACKEND_PROPERTY, "postgresql"); + assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); + } + + private boolean evaluate(Class testClass) { + ExtensionContext context = mock(ExtensionContext.class); + when(context.getTestClass()).thenReturn(Optional.of(testClass)); + return !condition.evaluateExecutionCondition(context).isDisabled(); + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java new file mode 100644 index 00000000000..3933adcca10 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.reflect.ClassPath; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * Structurally guards the two failure modes the old per-backend exclude-tag scheme allowed + * silently: a class excluded from every database lane (an empty {@code backends()}), and a class + * still using the raw {@code @Tag} strings {@link DatabaseTest} replaced instead of the annotation + * itself. Runs in {@code coreUnitTest} on every build - no database, no Docker. + * + *

    Class-level only: lane membership (which this checks) is a property of the class, not of + * individual {@code @ParameterizedTest}/{@code @TestTemplate} invocations, so this sidesteps the + * static-discovery limitations of trying to enumerate invocations ahead of execution. + */ +class TestDatabaseTestClassification { + + private static final Set LEGACY_BACKEND_TAGS = + Set.of( + "gravitino-core-h2-test", "gravitino-core-mysql-test", "gravitino-core-postgresql-test"); + + @Test + void everyDatabaseTestDeclaresAtLeastOneBackendAndNoClassUsesTheRetiredRawTags() + throws IOException { + List emptyBackends = new ArrayList<>(); + List legacyRawTags = new ArrayList<>(); + List rawDatabaseTag = new ArrayList<>(); + + for (ClassPath.ClassInfo info : + ClassPath.from(getClass().getClassLoader()) + .getTopLevelClassesRecursive("org.apache.gravitino")) { + Class candidate; + try { + candidate = info.load(); + } catch (LinkageError e) { + // A class that can't even be linked on this classpath can't be a test class we care + // about; skip it rather than fail the whole scan. + continue; + } + if (candidate.isInterface() || Modifier.isAbstract(candidate.getModifiers())) { + continue; + } + + AnnotationSupport.findAnnotation(candidate, DatabaseTest.class) + .ifPresent( + annotation -> { + if (annotation.backends().length == 0) { + emptyBackends.add(candidate.getName()); + } + }); + + for (Annotation direct : candidate.getDeclaredAnnotations()) { + if (!(direct instanceof Tag)) { + continue; + } + String value = ((Tag) direct).value(); + if (LEGACY_BACKEND_TAGS.contains(value)) { + legacyRawTags.add(candidate.getName() + " -> " + value); + } else if (DatabaseTest.TAG.equals(value)) { + rawDatabaseTag.add(candidate.getName()); + } + } + } + + assertTrue( + emptyBackends.isEmpty(), + "@DatabaseTest(backends = {}) declares a class applicable to no backend, which is " + + "excluded from every database lane - the exact silent failure mode this check " + + "exists to catch:\n" + + String.join("\n", emptyBackends)); + + assertTrue( + legacyRawTags.isEmpty(), + "These classes still use the retired per-backend @Tag strings directly; use " + + "@DatabaseTest(backends = ...) instead so BackendLaneCondition enforces the " + + "restriction:\n" + + String.join("\n", legacyRawTags)); + + assertTrue( + rawDatabaseTag.isEmpty(), + "These classes declare @Tag(\"" + + DatabaseTest.TAG + + "\") directly instead of @DatabaseTest; use @DatabaseTest so backend restriction " + + "is enforced consistently:\n" + + String.join("\n", rawDatabaseTag)); + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index ac7cc18fec1..5aed974f698 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,11 +81,12 @@ import org.apache.gravitino.utils.NamespaceUtil; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; -@Tag("gravitino-core-database-test") +// No backends() given: BackendTestExtension itself drives which backends each @TestTemplate +// invocation runs against, so this class applies to every backend. +@DatabaseTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, From 547e9bdf03e8609e2a460eb465b914c6322d95c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:19:13 +0000 Subject: [PATCH 07/17] Revert "improvement(test): replace per-backend exclude-tags with @DatabaseTest + BackendLaneCondition" This reverts commit efee6a4c9e93ea88e4820288f53ef9a75263251b. --- core/build.gradle.kts | 18 +-- .../TestJdbcPartitionStatisticStorageIT.java | 15 +-- .../storage/AbstractEntityStorageTest.java | 6 +- .../relational/BackendLaneCondition.java | 64 --------- .../storage/relational/DatabaseBackend.java | 48 ------- .../storage/relational/DatabaseTest.java | 64 --------- .../relational/TestBackendLaneCondition.java | 122 ------------------ .../TestDatabaseTestClassification.java | 116 ----------------- .../storage/relational/TestJDBCBackend.java | 5 +- 9 files changed, 20 insertions(+), 438 deletions(-) delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 20b38f37f24..12ddee1eaa0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -106,11 +106,10 @@ artifacts { add("testArtifacts", testJar) } -// Must match DatabaseTest.TAG (core/src/test/java/.../storage/relational/DatabaseTest.java) - -// build scripts can't see a project's own test-source classes at configuration time, so this -// string is kept in sync by hand; TestDatabaseTestClassificationCheck pins the literal. val coreDatabaseTestTag = "gravitino-core-database-test" -val coreSupportedBackends = setOf("h2", "mysql", "postgresql") +val coreH2TestTag = "gravitino-core-h2-test" +val coreMySQLTestTag = "gravitino-core-mysql-test" +val corePostgreSQLTestTag = "gravitino-core-postgresql-test" val coreTestBackendProperty = "gravitino.core.test.backend" fun registerCoreTestTask( @@ -147,16 +146,17 @@ fun registerCoreTestTask( if (backend == null) { excludeTags(coreDatabaseTestTag, "gravitino-docker-test") } else { - // Lane membership only; which backend(s) a @DatabaseTest class actually runs under is - // decided per-class by BackendLaneCondition, reading the system property set below. includeTags(coreDatabaseTestTag) + when (backend) { + "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) + "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) + "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) + else -> throw GradleException("Unsupported core test backend: $backend") + } } } if (backend != null) { - if (backend !in coreSupportedBackends) { - throw GradleException("Unsupported core test backend: $backend") - } systemProperty(coreTestBackendProperty, backend) extensions.extraProperties["includeDockerTaggedTests"] = true diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index c5ed94fc53d..42b5938596a 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -58,8 +58,6 @@ import org.apache.gravitino.stats.PartitionStatisticsUpdate; import org.apache.gravitino.stats.StatisticValue; import org.apache.gravitino.stats.StatisticValues; -import org.apache.gravitino.storage.relational.DatabaseBackend; -import org.apache.gravitino.storage.relational.DatabaseTest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; @@ -87,8 +85,8 @@ *

  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ -// Not itself a @DatabaseTest: this class holds no @Test methods of its own, only the @Nested -// classes below, each of which declares its own single-backend @DatabaseTest. +@Tag("gravitino-core-database-test") +@Tag("gravitino-docker-test") public class TestJdbcPartitionStatisticStorageIT { private static final Logger LOG = @@ -96,8 +94,9 @@ public class TestJdbcPartitionStatisticStorageIT { /** * Abstract base class containing all test logic. Each database-specific test class extends this - * and implements the database setup, declaring its own single-backend {@code @DatabaseTest}. + * and implements the database setup. */ + @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { @@ -588,7 +587,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested - @DatabaseTest(backends = DatabaseBackend.MYSQL) + @Tag("gravitino-core-mysql-test") @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -659,7 +658,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested - @DatabaseTest(backends = DatabaseBackend.POSTGRESQL) + @Tag("gravitino-core-postgresql-test") @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -733,7 +732,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested - @DatabaseTest(backends = DatabaseBackend.H2) + @Tag("gravitino-core-h2-test") static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index fc2e0278fc9..ab1c8f76811 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -94,7 +94,6 @@ import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; import org.apache.gravitino.storage.relational.BackendTestSelector; -import org.apache.gravitino.storage.relational.DatabaseTest; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -106,13 +105,12 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// No backends() given: every concrete subclass here runs against every backend (h2/mysql/ -// postgresql), selected per-invocation by storageProvider()'s own BackendTestSelector filtering. -@DatabaseTest +@Tag("gravitino-core-database-test") abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java deleted file mode 100644 index 69bf9791638..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.gravitino.storage.relational; - -import java.util.Arrays; -import java.util.Optional; -import org.junit.jupiter.api.extension.ConditionEvaluationResult; -import org.junit.jupiter.api.extension.ExecutionCondition; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.platform.commons.support.AnnotationSupport; - -/** - * Restricts a {@link DatabaseTest} class to the backends it declares. Replaces the three {@code - * excludeTags(...)} clauses {@code registerCoreTestTask} used to hand-maintain per lane: with this - * condition, {@code core/build.gradle.kts} only needs {@code includeTags(coreDatabaseTestTag)} plus - * the {@code gravitino.core.test.backend} system property each lane already publishes for {@link - * BackendTestSelector}. - */ -public class BackendLaneCondition implements ExecutionCondition { - - @Override - public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { - Optional annotation = - context - .getTestClass() - .flatMap(c -> AnnotationSupport.findAnnotation(c, DatabaseTest.class)); - if (!annotation.isPresent()) { - return ConditionEvaluationResult.enabled("Not a @DatabaseTest class"); - } - - Optional selectedBackend = BackendTestSelector.selectedBackend(); - if (!selectedBackend.isPresent()) { - // No lane-specific backend was selected (e.g. the legacy :core:test invocation) - run - // under every backend this class declares, same as before this condition existed. - return ConditionEvaluationResult.enabled("No lane backend selected"); - } - - boolean applicable = - Arrays.stream(annotation.get().backends()) - .anyMatch(backend -> backend.propertyValue().equals(selectedBackend.get())); - - return applicable - ? ConditionEvaluationResult.enabled("Applies to backend " + selectedBackend.get()) - : ConditionEvaluationResult.disabled( - "@DatabaseTest does not declare backend " + selectedBackend.get()); - } -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java deleted file mode 100644 index 02c94ce83b9..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.gravitino.storage.relational; - -/** - * The core database backends exercised by {@code core/build.gradle.kts}'s database test lanes. The - * enum constant name is what a test declares in {@link DatabaseTest#backends()}; {@link - * #propertyValue()} is the wire value the lane tasks publish via the {@code - * gravitino.core.test.backend} system property and {@link BackendTestSelector} reads back. - */ -public enum DatabaseBackend { - H2("h2"), - MYSQL("mysql"), - POSTGRESQL("postgresql"); - - private final String propertyValue; - - DatabaseBackend(String propertyValue) { - this.propertyValue = propertyValue; - } - - /** - * Returns the value this backend is identified by in the {@code gravitino.core.test.backend} - * system property. - * - * @return the property value - */ - public String propertyValue() { - return propertyValue; - } -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java deleted file mode 100644 index 4b4b8e93dd1..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.gravitino.storage.relational; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.extension.ExtendWith; - -/** - * Marks a test class as a core database test and declares which backends it applies to. - * - *

    This is the single source of truth for both halves of lane membership that used to be - * expressed with two disconnected mechanisms: the {@code gravitino-core-database-test} tag (whether - * {@code core/build.gradle.kts}'s database lanes pick this class up at all, still carried via the - * meta-annotation below) and, previously, three separate backend-exclusion tags (which of the - * h2/mysql/postgresql lanes a class actually runs in). {@link #backends()} now answers both - * questions at once through {@link BackendLaneCondition}, so a class can no longer silently end up - * excluded from every lane (two conflicting restriction tags) or redundantly re-run in every lane - * (a missing restriction tag) - both are structural properties of a single annotation instead of - * independently-maintained tag sets. - * - *

    Omitting {@link #backends()} means "runs under every backend", which is an explicit, visible - * default rather than something inferred from the absence of any backend-specific tag. - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -@Inherited -@Tag(DatabaseTest.TAG) -@ExtendWith(BackendLaneCondition.class) -public @interface DatabaseTest { - - /** The JUnit tag published for {@code core/build.gradle.kts}'s lane-inclusion filter. */ - String TAG = "gravitino-core-database-test"; - - /** - * The backends this test class applies to. Defaults to every backend. - * - * @return the applicable backends - */ - DatabaseBackend[] backends() default { - DatabaseBackend.H2, DatabaseBackend.MYSQL, DatabaseBackend.POSTGRESQL - }; -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java deleted file mode 100644 index 3f252a08d4d..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.gravitino.storage.relational; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtensionContext; - -/** - * Direct unit test of {@link BackendLaneCondition}, independent of any real Gradle lane execution: - * exercises the exact matrix that used to be encoded across three separate hand-maintained {@code - * excludeTags(...)} clauses in {@code core/build.gradle.kts}. - */ -class TestBackendLaneCondition { - - private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; - - private final BackendLaneCondition condition = new BackendLaneCondition(); - - private String previousBackendProperty; - - @DatabaseTest - private static class AllBackendsFixture {} - - @DatabaseTest(backends = DatabaseBackend.H2) - private static class H2OnlyFixture {} - - @DatabaseTest(backends = {DatabaseBackend.MYSQL, DatabaseBackend.POSTGRESQL}) - private static class MySQLAndPostgreSQLFixture {} - - private static class NotDatabaseTestFixture {} - - @BeforeEach - void saveBackendProperty() { - previousBackendProperty = System.getProperty(BACKEND_PROPERTY); - } - - @AfterEach - void restoreBackendProperty() { - if (previousBackendProperty == null) { - System.clearProperty(BACKEND_PROPERTY); - } else { - System.setProperty(BACKEND_PROPERTY, previousBackendProperty); - } - } - - @Test - void nonDatabaseTestClassIsAlwaysEnabled() { - System.setProperty(BACKEND_PROPERTY, "mysql"); - assertTrue(evaluate(NotDatabaseTestFixture.class)); - } - - @Test - void noBackendSelectedRunsUnderEveryDeclaredBackend() { - System.clearProperty(BACKEND_PROPERTY); - assertTrue(evaluate(H2OnlyFixture.class)); - assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); - assertTrue(evaluate(AllBackendsFixture.class)); - } - - @Test - void allBackendsFixtureIsEnabledUnderEveryLane() { - for (String backend : new String[] {"h2", "mysql", "postgresql"}) { - System.setProperty(BACKEND_PROPERTY, backend); - assertTrue(evaluate(AllBackendsFixture.class), "expected enabled for " + backend); - } - } - - @Test - void h2OnlyFixtureRunsOnlyInTheH2Lane() { - System.setProperty(BACKEND_PROPERTY, "h2"); - assertTrue(evaluate(H2OnlyFixture.class)); - - System.setProperty(BACKEND_PROPERTY, "mysql"); - assertFalse(evaluate(H2OnlyFixture.class)); - - System.setProperty(BACKEND_PROPERTY, "postgresql"); - assertFalse(evaluate(H2OnlyFixture.class)); - } - - @Test - void multiBackendFixtureRunsOnlyInItsDeclaredLanes() { - System.setProperty(BACKEND_PROPERTY, "h2"); - assertFalse(evaluate(MySQLAndPostgreSQLFixture.class)); - - System.setProperty(BACKEND_PROPERTY, "mysql"); - assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); - - System.setProperty(BACKEND_PROPERTY, "postgresql"); - assertTrue(evaluate(MySQLAndPostgreSQLFixture.class)); - } - - private boolean evaluate(Class testClass) { - ExtensionContext context = mock(ExtensionContext.class); - when(context.getTestClass()).thenReturn(Optional.of(testClass)); - return !condition.evaluateExecutionCondition(context).isDisabled(); - } -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java deleted file mode 100644 index 3933adcca10..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.gravitino.storage.relational; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.common.reflect.ClassPath; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.platform.commons.support.AnnotationSupport; - -/** - * Structurally guards the two failure modes the old per-backend exclude-tag scheme allowed - * silently: a class excluded from every database lane (an empty {@code backends()}), and a class - * still using the raw {@code @Tag} strings {@link DatabaseTest} replaced instead of the annotation - * itself. Runs in {@code coreUnitTest} on every build - no database, no Docker. - * - *

    Class-level only: lane membership (which this checks) is a property of the class, not of - * individual {@code @ParameterizedTest}/{@code @TestTemplate} invocations, so this sidesteps the - * static-discovery limitations of trying to enumerate invocations ahead of execution. - */ -class TestDatabaseTestClassification { - - private static final Set LEGACY_BACKEND_TAGS = - Set.of( - "gravitino-core-h2-test", "gravitino-core-mysql-test", "gravitino-core-postgresql-test"); - - @Test - void everyDatabaseTestDeclaresAtLeastOneBackendAndNoClassUsesTheRetiredRawTags() - throws IOException { - List emptyBackends = new ArrayList<>(); - List legacyRawTags = new ArrayList<>(); - List rawDatabaseTag = new ArrayList<>(); - - for (ClassPath.ClassInfo info : - ClassPath.from(getClass().getClassLoader()) - .getTopLevelClassesRecursive("org.apache.gravitino")) { - Class candidate; - try { - candidate = info.load(); - } catch (LinkageError e) { - // A class that can't even be linked on this classpath can't be a test class we care - // about; skip it rather than fail the whole scan. - continue; - } - if (candidate.isInterface() || Modifier.isAbstract(candidate.getModifiers())) { - continue; - } - - AnnotationSupport.findAnnotation(candidate, DatabaseTest.class) - .ifPresent( - annotation -> { - if (annotation.backends().length == 0) { - emptyBackends.add(candidate.getName()); - } - }); - - for (Annotation direct : candidate.getDeclaredAnnotations()) { - if (!(direct instanceof Tag)) { - continue; - } - String value = ((Tag) direct).value(); - if (LEGACY_BACKEND_TAGS.contains(value)) { - legacyRawTags.add(candidate.getName() + " -> " + value); - } else if (DatabaseTest.TAG.equals(value)) { - rawDatabaseTag.add(candidate.getName()); - } - } - } - - assertTrue( - emptyBackends.isEmpty(), - "@DatabaseTest(backends = {}) declares a class applicable to no backend, which is " - + "excluded from every database lane - the exact silent failure mode this check " - + "exists to catch:\n" - + String.join("\n", emptyBackends)); - - assertTrue( - legacyRawTags.isEmpty(), - "These classes still use the retired per-backend @Tag strings directly; use " - + "@DatabaseTest(backends = ...) instead so BackendLaneCondition enforces the " - + "restriction:\n" - + String.join("\n", legacyRawTags)); - - assertTrue( - rawDatabaseTag.isEmpty(), - "These classes declare @Tag(\"" - + DatabaseTest.TAG - + "\") directly instead of @DatabaseTest; use @DatabaseTest so backend restriction " - + "is enforced consistently:\n" - + String.join("\n", rawDatabaseTag)); - } -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 5aed974f698..ac7cc18fec1 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,12 +81,11 @@ import org.apache.gravitino.utils.NamespaceUtil; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; -// No backends() given: BackendTestExtension itself drives which backends each @TestTemplate -// invocation runs against, so this class applies to every backend. -@DatabaseTest +@Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, From f1f7644868dda9dcf674961f189c10af040e3ca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:24:08 +0000 Subject: [PATCH 08/17] fix(test): update TestBackendTestSelector for getDisplayName() format change 654cfe33a dropped the hardcoded "()" from BackendTestExtension's getDisplayName() but didn't update this test's assertions, leaving coreUnitTest red on this branch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- .../org/apache/gravitino/storage/TestBackendTestSelector.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java index 68e57488392..642d70a1110 100644 --- a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java +++ b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java @@ -89,8 +89,8 @@ void testTemplateProviderUsesSelectedBackendAndMethodName() throws NoSuchMethodE String secondDisplayName = selectedInvocation(extension, "secondTemplateMethod").getDisplayName(1); - assertEquals("firstTemplateMethod()[MYSQL Backend]", firstDisplayName); - assertEquals("secondTemplateMethod()[MYSQL Backend]", secondDisplayName); + assertEquals("firstTemplateMethod[MYSQL Backend]", firstDisplayName); + assertEquals("secondTemplateMethod[MYSQL Backend]", secondDisplayName); assertNotEquals(firstDisplayName, secondDisplayName); } From bed4a938f051540f1ac6fe0f7ad6ab4333e3053a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:24:23 +0000 Subject: [PATCH 09/17] improvement(ci): remove the unwired compare-legacy subcommand compare_legacy_partition() (and its "compare-legacy" CLI subcommand) compared a legacy :core:test manifest against the union of the four split-lane manifests, but nothing in .github/workflows/build.yml ever called it - dev/ci/test-shards.sh excludes :core:test from CI entirely, so no legacy manifest is ever produced to compare against. manifest and reconcile, which ARE CI-wired, are untouched. Removes the dead function, its argparse subcommand, its dedicated unit tests, and the legacy/ fixture directory only they consumed. Drops the now-unused "legacy" choice from MANIFEST_LANES. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- dev/ci/core_test_identity.py | 84 +------------------ .../core_test_identity/legacy/TEST-legacy.xml | 49 ----------- dev/ci/tests/test_core_test_identity.py | 76 ----------------- 3 files changed, 3 insertions(+), 206 deletions(-) delete mode 100644 dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml diff --git a/dev/ci/core_test_identity.py b/dev/ci/core_test_identity.py index 84b888126be..c11c22cfe16 100644 --- a/dev/ci/core_test_identity.py +++ b/dev/ci/core_test_identity.py @@ -21,9 +21,7 @@ Gradle writes one JUnit XML directory per Test task. This tool turns those reports into stable, backend-neutral identity multisets so the H2, MySQL, and PostgreSQL lanes can prove that they exercised the same test contract. It also -records status counts and elapsed test time for CI artifacts. A separate -legacy comparison proves that the four split lanes preserve the exact test -multiset previously exercised by ``:core:test``. +records status counts and elapsed test time for CI artifacts. """ import argparse @@ -41,7 +39,7 @@ SCHEMA_VERSION = 1 LANES = ("unit", "h2", "mysql", "postgresql") DATABASE_LANES = LANES[1:] -MANIFEST_LANES = LANES + ("legacy",) +MANIFEST_LANES = LANES STATUS_KEYS = ("passed", "skipped", "failures", "errors") BACKEND_NAME_PATTERN = r"h2|mysql|postgresql" @@ -400,14 +398,6 @@ def _load_split_manifests(manifest_files): return by_lane, counters -def _combine_counters(counters): - """Combine identity Counters without changing their inputs.""" - combined = Counter() - for counter in counters: - combined.update(counter) - return combined - - def _lane_summary(manifest, identities): """Return the evidence retained for one successfully loaded lane.""" return { @@ -471,49 +461,6 @@ def reconcile_manifests(manifest_files): } -def compare_legacy_partition(legacy_manifest_file, split_manifest_files): - """Prove that legacy core:test equals the exact sum of the split lanes.""" - legacy_lane, legacy_manifest, legacy_identities = _load_manifest( - legacy_manifest_file - ) - if legacy_lane != "legacy": - raise ManifestError( - f"Expected a legacy manifest, got lane {legacy_lane} from {legacy_manifest_file}" - ) - - reconciliation = reconcile_manifests(split_manifest_files) - split_manifests, split_counters = _load_split_manifests(split_manifest_files) - split_identities = _combine_counters(split_counters[lane] for lane in LANES) - if legacy_identities != split_identities: - difference = _format_identity_difference(legacy_identities, split_identities) - raise ManifestError(f"Legacy/split identity mismatch: {difference}") - - split_statuses = { - key: sum(split_manifests[lane]["status_counts"][key] for lane in LANES) - for key in STATUS_KEYS - } - return { - "schema_version": SCHEMA_VERSION, - "successful": True, - "partition_equal": True, - "legacy": _lane_summary(legacy_manifest, legacy_identities), - "split": { - "test_count": sum(split_identities.values()), - "unique_identity_count": len(split_identities), - "duration_seconds": reconciliation["combined_duration_seconds"], - "status_counts": split_statuses, - "source_file_count": sum( - len(split_manifests[lane]["source_files"]) for lane in LANES - ), - "source_files": { - lane: split_manifests[lane]["source_files"] for lane in LANES - }, - "identity_digest": _identity_digest(split_identities), - }, - "lanes": reconciliation["lanes"], - } - - def _create_argument_parser(): """Create the command-line parser.""" parser = argparse.ArgumentParser(description=__doc__) @@ -539,27 +486,6 @@ def _create_argument_parser(): reconcile_parser.add_argument( "--output", required=True, type=Path, help="Combined JSON summary to write" ) - - comparison_parser = subparsers.add_parser( - "compare-legacy", - help="Compare a legacy core:test manifest with the four split lanes", - ) - comparison_parser.add_argument( - "--legacy-manifest", - required=True, - type=Path, - help="Manifest generated from the legacy :core:test results", - ) - comparison_parser.add_argument( - "--split-manifests", - required=True, - nargs="+", - type=Path, - help="The unit, H2, MySQL, and PostgreSQL manifests", - ) - comparison_parser.add_argument( - "--output", required=True, type=Path, help="Partition comparison JSON to write" - ) return parser @@ -570,12 +496,8 @@ def main(argv=None): try: if args.command == "manifest": document = build_manifest(args.lane, args.results) - elif args.command == "reconcile": - document = reconcile_manifests(args.manifests) else: - document = compare_legacy_partition( - args.legacy_manifest, args.split_manifests - ) + document = reconcile_manifests(args.manifests) write_json(document, args.output) except ManifestError as error: print(f"error: {error}", file=sys.stderr) diff --git a/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml b/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml deleted file mode 100644 index e1b8809b57e..00000000000 --- a/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py index 045f60034e6..23959b4bda5 100644 --- a/dev/ci/tests/test_core_test_identity.py +++ b/dev/ci/tests/test_core_test_identity.py @@ -290,51 +290,6 @@ def test_reconcile_rejects_tampered_identity_evidence(self): ): core_test_identity.reconcile_manifests(manifest_files) - def test_compare_legacy_partition_requires_exact_split_multiset(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - split_manifest_files = [] - for lane in core_test_identity.LANES: - manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - split_manifest_files.append(manifest_file) - - legacy = core_test_identity.build_manifest( - "legacy", FIXTURES / "legacy" - ) - legacy_file = output_directory / "legacy.json" - core_test_identity.write_json(legacy, legacy_file) - comparison = core_test_identity.compare_legacy_partition( - legacy_file, split_manifest_files - ) - - self.assertTrue(comparison["successful"]) - self.assertTrue(comparison["partition_equal"]) - self.assertEqual(comparison["legacy"]["test_count"], 21) - self.assertEqual(comparison["split"]["test_count"], 21) - self.assertEqual( - comparison["legacy"]["identity_digest"], - comparison["split"]["identity_digest"], - ) - self.assertEqual( - comparison["legacy"]["source_files"], ["TEST-legacy.xml"] - ) - - mismatched = copy.deepcopy(legacy) - mismatched["identities"][0]["name"] += "-different" - mismatched["identity_digest"] = core_test_identity._identity_digest( - identity_counter(mismatched) - ) - mismatched_file = output_directory / "legacy-mismatched.json" - core_test_identity.write_json(mismatched, mismatched_file) - with self.assertRaisesRegex( - core_test_identity.ManifestError, "Legacy/split identity mismatch" - ): - core_test_identity.compare_legacy_partition( - mismatched_file, split_manifest_files - ) - def test_cli_writes_manifest_and_reconciliation_output(self): with tempfile.TemporaryDirectory() as temp_dir: output_directory = Path(temp_dir) @@ -371,37 +326,6 @@ def test_cli_writes_manifest_and_reconciliation_output(self): summary = json.load(source) self.assertTrue(summary["database_identities_equal"]) - legacy_manifest_file = output_directory / "legacy.json" - return_code = core_test_identity.main( - [ - "manifest", - "--lane", - "legacy", - "--results", - str(FIXTURES / "legacy"), - "--output", - str(legacy_manifest_file), - ] - ) - self.assertEqual(return_code, 0) - - comparison_file = output_directory / "legacy-comparison.json" - return_code = core_test_identity.main( - [ - "compare-legacy", - "--legacy-manifest", - str(legacy_manifest_file), - "--split-manifests", - *(str(path) for path in manifest_files), - "--output", - str(comparison_file), - ] - ) - self.assertEqual(return_code, 0) - with comparison_file.open(encoding="utf-8") as source: - comparison = json.load(source) - self.assertTrue(comparison["partition_equal"]) - if __name__ == "__main__": unittest.main() From 46eef2130bf62fb36646941afa5bb02233b188f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:34:32 +0000 Subject: [PATCH 10/17] fix(ci): use a JUnit tag expression for core database lane exclusion The three excludeTags(otherTwoBackends) clauses in registerCoreTestTask excluded a class from every lane if it carried two conflicting backend tags, instead of running it under each tagged backend. Replace the hand-written per-backend cases with one JUnit boolean tag expression built from a backend->tag map: exclude a class only when it is pinned to another backend and not to this one. A class tagged for several backends now runs under each of them; adding a backend is one map entry. Still a discovery-time PostDiscoveryFilter (same as the tags it replaces), so an excluded class never appears in a lane's JUnit XML - core_test_identity.py's foreign-backend-marker check, which is wired into CI, still sees exactly what it did before. Verified against a real :core:coreH2Test run: reproduced the original bug on the old scheme (a double-tagged class silently excluded from every lane), then confirmed the fix runs it correctly with a clean, single-class XML directory and a passing manifest step. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 27 ++++++--- dev/ci/tests/test_core_test_identity.py | 76 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 12ddee1eaa0..e9231d5e573 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -107,9 +107,15 @@ artifacts { } val coreDatabaseTestTag = "gravitino-core-database-test" -val coreH2TestTag = "gravitino-core-h2-test" -val coreMySQLTestTag = "gravitino-core-mysql-test" -val corePostgreSQLTestTag = "gravitino-core-postgresql-test" +// Backend name -> JUnit tag that pins a database test class to that backend. A database test +// class with none of these tags runs under every backend lane. Adding a backend here is enough +// to teach the lane filtering below about it. +val coreBackendTestTags = + linkedMapOf( + "h2" to "gravitino-core-h2-test", + "mysql" to "gravitino-core-mysql-test", + "postgresql" to "gravitino-core-postgresql-test" + ) val coreTestBackendProperty = "gravitino.core.test.backend" fun registerCoreTestTask( @@ -146,13 +152,16 @@ fun registerCoreTestTask( if (backend == null) { excludeTags(coreDatabaseTestTag, "gravitino-docker-test") } else { + val ownBackendTag = + coreBackendTestTags[backend] + ?: throw GradleException("Unsupported core test backend: $backend") + val otherBackendTags = coreBackendTestTags.values.filter { it != ownBackendTag } includeTags(coreDatabaseTestTag) - when (backend) { - "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) - "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) - "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) - else -> throw GradleException("Unsupported core test backend: $backend") - } + // JUnit tag expression, still applied at discovery time like a plain tag list, so + // excluded classes never show up in this lane's JUnit XML. Exclude a class only when + // it is pinned to another backend and NOT to this one: a class tagged for several + // backends then runs under each of them instead of being dropped from all lanes. + excludeTags("!$ownBackendTag & (${otherBackendTags.joinToString(" | ")})") } } diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py index 23959b4bda5..045f60034e6 100644 --- a/dev/ci/tests/test_core_test_identity.py +++ b/dev/ci/tests/test_core_test_identity.py @@ -290,6 +290,51 @@ def test_reconcile_rejects_tampered_identity_evidence(self): ): core_test_identity.reconcile_manifests(manifest_files) + def test_compare_legacy_partition_requires_exact_split_multiset(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + split_manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + split_manifest_files.append(manifest_file) + + legacy = core_test_identity.build_manifest( + "legacy", FIXTURES / "legacy" + ) + legacy_file = output_directory / "legacy.json" + core_test_identity.write_json(legacy, legacy_file) + comparison = core_test_identity.compare_legacy_partition( + legacy_file, split_manifest_files + ) + + self.assertTrue(comparison["successful"]) + self.assertTrue(comparison["partition_equal"]) + self.assertEqual(comparison["legacy"]["test_count"], 21) + self.assertEqual(comparison["split"]["test_count"], 21) + self.assertEqual( + comparison["legacy"]["identity_digest"], + comparison["split"]["identity_digest"], + ) + self.assertEqual( + comparison["legacy"]["source_files"], ["TEST-legacy.xml"] + ) + + mismatched = copy.deepcopy(legacy) + mismatched["identities"][0]["name"] += "-different" + mismatched["identity_digest"] = core_test_identity._identity_digest( + identity_counter(mismatched) + ) + mismatched_file = output_directory / "legacy-mismatched.json" + core_test_identity.write_json(mismatched, mismatched_file) + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Legacy/split identity mismatch" + ): + core_test_identity.compare_legacy_partition( + mismatched_file, split_manifest_files + ) + def test_cli_writes_manifest_and_reconciliation_output(self): with tempfile.TemporaryDirectory() as temp_dir: output_directory = Path(temp_dir) @@ -326,6 +371,37 @@ def test_cli_writes_manifest_and_reconciliation_output(self): summary = json.load(source) self.assertTrue(summary["database_identities_equal"]) + legacy_manifest_file = output_directory / "legacy.json" + return_code = core_test_identity.main( + [ + "manifest", + "--lane", + "legacy", + "--results", + str(FIXTURES / "legacy"), + "--output", + str(legacy_manifest_file), + ] + ) + self.assertEqual(return_code, 0) + + comparison_file = output_directory / "legacy-comparison.json" + return_code = core_test_identity.main( + [ + "compare-legacy", + "--legacy-manifest", + str(legacy_manifest_file), + "--split-manifests", + *(str(path) for path in manifest_files), + "--output", + str(comparison_file), + ] + ) + self.assertEqual(return_code, 0) + with comparison_file.open(encoding="utf-8") as source: + comparison = json.load(source) + self.assertTrue(comparison["partition_equal"]) + if __name__ == "__main__": unittest.main() From 7e46c448ada95273177f5004edc0c9914caaebb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:35:03 +0000 Subject: [PATCH 11/17] fix(ci): revert accidental test_core_test_identity.py inclusion The previous commit unintentionally bundled in a restore of this file to its pre-compare-legacy-removal state (2 failing tests, referencing functions and fixtures bed4a938f already removed) alongside the unrelated core/build.gradle.kts fix. Not the intended change; reverting to bed4a938f's version. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- dev/ci/tests/test_core_test_identity.py | 76 ------------------------- 1 file changed, 76 deletions(-) diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py index 045f60034e6..23959b4bda5 100644 --- a/dev/ci/tests/test_core_test_identity.py +++ b/dev/ci/tests/test_core_test_identity.py @@ -290,51 +290,6 @@ def test_reconcile_rejects_tampered_identity_evidence(self): ): core_test_identity.reconcile_manifests(manifest_files) - def test_compare_legacy_partition_requires_exact_split_multiset(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - split_manifest_files = [] - for lane in core_test_identity.LANES: - manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - split_manifest_files.append(manifest_file) - - legacy = core_test_identity.build_manifest( - "legacy", FIXTURES / "legacy" - ) - legacy_file = output_directory / "legacy.json" - core_test_identity.write_json(legacy, legacy_file) - comparison = core_test_identity.compare_legacy_partition( - legacy_file, split_manifest_files - ) - - self.assertTrue(comparison["successful"]) - self.assertTrue(comparison["partition_equal"]) - self.assertEqual(comparison["legacy"]["test_count"], 21) - self.assertEqual(comparison["split"]["test_count"], 21) - self.assertEqual( - comparison["legacy"]["identity_digest"], - comparison["split"]["identity_digest"], - ) - self.assertEqual( - comparison["legacy"]["source_files"], ["TEST-legacy.xml"] - ) - - mismatched = copy.deepcopy(legacy) - mismatched["identities"][0]["name"] += "-different" - mismatched["identity_digest"] = core_test_identity._identity_digest( - identity_counter(mismatched) - ) - mismatched_file = output_directory / "legacy-mismatched.json" - core_test_identity.write_json(mismatched, mismatched_file) - with self.assertRaisesRegex( - core_test_identity.ManifestError, "Legacy/split identity mismatch" - ): - core_test_identity.compare_legacy_partition( - mismatched_file, split_manifest_files - ) - def test_cli_writes_manifest_and_reconciliation_output(self): with tempfile.TemporaryDirectory() as temp_dir: output_directory = Path(temp_dir) @@ -371,37 +326,6 @@ def test_cli_writes_manifest_and_reconciliation_output(self): summary = json.load(source) self.assertTrue(summary["database_identities_equal"]) - legacy_manifest_file = output_directory / "legacy.json" - return_code = core_test_identity.main( - [ - "manifest", - "--lane", - "legacy", - "--results", - str(FIXTURES / "legacy"), - "--output", - str(legacy_manifest_file), - ] - ) - self.assertEqual(return_code, 0) - - comparison_file = output_directory / "legacy-comparison.json" - return_code = core_test_identity.main( - [ - "compare-legacy", - "--legacy-manifest", - str(legacy_manifest_file), - "--split-manifests", - *(str(path) for path in manifest_files), - "--output", - str(comparison_file), - ] - ) - self.assertEqual(return_code, 0) - with comparison_file.open(encoding="utf-8") as source: - comparison = json.load(source) - self.assertTrue(comparison["partition_equal"]) - if __name__ == "__main__": unittest.main() From d94476449a7f9b3919d9c345e2ddd619b13ee25a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:59:11 +0000 Subject: [PATCH 12/17] improvement(test): flatten core database lane tags, drop the gatekeeper tag The gatekeeper+modifier scheme (gravitino-core-database-test required for any DB lane, plus optional per-backend modifier tags defaulting to "all backends" when absent) needed a boolean tag expression to reconcile the two dimensions, and let a class desync from the gatekeeper tag specifically - carrying correct modifier tags but missing the gatekeeper meant total invisibility across every lane. Flatten to three peer tags only. Every database test class carries at least one; "applies to all three" means carrying all three tags, explicitly, not carrying none. Lane filtering drops back to plain tag matching, no expression needed: coreUnitTest: excludeTags(h2Tag, mysqlTag, pgTag, dockerTag) coreH2Test: includeTags(h2Tag) coreMySQLTest: includeTags(mysqlTag) corePostgreSQLTest: includeTags(pgTag) Add @AllBackendsTest, a composed annotation that is purely three @Tag meta-annotations and nothing else - no @ExtendWith, no execution-time hook, so it stays a discovery-time tag match like the scheme it replaces. TestAllBackendsTest pins the contract directly: it expands to exactly the three backend tags via the same AnnotationSupport lookup JUnit's engine uses at discovery time, and explicitly asserts the annotation carries no execution-time meta-annotation. Migrated all three real usage patterns (parameter-driven multi-backend via storageProvider()/BackendTestSelector, @TestTemplate-driven via BackendTestExtension, and single-backend @Nested classes) to the new scheme. Verified against real :core:coreH2Test/:core:coreUnitTest runs: TestJdbcPartitionStatisticStorageIT's per-backend isolation still holds (only the matching nested class's XML is produced), and an @AllBackendsTest class is correctly excluded from coreUnitTest ("No tests found for given includes") and included in coreH2Test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 22 +++-- .../TestJdbcPartitionStatisticStorageIT.java | 5 +- .../storage/AbstractEntityStorageTest.java | 4 +- .../storage/relational/AllBackendsTest.java | 58 +++++++++++++ .../relational/TestAllBackendsTest.java | 82 +++++++++++++++++++ .../storage/relational/TestJDBCBackend.java | 3 +- 6 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index e9231d5e573..9dd500e9a5f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -106,10 +106,10 @@ artifacts { add("testArtifacts", testJar) } -val coreDatabaseTestTag = "gravitino-core-database-test" -// Backend name -> JUnit tag that pins a database test class to that backend. A database test -// class with none of these tags runs under every backend lane. Adding a backend here is enough -// to teach the lane filtering below about it. +// Backend name -> JUnit tag that admits a test class to that backend's lane. A class runs under +// exactly the backends it is tagged with: the @AllBackendsTest composed annotation in the core +// test sources expands to all three tags, and a class with none of them is a unit test. Adding +// a backend here is enough to teach the lane filtering below about it. val coreBackendTestTags = linkedMapOf( "h2" to "gravitino-core-h2-test", @@ -150,18 +150,16 @@ fun registerCoreTestTask( useJUnitPlatform { if (backend == null) { - excludeTags(coreDatabaseTestTag, "gravitino-docker-test") + // Whatever carries no backend tag (and no Docker tag) is the unit suite. + excludeTags(*coreBackendTestTags.values.toTypedArray(), "gravitino-docker-test") } else { val ownBackendTag = coreBackendTestTags[backend] ?: throw GradleException("Unsupported core test backend: $backend") - val otherBackendTags = coreBackendTestTags.values.filter { it != ownBackendTag } - includeTags(coreDatabaseTestTag) - // JUnit tag expression, still applied at discovery time like a plain tag list, so - // excluded classes never show up in this lane's JUnit XML. Exclude a class only when - // it is pinned to another backend and NOT to this one: a class tagged for several - // backends then runs under each of them instead of being dropped from all lanes. - excludeTags("!$ownBackendTag & (${otherBackendTags.joinToString(" | ")})") + // Plain tag include, applied by JUnit at discovery time, so classes not tagged for this + // backend never show up in this lane's JUnit XML. A class tagged for several backends + // runs under each of them. + includeTags(ownBackendTag) } } diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index 42b5938596a..e3109626030 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -85,7 +85,6 @@ *

  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ -@Tag("gravitino-core-database-test") @Tag("gravitino-docker-test") public class TestJdbcPartitionStatisticStorageIT { @@ -94,9 +93,9 @@ public class TestJdbcPartitionStatisticStorageIT { /** * Abstract base class containing all test logic. Each database-specific test class extends this - * and implements the database setup. + * and implements the database setup. Each subclass carries the tag of the one backend lane it + * runs in. */ - @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index ab1c8f76811..325e34a5638 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -93,6 +93,7 @@ import org.apache.gravitino.meta.TopicEntity; import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; +import org.apache.gravitino.storage.relational.AllBackendsTest; import org.apache.gravitino.storage.relational.BackendTestSelector; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; @@ -105,12 +106,11 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@Tag("gravitino-core-database-test") +@AllBackendsTest abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java new file mode 100644 index 00000000000..e7c3f94fc1b --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; + +/** + * Marks a core database test class that runs under every JDBC backend lane. + * + *

    The core test suite is split into one Gradle task per backend ({@code coreH2Test}, {@code + * coreMySQLTest}, {@code corePostgreSQLTest}), and each lane simply includes the classes carrying + * its backend tag. A class therefore runs under exactly the backends it is tagged with: + * + *

      + *
    • {@code @Tag("gravitino-core-mysql-test")} pins a class to the MySQL lane; list several tags + * to run under several lanes. + *
    • {@code @AllBackendsTest} runs a class under all three lanes. + *
    • A class with none of the backend tags is a unit test and runs in {@code coreUnitTest}. + *
    + * + *

    This is a plain JUnit composed annotation: it is meta-annotated with one {@link Tag} per + * backend and nothing else, so it is exactly equivalent to writing the three {@code @Tag}s on the + * class. JUnit expands it while scanning class annotations during test discovery (see {@code + * org.junit.platform.commons.support.AnnotationSupport#findRepeatableAnnotations}), so {@code + * core/build.gradle.kts} only needs the three backend tag strings and no execution-time condition + * is involved. The tag strings must stay in sync with {@code coreBackendTestTags} in {@code + * core/build.gradle.kts}. + */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Tag("gravitino-core-h2-test") +@Tag("gravitino-core-mysql-test") +@Tag("gravitino-core-postgresql-test") +public @interface AllBackendsTest {} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java new file mode 100644 index 00000000000..720a8e69cba --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Documented; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Tags; +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * Pins the contract of {@link AllBackendsTest}: JUnit must expand it to exactly the three backend + * lane tags that {@code core/build.gradle.kts} filters on, for the annotated class and for its + * subclasses, using the same annotation lookup the Jupiter engine runs at discovery time. + */ +public class TestAllBackendsTest { + + private static final List BACKEND_TAGS = + List.of( + "gravitino-core-h2-test", "gravitino-core-mysql-test", "gravitino-core-postgresql-test"); + + @Test + void testExpandsToEveryBackendTag() { + assertEquals(BACKEND_TAGS, tagsOf(Annotated.class)); + } + + @Test + void testSubclassInheritsEveryBackendTag() { + assertEquals(BACKEND_TAGS, tagsOf(Subclass.class)); + } + + @Test + void testCarriesNothingButTags() { + Set> metaAnnotations = + Arrays.stream(AllBackendsTest.class.getAnnotations()) + .map(Annotation::annotationType) + .collect(Collectors.toSet()); + + // No @ExtendWith or other execution-time hook: lane filtering stays a discovery-time tag + // match, so classes left out of a lane never appear in that lane's JUnit XML. + assertEquals( + Set.of(Documented.class, Inherited.class, Retention.class, Target.class, Tags.class), + metaAnnotations); + } + + private static List tagsOf(Class testClass) { + return AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() + .map(Tag::value) + .collect(Collectors.toList()); + } + + @AllBackendsTest + private static class Annotated {} + + private static class Subclass extends Annotated {} +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index ac7cc18fec1..8795ca8b285 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,11 +81,10 @@ import org.apache.gravitino.utils.NamespaceUtil; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; -@Tag("gravitino-core-database-test") +@AllBackendsTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, From 7c37f9e98893450558b86b9fe6585127a48fd8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:54:37 +0000 Subject: [PATCH 13/17] improvement(test): namespace core database lane annotations under CoreBackend Raw @Tag("gravitino-core-h2-test") string literals (and their multi/-mysql/ -postgresql siblings) were typo-prone: a misspelled tag compiles fine and the class just silently never runs in any lane. @AllBackendsTest already solved this for the all-three-backends case; extend the same discovery-time- safe pattern to the individual-backend case. CoreBackend.H2/.MySQL/.PostgreSQL/.All replace both the raw @Tag literals and @AllBackendsTest. Flat names (@CoreH2Test etc.) were considered and rejected: they'd collide with existing classes of those exact names (TestJdbcPartitionStatisticStorageIT$H2Test/$MySQLTest/$PostgreSQLTest) - not just confusingly, but as an actual compile error (an unqualified annotation import shadowed by a same-named nested class). Namespacing under CoreBackend makes that collision structurally impossible (CoreBackend.H2 is always qualified) and matches this package's existing "Backend" vocabulary (BackendTestExtension, BackendTestSelector). A parameterized single annotation (@CoreBackend(backends = {H2, MYSQL})) was also considered and rejected: JUnit's @Tag resolution only ever reads an annotation type's own fixed meta-annotations, never a per-usage attribute value, so it would need an execution-time mechanism to interpret - the same category of mistake the already-reverted @DatabaseTest/ExecutionCondition redesign made. Each nested annotation stays a plain composed @Tag with no @ExtendWith; TestCoreDatabaseLaneAnnotations pins the contract directly (exact tag expansion via the same AnnotationSupport lookup Jupiter's engine uses at discovery time, inheritance, stacking, and that none of the four carry an execution-time meta-annotation). Also adds `coreTestLaneOf`, a discovery-only local check (./gradlew :core:coreTestLaneOf -PclassName=) that prints which lane(s) a class's tags put it in without running anything, and warns when a Docker-tagged class carries no backend tag (the one gap this annotation redesign doesn't structurally close: forgetting every backend tag, not just one of several). And a build-script comment on coreBackendTestTags spelling out the four lane-membership shapes with examples. Verified against real :core:coreH2Test/:core:coreUnitTest/:core:coreTestLaneOf runs: TestJdbcPartitionStatisticStorageIT's per-backend isolation still holds (only the matching nested class's XML is produced, manifest passes), and coreTestLaneOf correctly reports lanes for a real @CoreBackend.All class and warns for a synthetic Docker-tagged, no-backend-tag orphan class. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 36 ++++- .../TestJdbcPartitionStatisticStorageIT.java | 7 +- .../storage/AbstractEntityStorageTest.java | 4 +- .../storage/relational/AllBackendsTest.java | 58 -------- .../storage/relational/CoreBackend.java | 105 ++++++++++++++ .../storage/relational/CoreTestLaneOf.java | 93 ++++++++++++ .../relational/TestAllBackendsTest.java | 82 ----------- .../TestCoreDatabaseLaneAnnotations.java | 135 ++++++++++++++++++ .../storage/relational/TestJDBCBackend.java | 2 +- 9 files changed, 372 insertions(+), 150 deletions(-) delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9dd500e9a5f..12418097f8c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -106,10 +106,22 @@ artifacts { add("testArtifacts", testJar) } -// Backend name -> JUnit tag that admits a test class to that backend's lane. A class runs under -// exactly the backends it is tagged with: the @AllBackendsTest composed annotation in the core -// test sources expands to all three tags, and a class with none of them is a unit test. Adding -// a backend here is enough to teach the lane filtering below about it. +// Core's tests run in one of four Gradle lanes: coreUnitTest (default, no Docker) and +// coreH2Test/coreMySQLTest/corePostgreSQLTest (one per backend, coreMySQLTest and +// corePostgreSQLTest need Docker). Lane membership is decided purely by which of the three +// backend tags below a test class carries - see CoreBackend in +// core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java for the typed +// annotations (@CoreBackend.H2/.MySQL/.PostgreSQL/.All) that set them, instead of writing raw +// @Tag("...") strings by hand: +// @CoreBackend.H2 -> runs only in coreH2Test +// @CoreBackend.H2 @CoreBackend.MySQL -> runs in coreH2Test and coreMySQLTest +// @CoreBackend.All -> runs in all three backend lanes +// (no CoreBackend annotation at all) -> a plain unit test, runs in coreUnitTest +// A class needing Docker but carrying no backend tag runs in no lane at all - check locally +// with `./gradlew :core:coreTestLaneOf -PclassName=`. +// +// Backend name -> JUnit tag that admits a test class to that backend's lane. Adding a backend +// here is enough to teach the lane filtering below about it; also add it to CoreBackend.java. val coreBackendTestTags = linkedMapOf( "h2" to "gravitino-core-h2-test", @@ -188,6 +200,22 @@ registerCoreTestTask("coreH2Test", "h2") registerCoreTestTask("coreMySQLTest", "mysql") registerCoreTestTask("corePostgreSQLTest", "postgresql") +tasks.register("coreTestLaneOf") { + group = "verification" + description = "Prints which core database test lane(s) a class runs in, from its tags, " + + "without running anything. Usage: -PclassName=" + dependsOn(tasks.named("testClasses")) + classpath = sourceSets["test"].runtimeClasspath + mainClass.set("org.apache.gravitino.storage.relational.CoreTestLaneOf") + doFirst { + val className = project.findProperty("className") as? String + ?: throw GradleException( + "Usage: ./gradlew :core:coreTestLaneOf -PclassName=" + ) + args(className) + } +} + val coreSuiteCoverage = providers.gradleProperty("coreSuiteCoverage").map(String::toBoolean).orElse(false) val coreSuiteTaskNames = diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index e3109626030..38d421bd547 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -58,6 +58,7 @@ import org.apache.gravitino.stats.PartitionStatisticsUpdate; import org.apache.gravitino.stats.StatisticValue; import org.apache.gravitino.stats.StatisticValues; +import org.apache.gravitino.storage.relational.CoreBackend; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; @@ -586,7 +587,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-mysql-test") + @CoreBackend.MySQL @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -657,7 +658,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-postgresql-test") + @CoreBackend.PostgreSQL @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -731,7 +732,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested - @Tag("gravitino-core-h2-test") + @CoreBackend.H2 static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index 325e34a5638..7cb53318d56 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -93,8 +93,8 @@ import org.apache.gravitino.meta.TopicEntity; import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; -import org.apache.gravitino.storage.relational.AllBackendsTest; import org.apache.gravitino.storage.relational.BackendTestSelector; +import org.apache.gravitino.storage.relational.CoreBackend; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -110,7 +110,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@AllBackendsTest +@CoreBackend.All abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java deleted file mode 100644 index e7c3f94fc1b..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/AllBackendsTest.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.gravitino.storage.relational; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.junit.jupiter.api.Tag; - -/** - * Marks a core database test class that runs under every JDBC backend lane. - * - *

    The core test suite is split into one Gradle task per backend ({@code coreH2Test}, {@code - * coreMySQLTest}, {@code corePostgreSQLTest}), and each lane simply includes the classes carrying - * its backend tag. A class therefore runs under exactly the backends it is tagged with: - * - *

      - *
    • {@code @Tag("gravitino-core-mysql-test")} pins a class to the MySQL lane; list several tags - * to run under several lanes. - *
    • {@code @AllBackendsTest} runs a class under all three lanes. - *
    • A class with none of the backend tags is a unit test and runs in {@code coreUnitTest}. - *
    - * - *

    This is a plain JUnit composed annotation: it is meta-annotated with one {@link Tag} per - * backend and nothing else, so it is exactly equivalent to writing the three {@code @Tag}s on the - * class. JUnit expands it while scanning class annotations during test discovery (see {@code - * org.junit.platform.commons.support.AnnotationSupport#findRepeatableAnnotations}), so {@code - * core/build.gradle.kts} only needs the three backend tag strings and no execution-time condition - * is involved. The tag strings must stay in sync with {@code coreBackendTestTags} in {@code - * core/build.gradle.kts}. - */ -@Documented -@Inherited -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -@Tag("gravitino-core-h2-test") -@Tag("gravitino-core-mysql-test") -@Tag("gravitino-core-postgresql-test") -public @interface AllBackendsTest {} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java new file mode 100644 index 00000000000..24098529462 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; + +/** + * Namespace for the annotations that select which core database test lane(s) a class runs in. + * + *

    The core test suite is split into one Gradle task per backend ({@code coreH2Test}, {@code + * coreMySQLTest}, {@code corePostgreSQLTest}), and each lane simply includes the classes carrying + * its backend tag. A class runs under exactly the lanes it is tagged for - there is no separate "is + * this a database test" gatekeeper tag to keep in sync: + * + *

      + *
    • {@link H2}, {@link MySQL}, {@link PostgreSQL} pin a class to one lane; stack more than one + * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. + *
    • {@link All} runs a class under all three lanes. + *
    • A class with none of these is a unit test and runs in {@code coreUnitTest}. + *
    + * + *

    Each is a plain JUnit composed annotation: meta-annotated with {@link Tag} and nothing else, + * so it is exactly equivalent to writing the raw {@code @Tag} string(s) on the class. JUnit expands + * it while scanning class annotations during test discovery (see {@code + * org.junit.platform.commons.support.AnnotationSupport#findRepeatableAnnotations}), so {@code + * core/build.gradle.kts} only needs the tag strings themselves and no execution-time condition is + * involved. {@link #H2_TAG}, {@link #MYSQL_TAG}, and {@link #POSTGRESQL_TAG} must stay in sync with + * {@code coreBackendTestTags} in {@code core/build.gradle.kts}. + * + *

    Because {@code core/build.gradle.kts}'s {@code reconcile} step (via {@code + * dev/ci/core_test_identity.py}) requires the h2/mysql/postgresql lanes to run the exact same set + * of normalized test identities, a single- or multi- (but not all-) backend class is only CI-legal + * as a normalized sibling of matching classes in the other backend(s) it omits - see {@code + * TestJdbcPartitionStatisticStorageIT}'s {@code H2Test}/{@code MySQLTest}/{@code PostgreSQLTest} + * nested classes for the pattern this currently requires. + */ +public final class CoreBackend { + + /** The JUnit tag that selects the H2 lane. */ + public static final String H2_TAG = "gravitino-core-h2-test"; + + /** The JUnit tag that selects the MySQL lane. */ + public static final String MYSQL_TAG = "gravitino-core-mysql-test"; + + /** The JUnit tag that selects the PostgreSQL lane. */ + public static final String POSTGRESQL_TAG = "gravitino-core-postgresql-test"; + + private CoreBackend() {} + + /** Pins a core database test class to the H2 lane ({@code coreH2Test}). */ + @Documented + @Inherited + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Tag(H2_TAG) + public @interface H2 {} + + /** Pins a core database test class to the MySQL lane ({@code coreMySQLTest}). */ + @Documented + @Inherited + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Tag(MYSQL_TAG) + public @interface MySQL {} + + /** Pins a core database test class to the PostgreSQL lane ({@code corePostgreSQLTest}). */ + @Documented + @Inherited + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Tag(POSTGRESQL_TAG) + public @interface PostgreSQL {} + + /** Runs a core database test class under every backend lane. */ + @Documented + @Inherited + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Tag(H2_TAG) + @Tag(MYSQL_TAG) + @Tag(POSTGRESQL_TAG) + public @interface All {} +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java new file mode 100644 index 00000000000..a9759a37bf3 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Tag; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * A local, discovery-only check: prints which core database test lane(s) a class runs in, from its + * tags, without running anything. Reads a class's tags with the same lookup the Jupiter engine uses + * at discovery time (see {@link CoreBackend}), so the answer matches what {@code + * core/build.gradle.kts}'s lane tasks would actually do. + * + *

    Run with {@code ./gradlew :core:coreTestLaneOf -PclassName=}. + */ +public final class CoreTestLaneOf { + + private CoreTestLaneOf() {} + + /** + * Entry point. + * + * @param args exactly one fully-qualified class name to inspect + */ + public static void main(String[] args) { + if (args.length != 1) { + System.err.println("Usage: CoreTestLaneOf "); + System.exit(1); + return; + } + + Class testClass; + try { + testClass = Class.forName(args[0]); + } catch (ClassNotFoundException e) { + System.err.println("Class not found on the test classpath: " + args[0]); + System.exit(1); + return; + } + + Set tags = + AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() + .map(Tag::value) + .collect(Collectors.toSet()); + + List lanes = new ArrayList<>(); + if (tags.contains(CoreBackend.H2_TAG)) { + lanes.add("coreH2Test"); + } + if (tags.contains(CoreBackend.MYSQL_TAG)) { + lanes.add("coreMySQLTest"); + } + if (tags.contains(CoreBackend.POSTGRESQL_TAG)) { + lanes.add("corePostgreSQLTest"); + } + + System.out.println(args[0] + " tags: " + tags); + if (!lanes.isEmpty()) { + System.out.println(args[0] + " runs in: " + String.join(", ", lanes)); + return; + } + + if (tags.contains("gravitino-docker-test")) { + System.out.println( + args[0] + + " carries gravitino-docker-test but no backend tag - it will NOT run in ANY" + + " lane. Add @CoreBackend.H2/@CoreBackend.MySQL/@CoreBackend.PostgreSQL or" + + " @CoreBackend.All."); + } else { + System.out.println(args[0] + " carries no backend tag - runs in coreUnitTest."); + } + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java deleted file mode 100644 index 720a8e69cba..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestAllBackendsTest.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.gravitino.storage.relational; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.lang.annotation.Annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Tags; -import org.junit.jupiter.api.Test; -import org.junit.platform.commons.support.AnnotationSupport; - -/** - * Pins the contract of {@link AllBackendsTest}: JUnit must expand it to exactly the three backend - * lane tags that {@code core/build.gradle.kts} filters on, for the annotated class and for its - * subclasses, using the same annotation lookup the Jupiter engine runs at discovery time. - */ -public class TestAllBackendsTest { - - private static final List BACKEND_TAGS = - List.of( - "gravitino-core-h2-test", "gravitino-core-mysql-test", "gravitino-core-postgresql-test"); - - @Test - void testExpandsToEveryBackendTag() { - assertEquals(BACKEND_TAGS, tagsOf(Annotated.class)); - } - - @Test - void testSubclassInheritsEveryBackendTag() { - assertEquals(BACKEND_TAGS, tagsOf(Subclass.class)); - } - - @Test - void testCarriesNothingButTags() { - Set> metaAnnotations = - Arrays.stream(AllBackendsTest.class.getAnnotations()) - .map(Annotation::annotationType) - .collect(Collectors.toSet()); - - // No @ExtendWith or other execution-time hook: lane filtering stays a discovery-time tag - // match, so classes left out of a lane never appear in that lane's JUnit XML. - assertEquals( - Set.of(Documented.class, Inherited.class, Retention.class, Target.class, Tags.class), - metaAnnotations); - } - - private static List tagsOf(Class testClass) { - return AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() - .map(Tag::value) - .collect(Collectors.toList()); - } - - @AllBackendsTest - private static class Annotated {} - - private static class Subclass extends Annotated {} -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java new file mode 100644 index 00000000000..416e7b5cdaf --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Documented; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Tags; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * Pins the contract of {@link CoreBackend.H2}, {@link CoreBackend.MySQL}, {@link + * CoreBackend.PostgreSQL}, and {@link CoreBackend.All}: each must expand to exactly the tag(s) + * {@code core/build.gradle.kts} filters on, for the annotated class and for its subclasses, using + * the same annotation lookup the Jupiter engine runs at discovery time - and none may carry an + * execution-time hook, since that would repeat the mistake a prior, reverted design made. + */ +class TestCoreDatabaseLaneAnnotations { + + private static Stream annotationsAndExpectedTags() { + return Stream.of( + new Object[] {H2Annotated.class, List.of(CoreBackend.H2_TAG)}, + new Object[] {MySQLAnnotated.class, List.of(CoreBackend.MYSQL_TAG)}, + new Object[] {PostgreSQLAnnotated.class, List.of(CoreBackend.POSTGRESQL_TAG)}, + new Object[] { + AllAnnotated.class, + List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, CoreBackend.POSTGRESQL_TAG) + }); + } + + @ParameterizedTest + @MethodSource("annotationsAndExpectedTags") + void testExpandsToExpectedTags(Class annotatedClass, List expectedTags) { + assertEquals(expectedTags, tagsOf(annotatedClass)); + } + + @Test + void testStackingTwoAnnotationsRunsUnderBothLanes() { + assertEquals(List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG), tagsOf(H2AndMySQL.class)); + } + + @Test + void testSubclassInheritsTags() { + assertEquals(List.of(CoreBackend.H2_TAG), tagsOf(H2Subclass.class)); + assertEquals( + List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, CoreBackend.POSTGRESQL_TAG), + tagsOf(AllSubclass.class)); + } + + @Test + void testNoneOfThemCarryAnExecutionTimeHook() { + // No @ExtendWith or similar on any of them: lane filtering stays a discovery-time tag + // match, so classes left out of a lane never appear in that lane's JUnit XML. + Set> tagOnlyMetaAnnotations = + Set.of( + Documented.class, + Inherited.class, + Retention.class, + Target.class, + Tag.class, + Tags.class); + for (Class annotationType : + List.of( + CoreBackend.H2.class, + CoreBackend.MySQL.class, + CoreBackend.PostgreSQL.class, + CoreBackend.All.class)) { + Set> metaAnnotations = + Arrays.stream(annotationType.getAnnotations()) + .map(Annotation::annotationType) + .collect(Collectors.toSet()); + assertEquals( + Set.of(), + metaAnnotations.stream() + .filter(type -> !tagOnlyMetaAnnotations.contains(type)) + .collect(Collectors.toSet()), + annotationType.getSimpleName() + " carries an unexpected, non-tag meta-annotation"); + } + } + + private static List tagsOf(Class testClass) { + return AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() + .map(Tag::value) + .collect(Collectors.toList()); + } + + @CoreBackend.H2 + private static class H2Annotated {} + + @CoreBackend.MySQL + private static class MySQLAnnotated {} + + @CoreBackend.PostgreSQL + private static class PostgreSQLAnnotated {} + + @CoreBackend.All + private static class AllAnnotated {} + + @CoreBackend.H2 + @CoreBackend.MySQL + private static class H2AndMySQL {} + + private static class H2Subclass extends H2Annotated {} + + private static class AllSubclass extends AllAnnotated {} +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 8795ca8b285..009c39d99f7 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -84,7 +84,7 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; -@AllBackendsTest +@CoreBackend.All @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, From 03241c0fe8a4f0f87ad8b6d9fc7a1fc5a77d7b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:17:59 +0000 Subject: [PATCH 14/17] improvement(test): deprecate :core:test in place, add SPIP design doc :core:test is the java plugin's built-in test task, kept registered for tooling backward-compat but no longer the right way to run core tests. Its tasks.test block now warns on every direct invocation, naming the four real lanes (coreUnitTest/coreH2Test/coreMySQLTest/ corePostgreSQLTest) and coreTestLaneOf. No CI change needed since dev/ci/test-shards.sh already excludes it. Adds design-docs/testing/core-db-split.md, an SPIP (Heilmeier Catechism) documenting the final lane-split design retroactively, including the alternatives that were tried and reverted along the way and a contributor how-to (Appendix C) in place of a docs/how-to-test.md section, since that doc is repo-wide and never mentioned core specifically. --- core/build.gradle.kts | 18 + design-docs/testing/core-db-split.md | 647 +++++++++++++++++++++++++++ 2 files changed, 665 insertions(+) create mode 100644 design-docs/testing/core-db-split.md diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 12418097f8c..2ac888f25ae 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -245,7 +245,25 @@ tasks.named("jacocoTestReport") { } } +// :core:test is the java plugin's built-in `test` task, kept registered (and working) only for +// backward compatibility - IDEs and other tooling may still target it by convention. It is +// deprecated in place, not removed: +// - Dev CUJ: a contributor running tests locally should target one of the four lanes registered +// above (coreUnitTest / coreH2Test / coreMySQLTest / corePostgreSQLTest), never :core:test - +// it predates the lane split and does not correspond to any CI lane. Check where a class runs +// with `./gradlew :core:coreTestLaneOf -PclassName=...` instead of guessing. +// - CI CUJ: no change needed here. CI never invokes :core:test - dev/ci/test-shards.sh emits +// `-x :core:test` for the `others` shard, so the warning below only ever fires for a developer +// running it directly. tasks.test { + doFirst { + logger.warn( + "WARNING: :core:test is deprecated and does not correspond to any CI lane. Use " + + "coreUnitTest, coreH2Test, coreMySQLTest, or corePostgreSQLTest instead - run " + + "./gradlew :core:coreTestLaneOf -PclassName= to check which " + + "one(s) a class belongs to." + ) + } val testMode = project.properties["testMode"] as? String ?: "embedded" if (testMode == "embedded") { environment("GRAVITINO_HOME", project.rootDir.path) diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md new file mode 100644 index 00000000000..20e0792dfc6 --- /dev/null +++ b/design-docs/testing/core-db-split.md @@ -0,0 +1,647 @@ + + +# SPIP: Split core tests into explicit database lanes + +| | | +|---|---| +| **Status** | Implemented on PR [apache/gravitino#13517](https://github.com/apache/gravitino/pull/13517); this document records the final design retroactively | +| **Scope** | `core` module test execution, `core/build.gradle.kts`, `dev/ci/core_test_identity.py`, `.github/workflows/build.yml` | +| **Format** | Apache Spark SPIP (Heilmeier Catechism) | + +## Q1. What are you trying to do? + +Make it explicit, per test class, which database(s) a core test runs against, and make the +build honor that declaration exactly. + +Concretely: + +- Replace the single `:core:test` run with four Gradle lanes: `coreUnitTest` (no database, no + Docker), `coreH2Test`, `coreMySQLTest`, and `corePostgreSQLTest`. +- Give contributors one typed way to put a class into a lane: `@CoreBackend.H2`, + `@CoreBackend.MySQL`, `@CoreBackend.PostgreSQL`, or `@CoreBackend.All`. No raw tag strings. +- Guarantee that a class not tagged for a lane leaves no trace in that lane's output, so CI can + prove the three database lanes ran the same test contract. +- Give a developer a way to ask "which lane does my class run in?" without running any test. +- Warn a developer off the old, unsplit `:core:test` entry point without deleting it. + +## Q2. What problem is this proposal NOT designed to solve? + +These are deliberate exclusions, not oversights. + +- **No CI guard for the orphan case.** A class that carries `gravitino-docker-test` but no + `@CoreBackend.*` annotation is excluded from `coreUnitTest` (by the Docker tag) and from every + backend lane (no backend tag), so it runs nowhere. The design does not add a CI step that + scans for this. Mitigation is developer self-service: `./gradlew :core:coreTestLaneOf` + prints an explicit warning for exactly this shape, and the build script comment on + `coreBackendTestTags` documents it. +- **No cross-lane report aggregation.** Each lane writes its own JUnit XML, HTML report, and + JaCoCo `.exec` under a lane-specific path. CI uploads them as four separate evidence artifacts + and only the JaCoCo data is merged (for coverage). There is no tool that merges the four JUnit + reports into one; readers open the lane they care about. +- **Not removing `:core:test`.** It is the `java` plugin's built-in `test` task; other tooling + (IDEs, scripts) may still target it by convention, so it stays registered and functional rather + than being deleted. It is deprecated *in place* instead (see Q4/Appendix C): running it directly + now prints a warning naming the four real lanes, but it still runs and still passes. +- **Not changing how a test selects its backend at runtime.** `BackendTestSelector` (reads the + `gravitino.core.test.backend` system property), `BackendTestExtension` (`@TestTemplate` + invocation contexts), and the `storageProvider()` parameter pattern are unchanged. The lane + decides *whether* a class runs; these decide *what it does* once it runs. + +## Q3. How is it done today, and what are the limits of current practice? + +Before this PR, `core` ran every test through one `:core:test` task. H2-backed, MySQL-backed, +and PostgreSQL-backed tests were all discovered by the same task, and the only filtering was +the repository-wide `excludeTags("gravitino-docker-test")` applied when Docker was unavailable. +There was no notion of a backend lane at all, so there was nothing for CI to reconcile. + +The first iteration on this PR branch introduced four lane tasks but decided membership with a +gatekeeper tag plus modifier tags: + +```kotlin +// state at 654cfe33a, since replaced +useJUnitPlatform { + if (backend == null) { + excludeTags(coreDatabaseTestTag, "gravitino-docker-test") + } else { + includeTags(coreDatabaseTestTag) // gatekeeper + when (backend) { + "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) + "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) + "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) + } + } +} +``` + +A class had to carry `gravitino-core-database-test` to enter any database lane, and then +optionally carried per-backend tags whose *absence* meant "all backends". This had three +concrete failure modes, all silent: + +1. **Gatekeeper desync.** A class with correct per-backend tags but no gatekeeper tag was + invisible to every lane. Nothing failed; the tests just never ran. +2. **Two tags meant zero lanes.** Because each lane *excluded* the other two backends' tags, a + class tagged for both H2 and MySQL was excluded from H2 (carries MySQL tag), from MySQL + (carries H2 tag), and from PostgreSQL. An intermediate fix (46eef2130) replaced the + `when` with a boolean tag expression to make multi-tagged classes run under each lane, but + the expression was hard to read and still depended on the gatekeeper. +3. **Typos compiled.** Tags were raw `@Tag("gravitino-core-h2-test")` string literals. A + misspelling was a valid, unrelated tag, so the class silently dropped out of its lane. + +There was also no way to answer "where will this class run?" other than running a lane and +grepping the resulting XML. + +## Q4. What is new in your approach, and why do you think it will be successful? + +### Three peer tags, nothing else + +The gatekeeper is gone. Lane membership is decided by exactly three JUnit tags, defined once +in `core/build.gradle.kts` and mirrored as constants in `CoreBackend`: + +```kotlin +val coreBackendTestTags = linkedMapOf( + "h2" to "gravitino-core-h2-test", + "mysql" to "gravitino-core-mysql-test", + "postgresql" to "gravitino-core-postgresql-test" +) +``` + +Each backend lane is a plain `includeTags(ownBackendTag)`. The unit lane is +`excludeTags(, "gravitino-docker-test")`. "Runs on all backends" means carrying all +three tags explicitly, never carrying none. A class tagged for two backends runs in both, because +each lane only looks for its own tag and never excludes another backend's. + +### Typed annotations that are pure `@Tag` composition + +Contributors never write tag strings. `CoreBackend` is a final namespace class with four nested +marker annotations; each is meta-annotated with `@Tag` (one or three) and the standard +`@Documented @Inherited @Retention(RUNTIME) @Target(TYPE)` set, and nothing else. This is the +single most important property of the design: **filtering happens at JUnit discovery time**. +Gradle's `includeTags`/`excludeTags` become a JUnit Platform `PostDiscoveryFilter`, which reads +class tags reflectively via `AnnotationSupport.findRepeatableAnnotations(clazz, Tag.class)`. +Because the annotations carry no `@ExtendWith` or other execution-time hook, an excluded class is +pruned from the discovered test plan before execution begins and therefore produces no +`` element, not even a `` one, in the lane's JUnit XML. + +That hard-zero property is what `dev/ci/core_test_identity.py reconcile` depends on (Appendix +B). An earlier redesign on this same branch (`@DatabaseTest` + a `BackendLaneCondition` +`ExecutionCondition`, commit efee6a4c9) was reverted (547e9bdf0) precisely because an +`ExecutionCondition` runs after discovery: the disabled class still appeared in every lane's XML +as ``, the manifest step saw foreign backend markers, and reconcile failed. + +`TestCoreDatabaseLaneAnnotations` pins this contract as a regression guard: it asserts the exact +tag expansion of each annotation, that stacking two annotations yields both tags, that subclasses +inherit the tags, and that none of the four annotation types carries any meta-annotation outside +`{Documented, Inherited, Retention, Target, Tag, Tags}`. Re-introducing an execution-time hook +fails that test. + +### A local, discovery-only lane check + +`./gradlew :core:coreTestLaneOf -PclassName=` runs `CoreTestLaneOf`, which loads the class, +calls the same `AnnotationSupport.findRepeatableAnnotations` lookup the engine uses, maps the +resulting tag set to lane task names, and prints them. If the class carries +`gravitino-docker-test` but no backend tag, it prints an explicit "will NOT run in ANY lane" +warning. Because it re-derives the answer through the identical mechanism, its output is +trustworthy without running a single test. + +### `:core:test` deprecated in place, not removed + +`:core:test` is the `java` plugin's built-in `test` task, auto-registered before this module's +build script runs. Removing it outright risks breaking any tooling (IDE run buttons, scripts) +that targets `::test` by convention across the whole repo, not just `core`. Instead its +`tasks.test { ... }` configuration block gained a `doFirst` that logs a warning naming the four +lanes and `coreTestLaneOf` every time it runs, so it still works exactly as before but visibly +tells a developer it is the wrong task. No CI change was needed: `dev/ci/test-shards.sh` already +emits `-x :core:test` for the `others` shard, so the warning only ever fires for someone running +it directly. See Appendix C for the day-to-day commands this replaces. + +### Alternatives considered (brief) + +- **Parameterized annotation** (`@CoreDBTest(backends = {H2, MYSQL})`): mechanically possible + with a custom `PostDiscoveryFilter` that reads the attribute, but rejected. JUnit's built-in + tag resolution only reads an annotation type's fixed meta-annotations, never a per-usage + attribute, so it would need a bespoke filter to interpret. More importantly, + `core_test_identity.py reconcile` requires a fixed, small set of lanes with exact identity + equality across the three database lanes; a free-form attribute-driven subset per class would + undermine that invariant rather than express it. +- **Flat top-level annotation names** (`@CoreH2Test`, `@CoreMySQLTest`, ...): rejected after a + real, reproduced compile error. `TestJdbcPartitionStatisticStorageIT` already declares nested + classes named `H2Test`, `MySQLTest`, and `PostgreSQLTest`; an unqualified annotation import of + the same simple name was shadowed by the nested class, producing + `H2Test cannot be converted to Annotation`. Namespacing under `CoreBackend` makes the + reference always qualified, so the collision is structurally impossible, and it matches the + package's existing "Backend" vocabulary (`BackendTestExtension`, `BackendTestSelector`). +- **Execution-time condition** (`@DatabaseTest` + `ExecutionCondition`): implemented, reverted; + see above. + +### Why it will work + +The design has already been run, not just reasoned about. Real `:core:coreH2Test`, +`:core:coreUnitTest`, and `:core:coreTestLaneOf` invocations on the branch confirmed that only +the matching nested class's XML is produced for `TestJdbcPartitionStatisticStorageIT`, that an +`@CoreBackend.All` class is excluded from `coreUnitTest` ("No tests found for given includes") +and included in `coreH2Test`, that the manifest step passes on the lane output, and that +`coreTestLaneOf` reports lanes for a real class and warns for a synthetic orphan. + +## Q5. Who cares? If you are successful, what difference will it make? + +- **Contributors adding core storage tests** get a two-line, compile-checked way to declare + where a test runs, and a five-second local command to confirm it. The "my test never ran and + nobody noticed" class of bug is reduced to one remaining shape (the orphan), which the local + tool names explicitly. +- **Reviewers** can read lane membership off the class declaration instead of reconstructing it + from a tag expression in Gradle. +- **CI maintainers** get a lane filter that is three trivial include/exclude lines, a + reconcile step whose hard-zero precondition is guaranteed by construction, and a regression + test that fails if anyone reintroduces an execution-time hook. +- **The project** keeps the ability to prove, on every PR, that H2, MySQL, and PostgreSQL ran + the identical normalized test contract, and that unit and database identities are disjoint. + +## Q6. What are the risks? + +- **Orphan classes run nowhere and nothing in CI says so.** A `gravitino-docker-test` class with + no `@CoreBackend.*` annotation is dropped by every lane. Accepted by design (Q2); mitigated by + `coreTestLaneOf`'s explicit warning and by documentation, not enforcement. +- **Tag string drift.** `CoreBackend.H2_TAG` / `MYSQL_TAG` / `POSTGRESQL_TAG` must stay equal to + the values in `coreBackendTestTags`. They are declared in two places (Kotlin build script and + Java test source) with no shared source. Mitigated by a comment on each side naming the other; + a mismatch surfaces as an empty lane ("No tests found for given includes") rather than silent + success, because the lane would then include a tag no class carries. +- **`:core:test` still works and still means "everything".** It is deprecated in place, not + removed (Q4), so a developer can still run the unsplit task locally and get results that do not + correspond to any CI lane; the `doFirst` warning is advisory, not a hard failure, so it is easy + to miss in a noisy log. +- **Sharded reports.** With no aggregation tooling, someone looking for "all core test + results" must open up to four reports. Accepted as scope simplification. +- **Partial-backend classes are only CI-legal as normalized siblings.** A class annotated with a + single backend (or two) passes reconcile only if matching classes exist for the backends it + omits and `core_test_identity.py`'s normalization maps them to the same identity (today: the + `TestJdbcPartitionStatisticStorageIT$H2Test/$MySQLTest/$PostgreSQLTest` shape, handled by + `STATS_BACKEND_CLASS_RE`). A new single-backend class without siblings fails reconcile with + "Database identity mismatch". This is the intended contract, but it is a constraint contributors + must know; it is documented in `CoreBackend`'s Javadoc. + +## Q7. How long will it take? + +**Done, on the PR branch:** + +- `CoreBackend` annotation namespace (`H2`, `MySQL`, `PostgreSQL`, `All`). +- Four Gradle lane tasks via `registerCoreTestTask`, with the three-peer-tag filter. +- `coreTestLaneOf` JavaExec task and `CoreTestLaneOf` main class. +- `TestCoreDatabaseLaneAnnotations` regression guard. +- Migration of all three existing usage patterns (`AbstractEntityStorageTest`, + `TestJDBCBackend`, `TestJdbcPartitionStatisticStorageIT`) and removal of the gatekeeper tag. +- Removal of the dead `compare-legacy` subcommand from `core_test_identity.py`. +- CI wiring: `build` matrix shards `core-unit`/`core-h2`/`core-mysql`/`core-postgresql` each run + one lane and upload evidence; `core-test-contract` (`needs: [changes, build]`) runs the tool's + own unit tests, downloads the four evidence artifacts, and reconciles. +- `:core:test` deprecated in place: a `doFirst` warning on its `tasks.test` block names the four + lanes and `coreTestLaneOf`; the task still runs and still passes. + +Contributor-facing documentation for the lanes lives in this document (Appendix C) rather than in +`docs/how-to-test.md`, which is a repo-wide doc unrelated to this split (it only documents the +root `./gradlew test` task and does not mention `core` or its lanes) - no edit there was needed. + +**Remaining, in scope, not yet done:** + +- Reply to the open review thread on #13517 and update the PR description to match the final + design. + +## Q8. What are the mid-term and final "exams" to check for success? + +Each criterion is concrete and checkable. + +**Mid-term (already verifiable on the branch):** + +1. `./gradlew :core:coreUnitTest -PskipITs --tests '*TestCoreDatabaseLaneAnnotations*'` passes: each + annotation expands to exactly its expected tags, stacking and inheritance hold, and no + annotation carries a non-tag meta-annotation. +2. `./gradlew :core:coreH2Test` produces JUnit XML under `core/build/test-results/coreH2Test/` + containing no `` whose classname or name carries a `mysql` or `postgresql` marker + (`python3 dev/ci/core_test_identity.py manifest --lane h2 ...` exits 0). +3. `./gradlew :core:coreUnitTest` on an `@CoreBackend.All` class reports "No tests found for + given includes" for that class. +4. `./gradlew :core:coreTestLaneOf -PclassName=org.apache.gravitino.storage.relational.TestJDBCBackend` + prints `runs in: coreH2Test, coreMySQLTest, corePostgreSQLTest` in under five seconds once + test classes are compiled, without executing any test. +5. `coreTestLaneOf` on a class tagged only `gravitino-docker-test` prints the "will NOT run in + ANY lane" warning. +6. `./gradlew :core:test -PskipITs --tests ` still passes and now also logs the + `:core:test is deprecated ...` warning naming the four lanes and `coreTestLaneOf`. + +**Final (CI, on every PR touching core):** + +7. All four `build` shards succeed and each uploads `core--test-evidence` containing a + non-empty `.json` manifest, JUnit XML, HTML report, and JaCoCo `.exec`. +8. `core-test-contract` passes: `reconcile` reports `database_identities_equal: true` and + `unit_database_disjoint: true`, i.e. the H2, MySQL, and PostgreSQL manifests hold identical + normalized identity multisets and the unit manifest shares none of them. +9. No `@Tag("gravitino-core-*-test")` string literal exists in `core/src/test` (all lane + membership goes through `@CoreBackend.*`). + +--- + +## Appendix A: API Changes + +### A.1 `CoreBackend` annotations + +Location: `core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java`. +Test-source only; not part of any published artifact. + +```java +public final class CoreBackend { + public static final String H2_TAG = "gravitino-core-h2-test"; + public static final String MYSQL_TAG = "gravitino-core-mysql-test"; + public static final String POSTGRESQL_TAG = "gravitino-core-postgresql-test"; + + private CoreBackend() {} + + @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) + @Tag(H2_TAG) + public @interface H2 {} + + @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) + @Tag(MYSQL_TAG) + public @interface MySQL {} + + @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) + @Tag(POSTGRESQL_TAG) + public @interface PostgreSQL {} + + @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) + @Tag(H2_TAG) @Tag(MYSQL_TAG) @Tag(POSTGRESQL_TAG) + public @interface All {} +} +``` + +Semantics: + +| Declaration | Tags carried | Lanes | +|---|---|---| +| (none) | none | `coreUnitTest` | +| `@CoreBackend.H2` | `h2` | `coreH2Test` | +| `@CoreBackend.H2 @CoreBackend.MySQL` | `h2`, `mysql` | `coreH2Test`, `coreMySQLTest` | +| `@CoreBackend.All` | `h2`, `mysql`, `postgresql` | all three backend lanes | +| `@Tag("gravitino-docker-test")` only | `docker` | **none** (orphan) | + +`@Target(TYPE)` restricts the annotations to classes. `@Inherited` means an abstract base class +can carry the annotation and every concrete subclass (including Jupiter `@Nested` classes and +`TestJDBCBackend` subclasses) inherits lane membership. The three `*_TAG` constants are the +Java-side mirror of `coreBackendTestTags` in `core/build.gradle.kts` and must be kept equal. + +Usage patterns as migrated on the PR: + +```java +// Multi-backend via a parameter provider; the lane's system property narrows storageProvider(). +@CoreBackend.All +abstract class AbstractEntityStorageTest { + static Object[][] storageProvider() { /* filtered by BackendTestSelector.isSelected */ } +} + +// Multi-backend via @TestTemplate; BackendTestExtension emits one invocation for the lane's backend. +@CoreBackend.All +@ExtendWith({BackendTestExtension.class, ...}) +public abstract class TestJDBCBackend { ... } + +// One @Nested class per single backend; each nested class is its own lane member. +@Tag("gravitino-docker-test") +public class TestJdbcPartitionStatisticStorageIT { + @Nested @CoreBackend.MySQL @Tag("gravitino-docker-test") static class MySQLTest extends Base {} + @Nested @CoreBackend.PostgreSQL @Tag("gravitino-docker-test") static class PostgreSQLTest extends Base {} + @Nested @CoreBackend.H2 static class H2Test extends Base {} +} +``` + +### A.2 Gradle tasks + +Location: `core/build.gradle.kts`. + +```kotlin +val coreBackendTestTags = linkedMapOf( + "h2" to "gravitino-core-h2-test", + "mysql" to "gravitino-core-mysql-test", + "postgresql" to "gravitino-core-postgresql-test" +) +val coreTestBackendProperty = "gravitino.core.test.backend" + +fun registerCoreTestTask(taskName: String, backend: String? = null) = + tasks.register(taskName) { ... } + +registerCoreTestTask("coreUnitTest") +registerCoreTestTask("coreH2Test", "h2") +registerCoreTestTask("coreMySQLTest", "mysql") +registerCoreTestTask("corePostgreSQLTest", "postgresql") + +tasks.register("coreTestLaneOf") { ... } +``` + +Per-task configuration set by `registerCoreTestTask`: + +| Property | Unit lane (`backend == null`) | Backend lane | +|---|---|---| +| `useJUnitPlatform` filter | `excludeTags(h2, mysql, postgresql, "gravitino-docker-test")` | `includeTags(coreBackendTestTags[backend])` | +| `systemProperty(gravitino.core.test.backend)` | not set | `backend` | +| `extraProperties["includeDockerTaggedTests"]` | not set (root build applies its default) | `true` (root build does not add `excludeTags("gravitino-docker-test")`) | +| `maxParallelForks` / `junit.jupiter.execution.parallel.enabled` | default | `1` / `false` | +| Docker precondition (`doFirst`) | none | for `mysql`/`postgresql`: fail unless `rootProject.extra["dockerTest"] == true` | +| JUnit XML | `core/build/test-results//` | same | +| HTML report | `build/reports/tests/core//` | same | +| JaCoCo exec | `core/build/jacoco/.exec` | same | +| Up-to-date inputs | `coreTestSuite=unit`, `coreTestBackend=none`, `coreTestIncludesDockerTaggedTests=false` | `coreTestSuite=`, `coreTestBackend=`, `coreTestIncludesDockerTaggedTests=true` | + +Adding a backend is one map entry plus one `registerCoreTestTask(...)` call plus one nested +annotation in `CoreBackend`; the filter code needs no change. + +The lane JaCoCo files feed `validateCoreSuiteCoverage` and `jacocoTestReport` when +`-PcoreSuiteCoverage=true`, which is how the CI `coverage` job merges the four lanes. + +### A.3 `coreTestLaneOf` task and `CoreTestLaneOf` tool + +Gradle side: + +```kotlin +tasks.register("coreTestLaneOf") { + group = "verification" + dependsOn(tasks.named("testClasses")) + classpath = sourceSets["test"].runtimeClasspath + mainClass.set("org.apache.gravitino.storage.relational.CoreTestLaneOf") + doFirst { + val className = project.findProperty("className") as? String + ?: throw GradleException("Usage: ./gradlew :core:coreTestLaneOf -PclassName=") + args(className) + } +} +``` + +Java side, `core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java`: + +```java +public final class CoreTestLaneOf { + public static void main(String[] args) // exactly one arg: a fully qualified class name +} +``` + +Behaviour: + +- `args.length != 1` or class not on the test classpath: message to stderr, exit code 1. +- Otherwise prints ` tags: [...]` followed by exactly one of: + - ` runs in: coreH2Test, coreMySQLTest, corePostgreSQLTest` (subset, in that order), + - ` carries gravitino-docker-test but no backend tag - it will NOT run in ANY lane. Add ...`, + - ` carries no backend tag - runs in coreUnitTest.` +- Exit code 0 in all three printed cases; the orphan case is a warning, not a failure, so it can + be used interactively without special-casing. + +It runs only `testClasses` (compilation), never a `Test` task. + +### A.4 `dev/ci/core_test_identity.py` + +Two subcommands remain (the unwired `compare-legacy` subcommand was removed on this PR): + +``` +core_test_identity.py manifest --lane {unit,h2,mysql,postgresql} --results

    --output +core_test_identity.py reconcile --manifests --output +``` + +Unit tests: `dev/ci/tests/test_core_test_identity.py`, run by the `core-test-contract` job +before reconcile. + +--- + +## Appendix B: Design Sketch + +### B.1 Discovery-time versus execution-time filtering + +JUnit Platform runs a test task in two phases. **Discovery** builds a `TestPlan`: the Jupiter +engine scans the class directories, creates a `ClassTestDescriptor` per test class, and attaches +each descriptor's tags. `PostDiscoveryFilter`s then prune descriptors from that plan. **Execution** +walks the surviving plan, evaluates `ExecutionCondition`s, and runs (or skips) each node. + +Gradle's `useJUnitPlatform { includeTags(...) / excludeTags(...) }` is compiled into a +`TagFilter`, which is a `PostDiscoveryFilter`. A descriptor excluded by it is removed from the plan +before execution starts. Gradle's XML reporter only sees the executed plan, so an excluded class +contributes no `` and no `` elements at all. + +An `ExecutionCondition` (what the reverted `BackendLaneCondition` was) runs at execution time. A +disabled class is still in the plan; Jupiter reports it as skipped, and Gradle writes a +`` for each of its methods. That is a *trace*, and the +reconcile invariant in B.3 tolerates no trace. + +The shipped design therefore uses tags only. The `@CoreBackend.*` annotations exist solely so that +contributors do not type tag strings; at the JUnit level they are indistinguishable from writing +`@Tag("gravitino-core-h2-test")` directly. + +### B.2 How JUnit resolves the tags + +Jupiter collects a class's tags with +`AnnotationSupport.findRepeatableAnnotations(clazz, Tag.class)`. That lookup: + +1. Reads the class's directly present annotations. +2. Follows `@Inherited` annotations up the superclass chain. +3. For each annotation found, recursively inspects the annotation *type's* own meta-annotations, + so a `@CoreBackend.All` on the class yields the three `@Tag` meta-annotations declared on + `CoreBackend.All`. +4. Unwraps the `@Tags` container so repeated `@Tag`s are returned individually. + +Two consequences shape the API: + +- Tags come from an annotation type's fixed meta-annotations, never from an attribute value on + the usage site. This is why a parameterized `backends = {...}` attribute cannot participate in + standard tag filtering and would need a custom filter. +- Only annotations reachable through this reflective walk count. Any annotation whose meaning + depends on code running (an `@ExtendWith` extension, a condition) is invisible to the tag + filter. `TestCoreDatabaseLaneAnnotations.testNoneOfThemCarryAnExecutionTimeHook` enforces that + the four annotation types declare nothing outside `{Documented, Inherited, Retention, Target, + Tag, Tags}`, so the annotations cannot acquire execution-time behaviour without breaking the + test. + +`CoreTestLaneOf` calls exactly this `findRepeatableAnnotations` method and then applies the same +membership rule the Gradle filter applies (`tags ∩ {h2, mysql, postgresql}`), which is why its +answer is authoritative without running a lane. `TestCoreDatabaseLaneAnnotations.tagsOf` uses the +same call, so the regression guard, the local tool, and the engine share one lookup. + +### B.3 How `core_test_identity.py` validates lane membership + +**`manifest --lane L --results DIR`** parses every `TEST-*.xml` under `DIR` and, for each +``: + +1. Extracts backend markers from `C` (via `STATS_BACKEND_CLASS_RE`, matching + `TestJdbcPartitionStatisticStorageIT$Test`) and from `N` (via + `TEST_TEMPLATE_BACKEND_RE`, matching `[ Backend]` as emitted by + `BackendTestExtension`, and `BACKEND_TOKEN_RE`, matching a standalone `h2`/`mysql`/`postgresql` + token as emitted by parameterized `storageProvider()` names). +2. Fails closed (`ManifestError`, exit 1) if `L == unit` and any marker is present, or if `L` is + a database lane and any marker other than `L` is present ("foreign backend marker"). +3. Normalizes the identity: nested stats class names collapse to `...$BackendTest`, backend + tokens collapse to `BACKEND`, `[BACKEND Backend]`, and trailing `[n]` invocation indices + collapse to `[INDEX]`. +4. Counts the normalized `(classname, name)` pair in a multiset, counts status + (`passed`/`skipped`/`failures`/`errors`), and sums `time`. + +It also fails closed on missing XML, zero testcases, any failure or error, and malformed +durations. The output JSON carries the identity list, counts, and a SHA-256 `identity_digest`. + +Note step 2 does not look at status: a `` testcase is still a testcase. This is the +precise reason execution-time skipping is incompatible with the design. Under the reverted +`ExecutionCondition` scheme, `TestJdbcPartitionStatisticStorageIT$MySQLTest` appeared in the H2 +lane's XML as skipped, step 2 saw a `mysql` marker in the `h2` lane, and the manifest step +failed. Even a class with no recognizable markers would have broken reconcile, since a skipped +entry present in one lane but absent from another changes the multiset. + +**`reconcile --manifests unit h2 mysql postgresql`** loads exactly four manifests (one per lane, +no duplicates, no missing), re-validates each (schema, counts, digest, no failures), then asserts: + +- `counters[mysql] == counters[h2]` and `counters[postgresql] == counters[h2]` as multisets, + reporting up to five missing/extra identities on mismatch. +- `counters[unit] & counters[h2]` is empty (unit and database identities are disjoint). + +It writes `summary.json` with `database_identities_equal`, `unit_database_disjoint`, per-lane +summaries, and combined counts. Any violation exits 1 and fails the `core-test-contract` job. + +### B.4 End-to-end flow in CI + +``` +changes ──► build (matrix: core-unit | core-h2 | core-mysql | core-postgresql | ...) + │ each core-* shard: + │ ./gradlew :core: ... (tag filter prunes at discovery) + │ core_test_identity.py manifest --lane L (fails on foreign markers) + │ validate evidence files exist and are non-empty + │ upload core--test-evidence + ▼ + core-test-contract (needs: [changes, build]) + │ unittest dev/ci/tests/test_core_test_identity.py + │ download the four evidence artifacts + │ core_test_identity.py reconcile --manifests unit h2 mysql postgresql + │ upload summary.json as core-test-contract + ▼ + coverage (needs core-test-contract; merges the four JaCoCo .exec files) +``` + +`dev/ci/test-shards.sh` maps `build/core-` to `:core:` and emits `-x :core:test` +for the `others` shard so the unsplit task never runs in CI. + +### B.5 Invariants, in one place + +1. A class is in lane `L` iff its `findRepeatableAnnotations(Tag)` set contains `L`'s tag + (backend lanes) or contains none of the three backend tags and not `gravitino-docker-test` + (unit lane). +2. Every `@CoreBackend.*` type declares only `Tag`/`Tags` plus the four standard + meta-annotations. (Guarded by `TestCoreDatabaseLaneAnnotations`.) +3. A lane's JUnit XML contains testcases only for classes in that lane. (Follows from 1 and 2 + via discovery-time pruning; checked by `manifest`'s foreign-marker rule.) +4. The h2, mysql, and postgresql lanes yield identical normalized identity multisets, and the + unit lane is disjoint from them. (Checked by `reconcile`.) +5. `CoreBackend.*_TAG == coreBackendTestTags[*]`. (Not machine-checked; documented on both + sides.) + +--- + +## Appendix C: How to run core tests locally + +This is the contributor-facing walkthrough; `docs/how-to-test.md` covers the repo-wide +`./gradlew test` task and does not mention `core` specifically, so it was left unchanged and this +appendix is the source of truth for the lanes instead. + +### Running a lane + +```bash +./gradlew :core:coreUnitTest # no database, no Docker - the default +./gradlew :core:coreH2Test # H2-backed tests, no Docker +./gradlew :core:coreMySQLTest -PskipDockerTests=false # MySQL-backed, needs Docker +./gradlew :core:corePostgreSQLTest -PskipDockerTests=false # PostgreSQL-backed, needs Docker +``` + +`coreMySQLTest`/`corePostgreSQLTest` fail fast with a clear `GradleException` if Docker isn't +running and `-PskipDockerTests=false` wasn't passed - they won't silently no-op. `coreH2Test` needs +neither Docker nor that flag. Each lane runs its `Test` task sequentially +(`maxParallelForks = 1`) because database tests mutate process-wide state. + +Do **not** run `./gradlew :core:test` - it is deprecated in place (Q4/A.2): it still works, but +warns and does not correspond to any of the four lanes above or any CI shard. + +### Tagging a new test class + +Pick the annotation that matches where the class needs to run, from +`org.apache.gravitino.storage.relational.CoreBackend`: + +```java +@CoreBackend.H2 // only against H2 +public class MyPlainDbTest { ... } + +@CoreBackend.H2 +@CoreBackend.MySQL // against H2 and MySQL, not PostgreSQL +public class MyPartialTest { ... } + +@CoreBackend.All // against all three backends +public abstract class MyMultiBackendTest { ... } +``` + +No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. See +A.1's usage-pattern table for the three concrete shapes already in the codebase +(parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). + +### Checking where a class lands, before running anything + +```bash +./gradlew :core:coreTestLaneOf -PclassName=org.apache.gravitino.storage.relational.TestJDBCBackend +``` + +Compiles test sources (nothing else) and prints the class's tags and the lane(s) it runs in, or an +explicit warning if it carries `gravitino-docker-test` with no `@CoreBackend.*` annotation - the +one case that silently drops a class out of every lane (Q6). Run this after adding or changing an +annotation on a database-touching test class, before pushing. From 44978817b601e1286ecd84a048de5cd61e912939 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:39:45 +0000 Subject: [PATCH 15/17] fix(test): fix unit-lane manifest break, tighten CoreBackend docs TestCoreDatabaseLaneAnnotations' @ParameterizedTest used the default display name, which embeds each case's expected-tags argument (e.g. "[gravitino-core-h2-test]") into its own JUnit XML. That test runs in coreUnitTest, so core_test_identity.py's unit-lane manifest check rejected it as a foreign backend marker - reproduced directly, confirmed with a full 1968-testcase sweep through the real normalize_identity function after the fix, zero marker errors. Fixed by naming on the class under test only. Also, found via two independent Fable reviews: - CoreBackend's Javadoc misattributed core_test_identity.py's reconcile step to core/build.gradle.kts, and its lane-membership bullets didn't mention the docker-tag-only orphan case. Fixed both. - The SPIP's Appendix C taught a partial-backend tagging example (@CoreBackend.H2 @CoreBackend.MySQL) that fails CI's reconcile step unless normalized sibling classes exist for the omitted backends, which only one existing class shape satisfies. Replaced it with guidance to default to @CoreBackend.All and an explicit warning about the constraint. --- .../storage/relational/CoreBackend.java | 20 ++++---- .../TestCoreDatabaseLaneAnnotations.java | 8 +++- design-docs/testing/core-db-split.md | 46 ++++++++++++++----- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java index 24098529462..2e2d122ee32 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -36,9 +36,12 @@ * *
      *
    • {@link H2}, {@link MySQL}, {@link PostgreSQL} pin a class to one lane; stack more than one - * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. + * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. A class tagged + * for some but not all backends must satisfy the CI-legality constraint below. *
    • {@link All} runs a class under all three lanes. - *
    • A class with none of these is a unit test and runs in {@code coreUnitTest}. + *
    • A class with none of these is a unit test and runs in {@code coreUnitTest} - unless it also + * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane at all. + * Check with {@code ./gradlew :core:coreTestLaneOf -PclassName=}. *
    * *

    Each is a plain JUnit composed annotation: meta-annotated with {@link Tag} and nothing else, @@ -49,12 +52,13 @@ * involved. {@link #H2_TAG}, {@link #MYSQL_TAG}, and {@link #POSTGRESQL_TAG} must stay in sync with * {@code coreBackendTestTags} in {@code core/build.gradle.kts}. * - *

    Because {@code core/build.gradle.kts}'s {@code reconcile} step (via {@code - * dev/ci/core_test_identity.py}) requires the h2/mysql/postgresql lanes to run the exact same set - * of normalized test identities, a single- or multi- (but not all-) backend class is only CI-legal - * as a normalized sibling of matching classes in the other backend(s) it omits - see {@code - * TestJdbcPartitionStatisticStorageIT}'s {@code H2Test}/{@code MySQLTest}/{@code PostgreSQLTest} - * nested classes for the pattern this currently requires. + *

    Because {@code dev/ci/core_test_identity.py}'s {@code reconcile} step (invoked from {@code + * .github/workflows/build.yml}, not from {@code core/build.gradle.kts}) requires the + * h2/mysql/postgresql lanes to run the exact same set of normalized test identities, a single- or + * multi- (but not all-) backend class is only CI-legal as a normalized sibling of matching classes + * in the other backend(s) it omits - see {@code TestJdbcPartitionStatisticStorageIT}'s {@code + * H2Test}/{@code MySQLTest}/{@code PostgreSQLTest} nested classes for the pattern this currently + * requires. */ public final class CoreBackend { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java index 416e7b5cdaf..2aeb7cc0f93 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java @@ -57,7 +57,13 @@ private static Stream annotationsAndExpectedTags() { }); } - @ParameterizedTest + // Default JUnit display names include argument toStrings - for expectedTags that would print a + // tag string like "[gravitino-core-h2-test]" into this class's own JUnit XML, which is itself + // part of the coreUnitTest lane, and core_test_identity.py's manifest step rejects any standalone + // backend token there as a foreign-lane marker. Naming on {0} (the class under test) only avoids + // that: its simple name (e.g. H2Annotated) has no such token, since "h2"/"mysql"/"postgresql" is + // never followed by a non-letter there. + @ParameterizedTest(name = "{index}: {0}") @MethodSource("annotationsAndExpectedTags") void testExpandsToExpectedTags(Class annotatedClass, List expectedTags) { assertEquals(expectedTags, tagsOf(annotatedClass)); diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md index 20e0792dfc6..7c590d07ea7 100644 --- a/design-docs/testing/core-db-split.md +++ b/design-docs/testing/core-db-split.md @@ -258,6 +258,24 @@ Contributor-facing documentation for the lanes lives in this document (Appendix `docs/how-to-test.md`, which is a repo-wide doc unrelated to this split (it only documents the root `./gradlew test` task and does not mention `core` or its lanes) - no edit there was needed. +Found and fixed during review, before merge: `TestCoreDatabaseLaneAnnotations`'s +`@ParameterizedTest` used the default display name, which embeds each case's expected-tags +argument (e.g. `[gravitino-core-h2-test]`) into its own JUnit XML - since that test carries no +`@CoreBackend.*` annotation itself, it runs in `coreUnitTest`, and `manifest`'s unit-lane check +rejects any standalone backend token there as a foreign marker. Reproduced directly +(`./gradlew :core:coreUnitTest --tests ...TestCoreDatabaseLaneAnnotations` then `manifest --lane +unit` failed with "contains an explicit backend marker ['h2']"), fixed by naming on the class +under test only (`@ParameterizedTest(name = "{index}: {0}")`), re-verified clean, and confirmed +by scanning all 1968 `coreUnitTest` testcases through the real `normalize_identity` function with +zero marker errors. + +**Optional follow-up, not required to ship:** `STATS_BACKEND_CLASS_RE` (B.2/B.3) is hard-coded to +one outer class name, so it does not generalize to a second nested-per-backend class without +editing the regex. Appendix C now tells contributors to prefer `@CoreBackend.All` and flags this +constraint explicitly rather than silently teaching a pattern that fails `reconcile`; generalizing +the regex to any outer class name is a real improvement but touches CI-wired parsing logic and +needs its own fixtures/tests, so it was left out of this pass. + **Remaining, in scope, not yet done:** - Reply to the open review thread on #13517 and update the PR description to match the final @@ -620,20 +638,26 @@ Pick the annotation that matches where the class needs to run, from `org.apache.gravitino.storage.relational.CoreBackend`: ```java -@CoreBackend.H2 // only against H2 -public class MyPlainDbTest { ... } - -@CoreBackend.H2 -@CoreBackend.MySQL // against H2 and MySQL, not PostgreSQL -public class MyPartialTest { ... } - -@CoreBackend.All // against all three backends +@CoreBackend.All // against all three backends - the default choice public abstract class MyMultiBackendTest { ... } + +@CoreBackend.H2 // only against H2 - a genuinely H2-only test +public class MyH2OnlyTest { ... } ``` -No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. See -A.1's usage-pattern table for the three concrete shapes already in the codebase -(parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). +No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. + +**Use `@CoreBackend.All` unless the class is genuinely single-backend.** Stacking a subset (e.g. +`@CoreBackend.H2 @CoreBackend.MySQL`) compiles and each lane it names runs the class, but CI's +`reconcile` step then requires a *normalized sibling* in every backend lane it omits (Appendix +A.1/A.4, `CoreBackend`'s Javadoc) - today the only shape that satisfies that is one `@Nested` +class per backend under a shared outer class, following +`TestJdbcPartitionStatisticStorageIT`'s `H2Test`/`MySQLTest`/`PostgreSQLTest` pattern exactly +(`core_test_identity.py`'s normalization is hard-coded to that one outer class name - see B.3). A +standalone partial-backend class without that sibling structure passes locally in the lanes it +runs in and then fails `reconcile` in CI with "Database identity mismatch". If in doubt, use +`@CoreBackend.All`. See A.1's usage-pattern table for the three concrete shapes already in the +codebase (parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). ### Checking where a class lands, before running anything From 2a495f57418398384be8981daa389f3c97051d49 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sun, 27 Sep 2026 01:43:41 -0700 Subject: [PATCH 16/17] test(core): reuse database fixtures by class --- .../job/TestJobManagerMultiNode.java | 3 + .../storage/AbstractEntityStorageTest.java | 12 +- .../gravitino/storage/TestSQLScripts.java | 3 + .../relational/BackendTestExtension.java | 369 ++++++++++++++--- .../storage/relational/DatabaseFixture.java | 42 ++ .../storage/relational/DatabaseIsolation.java | 32 ++ .../relational/TestBackendTestExtension.java | 390 ++++++++++++++++++ .../test/container/ContainerSuite.java | 17 +- .../test/container/TestContainerSuite.java | 48 +++ .../test/util/CloseContainerExtension.java | 23 +- 10 files changed, 846 insertions(+), 93 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java create mode 100644 integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestContainerSuite.java diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java index 487645a10d9..8336560691f 100644 --- a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java +++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java @@ -51,6 +51,8 @@ import org.apache.gravitino.meta.JobEntity; import org.apache.gravitino.meta.JobTemplateEntity; import org.apache.gravitino.storage.RandomIdGenerator; +import org.apache.gravitino.storage.relational.DatabaseFixture; +import org.apache.gravitino.storage.relational.DatabaseIsolation; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.TestJDBCBackend; import org.apache.gravitino.utils.NameIdentifierUtil; @@ -66,6 +68,7 @@ * JobManager} and {@link LocalJobExecutor}, share the same relational metadata store. The status * pull and the cleanup are triggered manually, so that the test controls which node runs them when. */ +@DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) public class TestJobManagerMultiNode extends TestJDBCBackend { private static final String METALAKE = "metalake_job_multi_node"; diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index 7cb53318d56..fb862dc2e6f 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -76,8 +76,8 @@ import org.apache.gravitino.exceptions.NoSuchEntityException; import org.apache.gravitino.exceptions.NonEmptyEntityException; import org.apache.gravitino.file.Fileset; -import org.apache.gravitino.integration.test.container.ContainerSuite; import org.apache.gravitino.integration.test.util.BaseIT; +import org.apache.gravitino.integration.test.util.CloseContainerExtension; import org.apache.gravitino.meta.AuditInfo; import org.apache.gravitino.meta.BaseMetalake; import org.apache.gravitino.meta.CatalogEntity; @@ -104,13 +104,14 @@ import org.apache.gravitino.storage.relational.converters.SQLExceptionConverterFactory; import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper; import org.apache.ibatis.session.SqlSession; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @CoreBackend.All +@ExtendWith(CloseContainerExtension.class) abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); @@ -134,13 +135,6 @@ static Object[][] storageProvider() { .toArray(Object[][]::new); } - @AfterEach - void closeSuit() throws IOException { - // todo: refactor TestEntityStorage to extend TestJDBCBackend, otherwise, each test will start - // and stop the container suite. - ContainerSuite.getInstance().close(); - } - protected void init(String type, Config config) throws IllegalAccessException { Preconditions.checkArgument(StringUtils.isNotBlank(type)); File dir = new File(DB_DIR); diff --git a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java index c7586147786..1a222d78e4e 100644 --- a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java +++ b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java @@ -34,12 +34,15 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.gravitino.storage.relational.DatabaseFixture; +import org.apache.gravitino.storage.relational.DatabaseIsolation; import org.apache.gravitino.storage.relational.TestJDBCBackend; import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.TestTemplate; +@DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) public class TestSQLScripts extends TestJDBCBackend { @TestTemplate diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index 7dc09519136..26b634346b9 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java @@ -50,12 +50,15 @@ import org.apache.gravitino.storage.RandomIdGenerator; import org.apache.gravitino.storage.relational.service.EntityIdService; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; +import org.junit.jupiter.api.extension.TestWatcher; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,34 +73,44 @@ public class BackendTestExtension ExtensionContext.Namespace.create(BackendTestExtension.class); private static final String STORE_KEY = "BACKEND_MAP"; + private final BackendFactory backendFactory; + + /** Creates the extension with the production database-backend factory. */ + public BackendTestExtension() { + this(new DefaultBackendFactory()); + } + + BackendTestExtension(BackendFactory backendFactory) { + this.backendFactory = backendFactory; + } + @Override public void beforeAll(ExtensionContext context) { - // Initialize a Map and store it at the Class level. - // Key: backendType ("h2", "mysql"...), Value: RelationalBackend instance - context.getStore(NAMESPACE).put(STORE_KEY, new ConcurrentHashMap()); + context.getStore(NAMESPACE).put(STORE_KEY, new ConcurrentHashMap()); } @Override @SuppressWarnings("unchecked") - public void afterAll(ExtensionContext context) { - // Test class ended, close all started Backend - ConcurrentHashMap map = - (ConcurrentHashMap) context.getStore(NAMESPACE).get(STORE_KEY); + public void afterAll(ExtensionContext context) throws Exception { + ConcurrentHashMap map = + (ConcurrentHashMap) context.getStore(NAMESPACE).remove(STORE_KEY); if (map != null) { - map.forEach( - (type, backend) -> { - try { - LOG.info("Tearing down backend: {}", type); - backend.close(); - // H2 special cleaning logic - if ("h2".equals(type) && backend instanceof H2BackendWrapper) { - ((H2BackendWrapper) backend).cleanFile(); - } - } catch (Exception e) { - LOG.error("Failed to close backend {}", type, e); - } - }); + Exception failure = null; + for (BackendResource resource : map.values()) { + try { + resource.close(); + } catch (Exception e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } } } @@ -113,8 +126,8 @@ public Stream provideTestTemplateInvocationContex Optional selectedBackend = BackendTestSelector.selectedBackend(); if (selectedBackend.isPresent()) { LOG.info("Running tests with the selected {} backend.", selectedBackend.get()); - return Stream.of(selectedBackend.get()) - .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + return createInvocationContexts( + testMethodName, Collections.singletonList(selectedBackend.get()), true); } List backendsToTest = new ArrayList<>(); @@ -130,17 +143,33 @@ public Stream provideTestTemplateInvocationContex "Running tests with H2 backend only. Set env var 'dockerTest=true' to include all backends."); } - return backendsToTest.stream() - .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + return createInvocationContexts(testMethodName, backendsToTest, false); + } + + Stream createInvocationContexts( + String testMethodName, List backends, boolean reuseBackend) { + return backends.stream() + .map( + backendType -> + new BackendInvocationContext( + testMethodName, backendType, reuseBackend, backendFactory)); } private static class BackendInvocationContext implements TestTemplateInvocationContext { private final String testMethodName; private final String backendType; - - public BackendInvocationContext(String testMethodName, String backendType) { + private final boolean reuseBackend; + private final BackendFactory backendFactory; + + private BackendInvocationContext( + String testMethodName, + String backendType, + boolean reuseBackend, + BackendFactory backendFactory) { this.testMethodName = testMethodName; this.backendType = backendType; + this.reuseBackend = reuseBackend; + this.backendFactory = backendFactory; } @Override @@ -153,35 +182,26 @@ public String getDisplayName(int invocationIndex) { @Override public List getAdditionalExtensions() { - return Collections.singletonList(new BackendSetupCallback(backendType)); + return Collections.singletonList( + new BackendSetupCallback(backendType, reuseBackend, backendFactory)); } } - private static class BackendSetupCallback implements BeforeEachCallback { - private final String backendType; - private final BaseIT baseIT = new BaseIT(); + @FunctionalInterface + interface BackendFactory { + BackendResource create(String backendType) throws Exception; + } - public BackendSetupCallback(String backendType) { - this.backendType = backendType; - } + @FunctionalInterface + interface BackendActivator { + void activate() throws Exception; + } + private static class DefaultBackendFactory implements BackendFactory { @Override - public void beforeEach(ExtensionContext context) throws Exception { - - // Lazy loading: If the backend of this type has not started, start it - RelationalBackend backend = startBackend(backendType); - - // Inject into the test instance - Object testInstance = context.getRequiredTestInstance(); - if (testInstance instanceof TestJDBCBackend) { - LOG.info("Injecting {} backend into test instance", backendType); - ((TestJDBCBackend) testInstance).setBackend(backend); - ((TestJDBCBackend) testInstance).setBackendType(backendType); - } - } - - private RelationalBackend startBackend(String type) throws Exception { - LOG.info("Initializing backend resource: {}", type); + public BackendResource create(String backendType) throws Exception { + BaseIT baseIT = new BaseIT(); + LOG.info("Initializing backend resource: {}", backendType); Config config = Mockito.mock(Config.class); Mockito.when(config.get(Configs.ENTITY_STORE)).thenReturn(Configs.RELATIONAL_ENTITY_STORE); Mockito.when(config.get(Configs.ENTITY_RELATIONAL_STORE)) @@ -192,20 +212,15 @@ private RelationalBackend startBackend(String type) throws Exception { .thenReturn(DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_WAIT_MILLISECONDS); Mockito.when(config.get(CACHE_ENABLED)).thenReturn(true); - - FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true); - FieldUtils.writeField( - GravitinoEnv.getInstance(), "idGenerator", RandomIdGenerator.INSTANCE, true); - RelationalBackend backend = new JDBCBackend(); - if ("mysql".equals(type)) { + if ("mysql".equals(backendType)) { String url = baseIT.startAndInitMySQLBackend(); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL)).thenReturn(url); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root"); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD)).thenReturn("root"); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER)) .thenReturn("com.mysql.cj.jdbc.Driver"); - } else if ("postgresql".equals(type)) { + } else if ("postgresql".equals(backendType)) { String url = baseIT.startAndInitPGBackend(); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_URL)).thenReturn(url); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root"); @@ -226,15 +241,240 @@ private RelationalBackend startBackend(String type) throws Exception { Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER)).thenReturn("org.h2.Driver"); // Wrap it with a Wrapper so that files can be deleted during the clean process - backend = new H2BackendWrapper(config, jdbcPath); + backend = new H2BackendWrapper(jdbcPath); + } + + BackendResource resource = + new BackendResource( + backendType, + backend, + () -> { + FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true); + FieldUtils.writeField( + GravitinoEnv.getInstance(), "idGenerator", RandomIdGenerator.INSTANCE, true); + EntityIdService.initialize(new RelationalEntityStoreIdResolver()); + }); + try { + resource.activate(); + // Close the backend before initializing to make sure the singleton sqlSession is cleared. + backend.close(); + backend.initialize(config); + return resource; + } catch (Exception failure) { + try { + resource.close(); + } catch (Exception closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + } + + private static class BackendSetupCallback + implements BeforeEachCallback, + AfterEachCallback, + LifecycleMethodExecutionExceptionHandler, + TestWatcher { + private final String backendType; + private final boolean reuseBackend; + private final BackendFactory backendFactory; + private BackendResource backendResource; + private boolean closeAfterEach; + + private BackendSetupCallback( + String backendType, boolean reuseBackend, BackendFactory backendFactory) { + this.backendType = backendType; + this.reuseBackend = reuseBackend; + this.backendFactory = backendFactory; + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + DatabaseIsolation isolation = isolation(context); + if (isolation == DatabaseIsolation.DEDICATED_SERVER) { + throw new UnsupportedOperationException( + "DEDICATED_SERVER isolation is not implemented for core database tests"); } - // close the backend before initializing to make sure the singleton sqlSession - // has been cleared. - backend.close(); - backend.initialize(config); - EntityIdService.initialize(new RelationalEntityStoreIdResolver()); - return backend; + closeAfterEach = !reuseBackend || isolation == DatabaseIsolation.FRESH_NAMESPACE; + try { + if (reuseBackend && isolation == DatabaseIsolation.FRESH_NAMESPACE) { + closeClassBackend(context); + } + backendResource = closeAfterEach ? newBackendResource() : getOrCreateClassBackend(context); + backendResource.activate(); + injectBackend(context, backendResource.backend); + } catch (Exception e) { + cleanupAfterSetupFailure(e); + throw e; + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + if (closeAfterEach && backendResource != null) { + backendResource.close(); + backendResource = null; + } + } + + @Override + public void testAborted(ExtensionContext context, Throwable cause) { + poisonSharedFixture(); + } + + @Override + public void testFailed(ExtensionContext context, Throwable cause) { + poisonSharedFixture(); + } + + @Override + public void handleBeforeEachMethodExecutionException( + ExtensionContext context, Throwable throwable) throws Throwable { + poisonSharedFixture(); + throw throwable; + } + + @Override + public void handleAfterEachMethodExecutionException( + ExtensionContext context, Throwable throwable) throws Throwable { + poisonSharedFixture(); + throw throwable; + } + + private void injectBackend(ExtensionContext context, RelationalBackend backend) { + Object testInstance = context.getRequiredTestInstance(); + if (testInstance instanceof TestJDBCBackend) { + LOG.info("Injecting {} backend into test instance", backendType); + ((TestJDBCBackend) testInstance).setBackend(backend); + ((TestJDBCBackend) testInstance).setBackendType(backendType); + } + } + + private BackendResource getOrCreateClassBackend(ExtensionContext context) throws Exception { + ConcurrentHashMap backendMap = backendMap(context); + synchronized (backendMap) { + BackendResource resource = backendMap.get(backendType); + if (resource != null && resource.poisoned) { + backendMap.remove(backendType); + resource.close(); + resource = null; + } + if (resource == null) { + resource = newBackendResource(); + backendMap.put(backendType, resource); + } + return resource; + } + } + + private void closeClassBackend(ExtensionContext context) throws Exception { + ConcurrentHashMap backendMap = backendMap(context); + synchronized (backendMap) { + BackendResource resource = backendMap.remove(backendType); + if (resource != null) { + resource.close(); + } + } + } + + @SuppressWarnings("unchecked") + private ConcurrentHashMap backendMap(ExtensionContext context) { + ExtensionContext classContext = context; + while (classContext.getTestMethod().isPresent()) { + classContext = + classContext + .getParent() + .orElseThrow(() -> new IllegalStateException("Test class context is unavailable")); + } + + ConcurrentHashMap backendMap = + (ConcurrentHashMap) + classContext.getStore(NAMESPACE).get(STORE_KEY); + if (backendMap == null) { + throw new IllegalStateException("Backend fixture store is unavailable"); + } + return backendMap; + } + + private BackendResource newBackendResource() throws Exception { + return backendFactory.create(backendType); + } + + private void cleanupAfterSetupFailure(Exception failure) { + if (backendResource == null) { + return; + } + if (!closeAfterEach) { + backendResource.poisoned = true; + return; + } + try { + backendResource.close(); + } catch (Exception closeFailure) { + failure.addSuppressed(closeFailure); + } finally { + backendResource = null; + } + } + + private void poisonSharedFixture() { + if (!closeAfterEach && backendResource != null) { + backendResource.poisoned = true; + } + } + + private DatabaseIsolation isolation(ExtensionContext context) { + DatabaseFixture methodFixture = + context.getRequiredTestMethod().getAnnotation(DatabaseFixture.class); + if (methodFixture != null) { + return methodFixture.value(); + } + DatabaseFixture classFixture = + context.getRequiredTestClass().getAnnotation(DatabaseFixture.class); + return classFixture == null ? DatabaseIsolation.RESETTABLE_NAMESPACE : classFixture.value(); + } + } + + static class BackendResource { + private final String backendType; + private final RelationalBackend backend; + private final BackendActivator activator; + private boolean poisoned; + + BackendResource(String backendType, RelationalBackend backend, BackendActivator activator) { + this.backendType = backendType; + this.backend = backend; + this.activator = activator; + } + + private void activate() throws Exception { + activator.activate(); + } + + private void close() throws Exception { + LOG.info("Tearing down backend: {}", backendType); + Exception failure = null; + try { + backend.close(); + } catch (Exception e) { + failure = e; + } + try { + if (backend instanceof H2BackendWrapper) { + ((H2BackendWrapper) backend).cleanFile(); + } + } catch (Exception e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } } } @@ -242,9 +482,8 @@ private RelationalBackend startBackend(String type) throws Exception { public static class H2BackendWrapper extends JDBCBackend { private final String path; - public H2BackendWrapper(Config config, String path) { + public H2BackendWrapper(String path) { this.path = path; - super.initialize(config); } public void cleanFile() throws IOException { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java new file mode 100644 index 00000000000..6c966af4b0c --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Declares the database-fixture isolation required by a test class or method. */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface DatabaseFixture { + + /** + * Returns the required database isolation. + * + * @return the isolation policy + */ + DatabaseIsolation value() default DatabaseIsolation.RESETTABLE_NAMESPACE; +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java new file mode 100644 index 00000000000..c87d27ca6ca --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +/** Isolation policies for database-backed tests. */ +public enum DatabaseIsolation { + /** Reuse a class-scoped fixture and reset its tables before each test. */ + RESETTABLE_NAMESPACE, + + /** Create and close a fresh database backend for each test. */ + FRESH_NAMESPACE, + + /** Create a dedicated database server for each test. */ + DEDICATED_SERVER +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java new file mode 100644 index 00000000000..d9c3c30c142 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java @@ -0,0 +1,390 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.TestTemplateInvocationContext; +import org.junit.jupiter.api.extension.TestWatcher; + +class TestBackendTestExtension { + + @Test + void testSelectedBackendReusesClassFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(1, factory.backends.size()); + assertEquals(2, factory.activationCount); + verify(factory.backends.get(0), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(0), times(1)).close(); + } + + @Test + void testLegacyH2DoesNotReuseFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, false, contexts.methodContext("first")); + runInvocation(extension, false, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + + extension.afterAll(contexts.classContext); + } + + @Test + void testFreshClassCreatesAndClosesEachInvocation() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(FreshIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + + extension.afterAll(contexts.classContext); + } + + @Test + void testFreshMethodEvictsSharedFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(MixedIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("resettable")); + runInvocation(extension, true, contexts.methodContext("fresh")); + runInvocation(extension, true, contexts.methodContext("resettable")); + + assertEquals(3, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + verify(factory.backends.get(2), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(2), times(1)).close(); + } + + @Test + void testFailedSharedFixtureIsClosedAndRebuilt() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + ((AfterEachCallback) firstCallback).afterEach(firstContext); + ((TestWatcher) firstCallback).testFailed(firstContext, new AssertionError("expected")); + + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testLifecycleFailurePoisonsSharedFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + assertThrows( + IllegalStateException.class, + () -> + ((LifecycleMethodExecutionExceptionHandler) firstCallback) + .handleBeforeEachMethodExecutionException( + firstContext, new IllegalStateException("expected"))); + + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testActivationFailurePoisonsSharedFixture() throws Exception { + List backends = new ArrayList<>(); + AtomicInteger activations = new AtomicInteger(); + BackendTestExtension.BackendFactory factory = + backendType -> { + RelationalBackend backend = mock(RelationalBackend.class); + backends.add(backend); + return new BackendTestExtension.BackendResource( + backendType, + backend, + () -> { + if (activations.incrementAndGet() == 2) { + throw new IllegalStateException("expected"); + } + }); + }; + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + assertThrows( + IllegalStateException.class, + () -> startInvocation(extension, true, contexts.methodContext("second"))); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, backends.size()); + verify(backends.get(0), times(1)).close(); + + extension.afterAll(contexts.classContext); + verify(backends.get(1), times(1)).close(); + } + + @Test + void testDedicatedServerIsolationFailsFast() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DedicatedServerTests.class); + extension.beforeAll(contexts.classContext); + + assertThrows( + UnsupportedOperationException.class, + () -> startInvocation(extension, true, contexts.methodContext("first"))); + assertEquals(0, factory.backends.size()); + + extension.afterAll(contexts.classContext); + } + + @Test + void testAfterAllClosesEveryFixtureAndAggregatesFailures() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, "h2", contexts.methodContext("first")); + runInvocation(extension, true, "mysql", contexts.methodContext("second")); + doThrow(new IOException("h2 close")).when(factory.backends.get(0)).close(); + doThrow(new IOException("mysql close")).when(factory.backends.get(1)).close(); + + Exception failure = + assertThrows(Exception.class, () -> extension.afterAll(contexts.classContext)); + assertEquals(1, failure.getSuppressed().length); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testH2CleanupRunsAfterUncheckedBackendCloseFailure() throws Exception { + BackendTestExtension.H2BackendWrapper backend = + mock(BackendTestExtension.H2BackendWrapper.class); + BackendTestExtension extension = + new BackendTestExtension( + backendType -> + new BackendTestExtension.BackendResource(backendType, backend, () -> {})); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + runInvocation(extension, true, contexts.methodContext("first")); + + doThrow(new IllegalStateException("close")).when(backend).close(); + doThrow(new IOException("clean")).when(backend).cleanFile(); + + Exception failure = + assertThrows(Exception.class, () -> extension.afterAll(contexts.classContext)); + assertEquals("close", failure.getMessage()); + assertEquals(1, failure.getSuppressed().length); + assertEquals("clean", failure.getSuppressed()[0].getMessage()); + verify(backend, times(1)).cleanFile(); + } + + @Test + void testDisplayNamePreservesMethodAndBackend() { + BackendTestExtension extension = + new BackendTestExtension( + backendType -> + new BackendTestExtension.BackendResource( + backendType, mock(RelationalBackend.class), () -> {})); + + TestTemplateInvocationContext context = + extension + .createInvocationContexts("testMethod", List.of("h2"), true) + .findFirst() + .orElseThrow(); + + assertEquals("testMethod[H2 Backend]", context.getDisplayName(1)); + } + + private static void runInvocation( + BackendTestExtension extension, boolean reuseBackend, ExtensionContext context) + throws Exception { + runInvocation(extension, reuseBackend, "h2", context); + } + + private static void runInvocation( + BackendTestExtension extension, + boolean reuseBackend, + String backendType, + ExtensionContext context) + throws Exception { + Extension callback = startInvocation(extension, reuseBackend, backendType, context); + ((AfterEachCallback) callback).afterEach(context); + } + + private static Extension startInvocation( + BackendTestExtension extension, boolean reuseBackend, ExtensionContext context) + throws Exception { + return startInvocation(extension, reuseBackend, "h2", context); + } + + private static Extension startInvocation( + BackendTestExtension extension, + boolean reuseBackend, + String backendType, + ExtensionContext context) + throws Exception { + TestTemplateInvocationContext invocation = + extension + .createInvocationContexts("testMethod", List.of(backendType), reuseBackend) + .findFirst() + .orElseThrow(); + Extension callback = invocation.getAdditionalExtensions().get(0); + ((BeforeEachCallback) callback).beforeEach(context); + return callback; + } + + private static class CountingBackendFactory implements BackendTestExtension.BackendFactory { + private final List backends = new ArrayList<>(); + private int activationCount; + + @Override + public BackendTestExtension.BackendResource create(String backendType) { + RelationalBackend backend = mock(RelationalBackend.class); + backends.add(backend); + return new BackendTestExtension.BackendResource( + backendType, backend, () -> activationCount++); + } + } + + private static class TestContexts { + private final Class testClass; + private final ExtensionContext classContext; + + private TestContexts(Class testClass) { + this.testClass = testClass; + classContext = mock(ExtensionContext.class); + ExtensionContext.Store store = mock(ExtensionContext.Store.class); + Map entries = new HashMap<>(); + + doAnswer( + invocation -> { + entries.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }) + .when(store) + .put(any(), any()); + when(store.get(any())).thenAnswer(invocation -> entries.get(invocation.getArgument(0))); + when(store.remove(any())).thenAnswer(invocation -> entries.remove(invocation.getArgument(0))); + when(classContext.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(classContext.getTestMethod()).thenReturn(Optional.empty()); + when(classContext.getRequiredTestClass()).thenAnswer(invocation -> testClass); + } + + private ExtensionContext methodContext(String methodName) throws NoSuchMethodException { + Method method = testClass.getDeclaredMethod(methodName); + ExtensionContext methodContext = mock(ExtensionContext.class); + when(methodContext.getTestMethod()).thenReturn(Optional.of(method)); + when(methodContext.getRequiredTestMethod()).thenReturn(method); + when(methodContext.getRequiredTestClass()).thenAnswer(invocation -> testClass); + when(methodContext.getRequiredTestInstance()).thenReturn(this); + when(methodContext.getParent()).thenReturn(Optional.of(classContext)); + return methodContext; + } + } + + private static class DefaultIsolationTests { + void first() {} + + void second() {} + } + + @DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) + private static class FreshIsolationTests { + void first() {} + + void second() {} + } + + private static class MixedIsolationTests { + void resettable() {} + + @DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) + void fresh() {} + } + + @DatabaseFixture(DatabaseIsolation.DEDICATED_SERVER) + private static class DedicatedServerTests { + void first() {} + } +} diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java index 9adff2730da..14e82b762e5 100644 --- a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java @@ -382,10 +382,10 @@ public void startDorisContainer(DorisImageName imageName) { } public void startMySQLContainer(TestDatabaseName testDatabaseName) { - ITUtils.cleanDisk(); if (mySQLContainer == null) { synchronized (ContainerSuite.class) { if (mySQLContainer == null) { + ITUtils.cleanDisk(); initIfNecessary(); // Start MySQL container MySQLContainer.Builder mysqlBuilder = @@ -439,10 +439,10 @@ public void startMySQLVersion5Container(TestDatabaseName testDatabaseName) { } public void startPostgreSQLContainer(TestDatabaseName testDatabaseName, PGImageName pgImageName) { - ITUtils.cleanDisk(); if (!pgContainerMap.containsKey(pgImageName)) { synchronized (ContainerSuite.class) { if (!pgContainerMap.containsKey(pgImageName)) { + ITUtils.cleanDisk(); initIfNecessary(); // Start PostgreSQL container PostgreSQLContainer.Builder pgBuilder = @@ -964,7 +964,8 @@ private static long[] cidrToRange(String cidr) throws Exception { @Override public void close() throws IOException { try { - closer.close(); + closeResources(closer); + } finally { mySQLContainer = null; mySQLVersion5Container = null; hiveContainer = null; @@ -978,8 +979,16 @@ public void close() throws IOException { kerberosHiveContainer = null; sqlBaseHiveContainer = null; pgContainerMap.clear(); + } + } + + static void closeResources(CloseableGroup resources) throws IOException { + try { + resources.close(); + } catch (IOException e) { + throw e; } catch (Exception e) { - LOG.error("Failed to close ContainerEnvironment", e); + throw new IOException("Failed to close ContainerEnvironment", e); } } } diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestContainerSuite.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestContainerSuite.java new file mode 100644 index 00000000000..4c7a21b934e --- /dev/null +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestContainerSuite.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.integration.test.container; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import org.apache.gravitino.integration.test.util.CloseableGroup; +import org.junit.jupiter.api.Test; + +class TestContainerSuite { + + @Test + void testCloseResourcesPropagatesFailureAfterClosingEveryResource() { + CloseableGroup resources = CloseableGroup.create(); + int[] closeCount = new int[1]; + resources.register(() -> closeCount[0]++); + resources.register( + () -> { + closeCount[0]++; + throw new IOException("expected"); + }); + + IOException failure = + assertThrows(IOException.class, () -> ContainerSuite.closeResources(resources)); + + assertEquals("expected", failure.getMessage()); + assertEquals(2, closeCount[0]); + } +} diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java index a37a9647009..5e26bf52488 100644 --- a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java @@ -32,14 +32,11 @@ public class CloseContainerExtension implements BeforeAllCallback { @Override public void beforeAll(ExtensionContext extensionContext) { - // Ensure that the container suite is initialized before closing it - if (ContainerSuite.initialized()) { - synchronized (CloseContainerExtension.class) { - extensionContext - .getRoot() - .getStore(ExtensionContext.Namespace.GLOBAL) - .getOrComputeIfAbsent(CloseableContainer.class); - } + synchronized (CloseContainerExtension.class) { + extensionContext + .getRoot() + .getStore(ExtensionContext.Namespace.GLOBAL) + .getOrComputeIfAbsent(CloseableContainer.class); } } @@ -48,13 +45,9 @@ private static class CloseableContainer implements ExtensionContext.Store.Closea private static final ContainerSuite CONTAINER_SUITE = ContainerSuite.getInstance(); @Override - public void close() { - try { - CONTAINER_SUITE.close(); - LOGGER.info("Containers were closed successfully"); - } catch (Exception e) { - LOGGER.warn("Containers were not closed as expected", e); - } + public void close() throws Exception { + CONTAINER_SUITE.close(); + LOGGER.info("Containers were closed successfully"); } } } From c95bffe011c705f978f62f4ae8454d6a3f61bc0f Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sun, 27 Sep 2026 04:41:38 -0700 Subject: [PATCH 17/17] test(core): add bounded adaptive database forks --- .github/workflows/build.yml | 25 + build.gradle.kts | 22 +- buildSrc/build.gradle.kts | 37 ++ .../testing/CoreDatabaseConcurrency.java | 502 ++++++++++++++++++ .../testing/TestCoreDatabaseConcurrency.java | 300 +++++++++++ core/build.gradle.kts | 130 ++++- .../TestJdbcPartitionStatisticStorageIT.java | 3 + .../relational/BackendTestExtension.java | 44 +- .../relational/DatabaseTestContext.java | 54 ++ .../relational/TestBackendTestExtension.java | 39 +- .../storage/relational/TestJDBCBackend.java | 16 +- .../service/TestModelVersionMetaService.java | 2 +- .../CoreDatabaseShardTelemetryExtension.java | 176 ++++++ ...stCoreDatabaseShardTelemetryExtension.java | 104 ++++ .../org.junit.jupiter.api.extension.Extension | 19 + 15 files changed, 1441 insertions(+), 32 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/java/org/apache/gravitino/testing/CoreDatabaseConcurrency.java create mode 100644 buildSrc/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseConcurrency.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java create mode 100644 core/src/test/java/org/apache/gravitino/testing/CoreDatabaseShardTelemetryExtension.java create mode 100644 core/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseShardTelemetryExtension.java create mode 100644 core/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31a769daaea..a094519f7a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -169,6 +169,24 @@ jobs: with: java-version: ${{ matrix.java-version }} + - name: Test database concurrency build logic + id: buildsrc-tests + if: >- + matrix.shard == 'core-unit' && + needs.changes.outputs.maintenance_module_only_changes != 'true' + run: ./gradlew -p buildSrc test + + - name: Upload database concurrency build logic test report + if: ${{ failure() && steps.buildsrc-tests.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: core-database-buildsrc-test-report + path: | + buildSrc/build/reports/tests/test + buildSrc/build/test-results/test + if-no-files-found: warn + retention-days: 1 + - name: Test publish to local if: matrix.shard == 'others' run: ./gradlew publishToMavenLocal -PskipWeb=true -x test @@ -285,6 +303,13 @@ jobs: test -s "${junit_xml}" test -s "build/reports/tests/core/${CORE_TASK}/index.html" test -s "core/build/jacoco/${CORE_TASK}.exec" + if [ "${CORE_LANE}" != "unit" ]; then + shard_record="$(find \ + "core/build/test-results/${CORE_TASK}/shard-telemetry" \ + -type f -name 'worker-*.log' -size +0c -print -quit)" + test -n "${shard_record}" + test -s "${shard_record}" + fi - name: Upload core test evidence if: steps.core-lane.outcome == 'success' diff --git a/build.gradle.kts b/build.gradle.kts index 7ba3b64cdd8..b8098c69023 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ import com.github.jk1.license.render.InventoryHtmlReportRenderer import com.github.jk1.license.render.ReportRenderer import com.github.vlsi.gradle.dsl.configureEach import net.ltgt.gradle.errorprone.errorprone +import org.apache.gravitino.testing.CoreDatabaseConcurrency import org.apache.tools.zip.ZipEntry import org.apache.tools.zip.ZipOutputStream import org.gradle.api.attributes.java.TargetJvmVersion @@ -458,12 +459,11 @@ allprojects { // Ryuk need privileged mode, if we want to rootless or run non-privileged mode, we need to disable it. param.environment("TESTCONTAINERS_RYUK_DISABLED", "true") - val dockerRunning = project.rootProject.extra["dockerRunning"] as? Boolean ?: false - val macDockerConnector = project.rootProject.extra["macDockerConnector"] as? Boolean ?: false - if (OperatingSystem.current().isMacOsX() && - dockerRunning && - macDockerConnector - ) { + val macDockerConnectorFixedNetwork = + project.rootProject.extra[ + CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA + ] as? Boolean ?: false + if (macDockerConnectorFixedNetwork) { param.environment("NEED_CREATE_DOCKER_NETWORK", "true") } @@ -1020,7 +1020,7 @@ subprojects { val skipTests = project.hasProperty("skipTests") if (!skipTests) { val extraArgs = project.property("extraJvmArgs") as List - jvmArgs = listOf("-Xmx4G") + extraArgs + jvmArgs = listOf(CoreDatabaseConcurrency.TEST_WORKER_MAX_HEAP_ARGUMENT) + extraArgs useJUnitPlatform() val isCoreSuiteTask = project.path == ":core" && @@ -1642,6 +1642,7 @@ project.extra["dockerTest"] = false project.extra["dockerRunning"] = false project.extra["macDockerConnector"] = false project.extra["isOrbStack"] = false +project.extra[CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA] = false // The following is to check the docker status and print the tip message fun printDockerCheckInfo() { @@ -1649,6 +1650,13 @@ fun printDockerCheckInfo() { checkDockerStatus() checkOrbStackStatus() + val macDockerConnectorFixedNetwork = + OperatingSystem.current().isMacOsX() && + (project.extra["dockerRunning"] as? Boolean ?: false) && + (project.extra["macDockerConnector"] as? Boolean ?: false) + project.extra[CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA] = + macDockerConnectorFixedNetwork + val testMode = project.properties["testMode"] as? String ?: "embedded" if (testMode != "deploy" && testMode != "embedded") { return diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000000..b7a000f20a9 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +plugins { + `java` +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.1") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/java/org/apache/gravitino/testing/CoreDatabaseConcurrency.java b/buildSrc/src/main/java/org/apache/gravitino/testing/CoreDatabaseConcurrency.java new file mode 100644 index 00000000000..5470da31558 --- /dev/null +++ b/buildSrc/src/main/java/org/apache/gravitino/testing/CoreDatabaseConcurrency.java @@ -0,0 +1,502 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.testing; + +import javax.annotation.Nullable; + +/** Calculates a conservative number of forks for the Core database test tasks. */ +public final class CoreDatabaseConcurrency { + + private static final long MIB = 1024L * 1024L; + private static final long TEST_WORKER_MAX_HEAP_MIB = 4096L; + private static final int ROLLOUT_CAP = 2; + private static final int DEFAULT_FORKS = 1; + + /** Maximum-heap JVM argument configured for every Gradle test worker. */ + public static final String TEST_WORKER_MAX_HEAP_ARGUMENT = + "-Xmx" + TEST_WORKER_MAX_HEAP_MIB + "m"; + + /** Maximum heap configured for every Gradle test worker. */ + public static final long TEST_WORKER_MAX_HEAP_BYTES = TEST_WORKER_MAX_HEAP_MIB * MIB; + + /** Default native-memory and database-container allowance for each test worker. */ + public static final long DEFAULT_TEST_WORKER_OVERHEAD_BYTES = 2048L * MIB; + + /** Default memory kept for the Gradle daemon and other build processes. */ + public static final long DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES = 4096L * MIB; + + /** Gradle property for the build-process memory reserve, in MiB. */ + public static final String BUILD_MEMORY_RESERVE_PROPERTY = "coreDatabaseBuildMemoryReserveMiB"; + + /** Environment variable for the build-process memory reserve, in MiB. */ + public static final String BUILD_MEMORY_RESERVE_ENVIRONMENT_VARIABLE = + "CORE_DATABASE_BUILD_MEMORY_RESERVE_MIB"; + + /** Gradle property for native and container memory overhead per test worker, in MiB. */ + public static final String FORK_MEMORY_OVERHEAD_PROPERTY = "coreDatabaseForkMemoryOverheadMiB"; + + /** Environment variable for native and container memory overhead per test worker, in MiB. */ + public static final String FORK_MEMORY_OVERHEAD_ENVIRONMENT_VARIABLE = + "CORE_DATABASE_FORK_MEMORY_OVERHEAD_MIB"; + + /** Gradle property for an exact database-test fork count. */ + public static final String FORKS_PROPERTY = "coreDatabaseForks"; + + /** Environment variable for an exact database-test fork count. */ + public static final String FORKS_ENVIRONMENT_VARIABLE = "CORE_DATABASE_FORKS"; + + /** Root-project extra-property key for the mac-docker-connector fixed-network predicate. */ + public static final String MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA = + "macDockerConnectorFixedNetwork"; + + /** Maximum safe fork count for a Docker lane using mac-docker-connector's fixed network. */ + public static final int MAC_DOCKER_CONNECTOR_MAX_FORKS = 1; + + /** Indicates that a lane has no task-specific maximum. */ + public static final int NO_LANE_MAXIMUM = 0; + + private CoreDatabaseConcurrency() {} + + /** Identifies where an operator-configurable memory value came from. */ + public enum ConfigurationSource { + /** A Gradle property supplied the value. */ + PROPERTY, + /** An environment variable supplied the value. */ + ENVIRONMENT, + /** The built-in conservative default supplied the value. */ + DEFAULT + } + + /** Identifies how the selected fork count was resolved. */ + public enum Source { + /** The {@link #FORKS_PROPERTY} value was used. */ + PROPERTY, + /** The {@link #FORKS_ENVIRONMENT_VARIABLE} value was used. */ + ENVIRONMENT, + /** All detected machine and build limits were used. */ + DETECTED, + /** Capacity detection was absent or partial, so the serial fallback was used. */ + DEFAULT + } + + /** Resource that constrained the calculated fork count. */ + public enum LimitingFactor { + /** Processor count, divided across the selected database lanes. */ + CPU, + /** Available memory, divided across the selected database lanes. */ + MEMORY, + /** Gradle worker count, divided across the selected database lanes. */ + GRADLE, + /** Operational rollout ceiling. */ + ROLLOUT, + /** Task-specific lane constraint. */ + LANE, + /** Conservative fallback used when capacity detection is absent or partial. */ + DEFAULT + } + + /** + * Resolves the configurable memory assumptions without reading process-global state. + * + *

    Each Gradle property takes precedence over its corresponding environment variable. + * Configured values must be positive whole MiB counts. + * + * @param buildReservePropertyValue value of {@link #BUILD_MEMORY_RESERVE_PROPERTY}, or null + * @param buildReserveEnvironmentValue value of {@link + * #BUILD_MEMORY_RESERVE_ENVIRONMENT_VARIABLE}, or null + * @param forkOverheadPropertyValue value of {@link #FORK_MEMORY_OVERHEAD_PROPERTY}, or null + * @param forkOverheadEnvironmentValue value of {@link + * #FORK_MEMORY_OVERHEAD_ENVIRONMENT_VARIABLE}, or null + * @return the resolved memory assumptions and their sources + */ + public static MemoryConfiguration resolveMemoryConfiguration( + @Nullable String buildReservePropertyValue, + @Nullable String buildReserveEnvironmentValue, + @Nullable String forkOverheadPropertyValue, + @Nullable String forkOverheadEnvironmentValue) { + ResolvedBytes buildReserve = + resolvePositiveMebibytes( + buildReservePropertyValue, + buildReserveEnvironmentValue, + DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES, + BUILD_MEMORY_RESERVE_PROPERTY, + BUILD_MEMORY_RESERVE_ENVIRONMENT_VARIABLE); + ResolvedBytes forkOverhead = + resolvePositiveMebibytes( + forkOverheadPropertyValue, + forkOverheadEnvironmentValue, + DEFAULT_TEST_WORKER_OVERHEAD_BYTES, + FORK_MEMORY_OVERHEAD_PROPERTY, + FORK_MEMORY_OVERHEAD_ENVIRONMENT_VARIABLE); + + return new MemoryConfiguration( + buildReserve.bytes(), buildReserve.source(), forkOverhead.bytes(), forkOverhead.source()); + } + + /** + * Returns the conservative per-fork resource budget using resolved memory assumptions. + * + * @param memoryConfiguration resolved build and worker memory assumptions + * @return per-fork memory budget and rollout ceiling + */ + public static Budget rolloutBudget(MemoryConfiguration memoryConfiguration) { + if (memoryConfiguration == null) { + throw new IllegalArgumentException("memory configuration must not be null"); + } + if (memoryConfiguration.buildReserveBytes() <= 0) { + throw new IllegalArgumentException("build reserve bytes must be positive"); + } + if (memoryConfiguration.forkOverheadBytes() <= 0) { + throw new IllegalArgumentException("fork overhead bytes must be positive"); + } + + try { + return new Budget( + Math.addExact(TEST_WORKER_MAX_HEAP_BYTES, memoryConfiguration.forkOverheadBytes()), + memoryConfiguration.buildReserveBytes(), + ROLLOUT_CAP); + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "test-worker memory budget exceeds the supported range", e); + } + } + + /** + * Calculates memory available to database-test workers from JVM-visible physical memory. + * + * @param totalMemoryBytes total memory visible to the Gradle JVM; zero means unavailable + * @param buildReserveBytes memory retained for Gradle and other build processes + * @return memory left after the build-process reserve + */ + public static long availableMemoryForTestWorkers(long totalMemoryBytes, long buildReserveBytes) { + if (totalMemoryBytes < 0) { + throw new IllegalArgumentException("total memory bytes must not be negative"); + } + if (buildReserveBytes <= 0) { + throw new IllegalArgumentException("build reserve bytes must be positive"); + } + if (totalMemoryBytes == 0) { + return 0; + } + return Math.max(0, totalMemoryBytes - buildReserveBytes); + } + + /** + * Returns the fixed lane maximum for a Docker task using mac-docker-connector. + * + * @param dockerLane whether the database lane requires Docker + * @param macDockerConnectorFixedNetwork whether the shared fixed network is active + * @return one when both inputs are true, otherwise {@link #NO_LANE_MAXIMUM} + */ + public static int laneMaximum(boolean dockerLane, boolean macDockerConnectorFixedNetwork) { + return dockerLane && macDockerConnectorFixedNetwork + ? MAC_DOCKER_CONNECTOR_MAX_FORKS + : NO_LANE_MAXIMUM; + } + + /** + * Resolves the fork count without reading process-global state. + * + *

    The Gradle property takes precedence over the environment variable. Exact requests are + * checked against every known capacity bound, the rollout cap, and the lane maximum. Without an + * exact request, every capacity signal must be known before automatic parallelism is enabled. + * Machine capacity is divided across the database lanes selected by the same Gradle invocation. + * + * @param propertyValue value of {@link #FORKS_PROPERTY}, or null + * @param environmentValue value of {@link #FORKS_ENVIRONMENT_VARIABLE}, or null + * @param capacity detected machine, build, and lane capacity + * @param budget per-fork memory budget and rollout cap + * @param laneMaximum task-specific maximum, or {@link #NO_LANE_MAXIMUM} + * @return the selected fork count and its resolution details + */ + public static Resolution resolve( + @Nullable String propertyValue, + @Nullable String environmentValue, + DetectedCapacity capacity, + Budget budget, + int laneMaximum) { + validate(capacity, budget, laneMaximum); + + CapacityLimit capacityLimit = calculateCapacityLimit(capacity, budget); + AppliedLimit allowedLimit = calculateAllowedLimit(capacityLimit, budget, laneMaximum); + String exactValue = propertyValue != null ? propertyValue : environmentValue; + Source exactSource = propertyValue != null ? Source.PROPERTY : Source.ENVIRONMENT; + + if (exactValue != null) { + int requestedForks = parseExactForks(exactValue, exactSource); + if (requestedForks > allowedLimit.maximum()) { + throw new IllegalArgumentException( + String.format( + "%s requests %d database forks, but the allowed maximum is %d " + + "(known per-lane capacity maximum %s, rollout cap %d, lane maximum %s)", + sourceName(exactSource), + requestedForks, + allowedLimit.maximum(), + displayMaximum(capacityLimit.detected(), capacityLimit.maximum()), + budget.rolloutCap(), + displayMaximum(laneMaximum > 0, laneMaximum))); + } + return new Resolution( + requestedForks, + capacityLimit.detected() ? capacityLimit.maximum() : 0, + allowedLimit.maximum(), + exactSource, + allowedLimit.factor()); + } + + if (!capacityLimit.fullyDetected()) { + return new Resolution( + DEFAULT_FORKS, + capacityLimit.detected() ? capacityLimit.maximum() : 0, + allowedLimit.maximum(), + Source.DEFAULT, + LimitingFactor.DEFAULT); + } + + return new Resolution( + allowedLimit.maximum(), + capacityLimit.maximum(), + allowedLimit.maximum(), + Source.DETECTED, + allowedLimit.factor()); + } + + private static void validate(DetectedCapacity capacity, Budget budget, int laneMaximum) { + if (capacity == null) { + throw new IllegalArgumentException("detected capacity must not be null"); + } + if (budget == null) { + throw new IllegalArgumentException("resource budget must not be null"); + } + if (capacity.processors() < 0) { + throw new IllegalArgumentException("detected processor count must not be negative"); + } + if (capacity.totalMemoryBytes() < 0) { + throw new IllegalArgumentException("detected total memory must not be negative"); + } + if (capacity.gradleMaxWorkers() < 0) { + throw new IllegalArgumentException("Gradle maximum workers must not be negative"); + } + if (capacity.activeDatabaseLanes() <= 0) { + throw new IllegalArgumentException("active database lane count must be positive"); + } + if (budget.memoryBytesPerFork() <= 0) { + throw new IllegalArgumentException("memory bytes per fork must be positive"); + } + if (budget.buildReserveBytes() <= 0) { + throw new IllegalArgumentException("build reserve bytes must be positive"); + } + if (budget.rolloutCap() <= 0) { + throw new IllegalArgumentException("rollout cap must be positive"); + } + if (laneMaximum < 0) { + throw new IllegalArgumentException("lane maximum must not be negative"); + } + } + + private static CapacityLimit calculateCapacityLimit(DetectedCapacity capacity, Budget budget) { + int maximum = Integer.MAX_VALUE; + LimitingFactor factor = LimitingFactor.DEFAULT; + int detectedSignals = 0; + + if (capacity.processors() > 0) { + maximum = perLane(capacity.processors(), capacity.activeDatabaseLanes()); + factor = LimitingFactor.CPU; + detectedSignals++; + } + + if (capacity.totalMemoryBytes() > 0) { + long availableMemoryBytes = + availableMemoryForTestWorkers(capacity.totalMemoryBytes(), budget.buildReserveBytes()); + long memoryForks = + Math.max(DEFAULT_FORKS, availableMemoryBytes / budget.memoryBytesPerFork()); + int boundedMemoryForks = (int) Math.min(memoryForks, Integer.MAX_VALUE); + int memoryForksPerLane = perLane(boundedMemoryForks, capacity.activeDatabaseLanes()); + if (memoryForksPerLane < maximum) { + maximum = memoryForksPerLane; + factor = LimitingFactor.MEMORY; + } + detectedSignals++; + } + + if (capacity.gradleMaxWorkers() > 0) { + int gradleWorkersPerLane = + perLane(capacity.gradleMaxWorkers(), capacity.activeDatabaseLanes()); + if (gradleWorkersPerLane < maximum) { + maximum = gradleWorkersPerLane; + factor = LimitingFactor.GRADLE; + } + detectedSignals++; + } + + if (detectedSignals == 0) { + return new CapacityLimit(0, LimitingFactor.DEFAULT, false, false); + } + return new CapacityLimit(maximum, factor, true, detectedSignals == 3); + } + + private static AppliedLimit calculateAllowedLimit( + CapacityLimit capacityLimit, Budget budget, int laneMaximum) { + int maximum = budget.rolloutCap(); + LimitingFactor factor = LimitingFactor.ROLLOUT; + + if (capacityLimit.detected() && capacityLimit.maximum() <= maximum) { + maximum = capacityLimit.maximum(); + factor = capacityLimit.factor(); + } + if (laneMaximum > 0 && laneMaximum <= maximum) { + maximum = laneMaximum; + factor = LimitingFactor.LANE; + } + + return new AppliedLimit(maximum, factor); + } + + private static int perLane(int capacity, int activeDatabaseLanes) { + return Math.max(DEFAULT_FORKS, capacity / activeDatabaseLanes); + } + + private static ResolvedBytes resolvePositiveMebibytes( + @Nullable String propertyValue, + @Nullable String environmentValue, + long defaultBytes, + String propertyName, + String environmentName) { + if (propertyValue != null) { + return new ResolvedBytes( + parsePositiveMebibytes(propertyValue, propertyName), ConfigurationSource.PROPERTY); + } + if (environmentValue != null) { + return new ResolvedBytes( + parsePositiveMebibytes(environmentValue, environmentName), + ConfigurationSource.ENVIRONMENT); + } + return new ResolvedBytes(defaultBytes, ConfigurationSource.DEFAULT); + } + + private static long parsePositiveMebibytes(String value, String name) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + throw positiveMebibytesError(name, null); + } + + try { + long mebibytes = Long.parseLong(trimmed); + if (mebibytes <= 0) { + throw positiveMebibytesError(name, null); + } + return Math.multiplyExact(mebibytes, MIB); + } catch (NumberFormatException | ArithmeticException e) { + throw positiveMebibytesError(name, e); + } + } + + private static IllegalArgumentException positiveMebibytesError( + String name, @Nullable RuntimeException cause) { + String message = name + " must be a positive whole number of MiB"; + return cause == null + ? new IllegalArgumentException(message) + : new IllegalArgumentException(message, cause); + } + + private static int parseExactForks(String value, Source source) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(sourceName(source) + " must be a positive integer"); + } + + try { + int parsed = Integer.parseInt(trimmed); + if (parsed <= 0) { + throw new IllegalArgumentException(sourceName(source) + " must be a positive integer"); + } + return parsed; + } catch (NumberFormatException e) { + throw new IllegalArgumentException(sourceName(source) + " must be a positive integer", e); + } + } + + private static String sourceName(Source source) { + return source == Source.PROPERTY ? FORKS_PROPERTY : FORKS_ENVIRONMENT_VARIABLE; + } + + private static String displayMaximum(boolean known, int maximum) { + return known ? Integer.toString(maximum) : "unknown"; + } + + /** + * Resolved build-process and per-worker memory assumptions. + * + * @param buildReserveBytes memory retained for Gradle and other build processes + * @param buildReserveSource input that supplied {@code buildReserveBytes} + * @param forkOverheadBytes native and database-container allowance per fork + * @param forkOverheadSource input that supplied {@code forkOverheadBytes} + */ + public record MemoryConfiguration( + long buildReserveBytes, + ConfigurationSource buildReserveSource, + long forkOverheadBytes, + ConfigurationSource forkOverheadSource) {} + + /** + * Machine, Gradle, and lane capacity detected by the caller. + * + *

    A zero processor, memory, or Gradle-worker value means that signal was unavailable. + * + * @param processors available processors + * @param totalMemoryBytes total memory visible to the Gradle JVM before the build reserve + * @param gradleMaxWorkers maximum Gradle workers available to the build + * @param activeDatabaseLanes database lanes selected by the same Gradle invocation + */ + public record DetectedCapacity( + int processors, long totalMemoryBytes, int gradleMaxWorkers, int activeDatabaseLanes) {} + + /** + * Resource budget for a database-test fork. + * + * @param memoryBytesPerFork memory reserved for each fork + * @param buildReserveBytes memory retained for Gradle and other build processes + * @param rolloutCap operational ceiling for parallel forks + */ + public record Budget(long memoryBytesPerFork, long buildReserveBytes, int rolloutCap) {} + + /** + * Result of resolving database-test concurrency. + * + * @param forks selected fork count + * @param safeMaximum smallest known per-lane capacity bound, or zero when none is known + * @param allowedMaximum smallest detected, rollout, and task-specific lane bound + * @param source input that selected {@code forks} + * @param limitingFactor bound that determined {@code allowedMaximum} + */ + public record Resolution( + int forks, + int safeMaximum, + int allowedMaximum, + Source source, + LimitingFactor limitingFactor) {} + + private record ResolvedBytes(long bytes, ConfigurationSource source) {} + + private record CapacityLimit( + int maximum, LimitingFactor factor, boolean detected, boolean fullyDetected) {} + + private record AppliedLimit(int maximum, LimitingFactor factor) {} +} diff --git a/buildSrc/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseConcurrency.java b/buildSrc/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseConcurrency.java new file mode 100644 index 00000000000..627391d0b25 --- /dev/null +++ b/buildSrc/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseConcurrency.java @@ -0,0 +1,300 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.testing; + +import static org.apache.gravitino.testing.CoreDatabaseConcurrency.DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES; +import static org.apache.gravitino.testing.CoreDatabaseConcurrency.DEFAULT_TEST_WORKER_OVERHEAD_BYTES; +import static org.apache.gravitino.testing.CoreDatabaseConcurrency.TEST_WORKER_MAX_HEAP_BYTES; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.gravitino.testing.CoreDatabaseConcurrency.Budget; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.ConfigurationSource; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.DetectedCapacity; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.LimitingFactor; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.MemoryConfiguration; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.Resolution; +import org.apache.gravitino.testing.CoreDatabaseConcurrency.Source; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TestCoreDatabaseConcurrency { + + private static final long MIB = 1024L * 1024L; + private static final long GIB = 1024L * MIB; + + @Test + void usesDefaultMemoryConfiguration() { + MemoryConfiguration configuration = + CoreDatabaseConcurrency.resolveMemoryConfiguration(null, null, null, null); + + assertEquals(DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES, configuration.buildReserveBytes()); + assertEquals(ConfigurationSource.DEFAULT, configuration.buildReserveSource()); + assertEquals(DEFAULT_TEST_WORKER_OVERHEAD_BYTES, configuration.forkOverheadBytes()); + assertEquals(ConfigurationSource.DEFAULT, configuration.forkOverheadSource()); + } + + @Test + void memoryPropertiesTakePrecedenceOverEnvironment() { + MemoryConfiguration configuration = + CoreDatabaseConcurrency.resolveMemoryConfiguration("5120", "6144", "1024", "2048"); + + assertEquals(5120L * MIB, configuration.buildReserveBytes()); + assertEquals(ConfigurationSource.PROPERTY, configuration.buildReserveSource()); + assertEquals(1024L * MIB, configuration.forkOverheadBytes()); + assertEquals(ConfigurationSource.PROPERTY, configuration.forkOverheadSource()); + } + + @Test + void memoryEnvironmentIsUsedWhenPropertiesAreAbsent() { + MemoryConfiguration configuration = + CoreDatabaseConcurrency.resolveMemoryConfiguration(null, "5120", null, "1024"); + + assertEquals(5120L * MIB, configuration.buildReserveBytes()); + assertEquals(ConfigurationSource.ENVIRONMENT, configuration.buildReserveSource()); + assertEquals(1024L * MIB, configuration.forkOverheadBytes()); + assertEquals(ConfigurationSource.ENVIRONMENT, configuration.forkOverheadSource()); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "zero", "0", "-1", "1.5", "9223372036854775807"}) + void rejectsInvalidConfiguredMemory(String value) { + assertThrows( + IllegalArgumentException.class, + () -> CoreDatabaseConcurrency.resolveMemoryConfiguration(value, null, null, null)); + assertThrows( + IllegalArgumentException.class, + () -> CoreDatabaseConcurrency.resolveMemoryConfiguration(null, null, value, null)); + } + + @Test + void rolloutBudgetIncludesHeapAndNativeAllowance() { + MemoryConfiguration configuration = + CoreDatabaseConcurrency.resolveMemoryConfiguration(null, null, null, null); + Budget budget = CoreDatabaseConcurrency.rolloutBudget(configuration); + + assertEquals( + TEST_WORKER_MAX_HEAP_BYTES + DEFAULT_TEST_WORKER_OVERHEAD_BYTES, + budget.memoryBytesPerFork()); + assertEquals(DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES, budget.buildReserveBytes()); + assertEquals(2, budget.rolloutCap()); + } + + @Test + void rejectsOverflowingRolloutBudget() { + MemoryConfiguration configuration = + new MemoryConfiguration( + GIB, ConfigurationSource.DEFAULT, Long.MAX_VALUE, ConfigurationSource.PROPERTY); + + assertThrows( + IllegalArgumentException.class, () -> CoreDatabaseConcurrency.rolloutBudget(configuration)); + } + + @Test + void calculatesAvailableMemoryWithoutUnderflow() { + assertEquals( + 12L * GIB, CoreDatabaseConcurrency.availableMemoryForTestWorkers(16L * GIB, 4L * GIB)); + assertEquals(0, CoreDatabaseConcurrency.availableMemoryForTestWorkers(2L * GIB, 4L * GIB)); + assertEquals(0, CoreDatabaseConcurrency.availableMemoryForTestWorkers(0, 4L * GIB)); + } + + @Test + void fullyDetectedCapacityEnablesTwoForkRollout() { + Resolution resolution = resolve(null, null, new DetectedCapacity(8, 64L * GIB, 8, 1), 0); + + assertEquals(2, resolution.forks()); + assertEquals(Source.DETECTED, resolution.source()); + assertEquals(LimitingFactor.ROLLOUT, resolution.limitingFactor()); + assertEquals(8, resolution.safeMaximum()); + assertEquals(2, resolution.allowedMaximum()); + } + + @Test + void cpuCanLimitForks() { + Resolution resolution = resolve(null, null, new DetectedCapacity(1, 64L * GIB, 8, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(LimitingFactor.CPU, resolution.limitingFactor()); + } + + @Test + void memoryCanLimitForks() { + Resolution resolution = resolve(null, null, new DetectedCapacity(8, 10L * GIB, 8, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(LimitingFactor.MEMORY, resolution.limitingFactor()); + } + + @Test + void gradleWorkersCanLimitForks() { + Resolution resolution = resolve(null, null, new DetectedCapacity(8, 64L * GIB, 1, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(LimitingFactor.GRADLE, resolution.limitingFactor()); + } + + @Test + void selectedLaneCountDividesDetectedCapacity() { + Resolution resolution = resolve(null, null, new DetectedCapacity(2, 16L * GIB, 2, 2), 0); + + assertEquals(1, resolution.forks()); + assertEquals(1, resolution.safeMaximum()); + assertEquals(LimitingFactor.CPU, resolution.limitingFactor()); + } + + @Test + void incompleteSignalsFallBackToOneFork() { + Resolution resolution = resolve(null, null, new DetectedCapacity(8, 0, 8, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(Source.DEFAULT, resolution.source()); + assertEquals(LimitingFactor.DEFAULT, resolution.limitingFactor()); + assertEquals(8, resolution.safeMaximum()); + } + + @Test + void absentSignalsFallBackToOneFork() { + Resolution resolution = resolve(null, null, new DetectedCapacity(0, 0, 0, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(Source.DEFAULT, resolution.source()); + assertEquals(0, resolution.safeMaximum()); + assertEquals(2, resolution.allowedMaximum()); + } + + @Test + void propertyOverrideTakesPrecedence() { + Resolution resolution = resolve("1", "2", new DetectedCapacity(8, 64L * GIB, 8, 1), 0); + + assertEquals(1, resolution.forks()); + assertEquals(Source.PROPERTY, resolution.source()); + } + + @Test + void environmentOverrideIsUsedWhenPropertyIsAbsent() { + Resolution resolution = resolve(null, "2", new DetectedCapacity(8, 64L * GIB, 8, 1), 0); + + assertEquals(2, resolution.forks()); + assertEquals(Source.ENVIRONMENT, resolution.source()); + } + + @Test + void exactOverrideCanUseUnknownCapacityWithinKnownLimits() { + Resolution resolution = resolve("2", null, new DetectedCapacity(0, 0, 0, 1), 0); + + assertEquals(2, resolution.forks()); + assertEquals(Source.PROPERTY, resolution.source()); + assertEquals(2, resolution.allowedMaximum()); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "zero", "0", "-1", "1.5", "2147483648"}) + void rejectsMalformedExactOverrides(String value) { + assertThrows( + IllegalArgumentException.class, + () -> resolve(value, null, new DetectedCapacity(8, 64L * GIB, 8, 1), 0)); + } + + @Test + void rejectsOverrideAboveRolloutCap() { + IllegalArgumentException failure = + assertThrows( + IllegalArgumentException.class, + () -> resolve("3", null, new DetectedCapacity(8, 64L * GIB, 8, 1), 0)); + + assertTrue(failure.getMessage().contains("allowed maximum is 2")); + } + + @Test + void rejectsOverrideAboveKnownPartialCapacity() { + assertThrows( + IllegalArgumentException.class, + () -> resolve("2", null, new DetectedCapacity(1, 0, 0, 1), 0)); + } + + @Test + void rejectsOverrideAbovePerLaneCapacity() { + assertThrows( + IllegalArgumentException.class, + () -> resolve("2", null, new DetectedCapacity(2, 16L * GIB, 2, 2), 0)); + } + + @Test + void fixedNetworkDockerLaneForcesOneFork() { + int laneMaximum = CoreDatabaseConcurrency.laneMaximum(true, true); + Resolution resolution = + resolve(null, null, new DetectedCapacity(8, 64L * GIB, 8, 1), laneMaximum); + + assertEquals(1, resolution.forks()); + assertEquals(LimitingFactor.LANE, resolution.limitingFactor()); + assertThrows( + IllegalArgumentException.class, + () -> resolve("2", null, new DetectedCapacity(8, 64L * GIB, 8, 1), laneMaximum)); + } + + @Test + void h2AndNonFixedNetworkDockerLanesHaveNoSpecialMaximum() { + assertEquals(0, CoreDatabaseConcurrency.laneMaximum(false, true)); + assertEquals(0, CoreDatabaseConcurrency.laneMaximum(true, false)); + } + + @Test + void rejectsInvalidCapacityAndBudgetInputs() { + Budget budget = defaultBudget(); + assertThrows( + IllegalArgumentException.class, + () -> + CoreDatabaseConcurrency.resolve( + null, null, new DetectedCapacity(-1, 1, 1, 1), budget, 0)); + assertThrows( + IllegalArgumentException.class, + () -> + CoreDatabaseConcurrency.resolve( + null, null, new DetectedCapacity(1, -1, 1, 1), budget, 0)); + assertThrows( + IllegalArgumentException.class, + () -> + CoreDatabaseConcurrency.resolve( + null, null, new DetectedCapacity(1, 1, -1, 1), budget, 0)); + assertThrows( + IllegalArgumentException.class, + () -> + CoreDatabaseConcurrency.resolve( + null, null, new DetectedCapacity(1, 1, 1, 0), budget, 0)); + assertThrows( + IllegalArgumentException.class, + () -> + CoreDatabaseConcurrency.resolve( + null, null, new DetectedCapacity(1, 1, 1, 1), budget, -1)); + } + + private static Resolution resolve( + String propertyValue, String environmentValue, DetectedCapacity capacity, int laneMaximum) { + return CoreDatabaseConcurrency.resolve( + propertyValue, environmentValue, capacity, defaultBudget(), laneMaximum); + } + + private static Budget defaultBudget() { + return CoreDatabaseConcurrency.rolloutBudget( + CoreDatabaseConcurrency.resolveMemoryConfiguration(null, null, null, null)); + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 2ac888f25ae..5b9f1673aa3 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,7 +1,11 @@ +import com.sun.management.OperatingSystemMXBean import net.ltgt.gradle.errorprone.errorprone +import org.apache.gravitino.testing.CoreDatabaseConcurrency +import org.apache.gravitino.testing.CoreDatabaseConcurrency.DetectedCapacity import org.gradle.api.tasks.testing.Test import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.tasks.JacocoReport +import java.lang.management.ManagementFactory /* * Licensed to the Apache Software Foundation (ASF) under one @@ -129,6 +133,53 @@ val coreBackendTestTags = "postgresql" to "gravitino-core-postgresql-test" ) val coreTestBackendProperty = "gravitino.core.test.backend" +val coreDatabaseTaskNames = + setOf("coreH2Test", "coreMySQLTest", "corePostgreSQLTest") +val coreDatabaseActiveLaneCount = + gradle.startParameter.taskNames + .map { it.substringAfterLast(':') } + .distinct() + .count { it in coreDatabaseTaskNames } + .coerceAtLeast(1) +val coreDatabaseTotalMemoryBytes = + runCatching { + (ManagementFactory.getOperatingSystemMXBean() as? OperatingSystemMXBean)?.totalMemorySize ?: 0L + }.getOrDefault(0L) +val coreDatabaseMemoryBasis = + if (coreDatabaseTotalMemoryBytes > 0) { + "jdk-total-memory-minus-configured-build-reserve" + } else { + "unavailable" + } +val coreDatabaseMemoryConfiguration = + CoreDatabaseConcurrency.resolveMemoryConfiguration( + providers.gradleProperty(CoreDatabaseConcurrency.BUILD_MEMORY_RESERVE_PROPERTY).orNull, + providers + .environmentVariable(CoreDatabaseConcurrency.BUILD_MEMORY_RESERVE_ENVIRONMENT_VARIABLE) + .orNull, + providers.gradleProperty(CoreDatabaseConcurrency.FORK_MEMORY_OVERHEAD_PROPERTY).orNull, + providers + .environmentVariable(CoreDatabaseConcurrency.FORK_MEMORY_OVERHEAD_ENVIRONMENT_VARIABLE) + .orNull + ) +val coreDatabaseAvailableMemoryBytes = + CoreDatabaseConcurrency.availableMemoryForTestWorkers( + coreDatabaseTotalMemoryBytes, + coreDatabaseMemoryConfiguration.buildReserveBytes() + ) +val coreDatabaseDetectedCapacity = + DetectedCapacity( + Runtime.getRuntime().availableProcessors(), + coreDatabaseTotalMemoryBytes, + gradle.startParameter.maxWorkerCount, + coreDatabaseActiveLaneCount + ) +val coreDatabaseConcurrencyBudget = + CoreDatabaseConcurrency.rolloutBudget(coreDatabaseMemoryConfiguration) +val macDockerConnectorFixedNetwork = + rootProject.extra[ + CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA + ] as? Boolean ?: false fun registerCoreTestTask( taskName: String, @@ -176,12 +227,87 @@ fun registerCoreTestTask( } if (backend != null) { + val shardTelemetryDirectory = + layout.buildDirectory.dir("test-results/$taskName/shard-telemetry") + val laneMaximum = + CoreDatabaseConcurrency.laneMaximum( + backend != "h2", + macDockerConnectorFixedNetwork + ) + val concurrencyResolution = + CoreDatabaseConcurrency.resolve( + providers.gradleProperty(CoreDatabaseConcurrency.FORKS_PROPERTY).orNull, + providers.environmentVariable(CoreDatabaseConcurrency.FORKS_ENVIRONMENT_VARIABLE).orNull, + coreDatabaseDetectedCapacity, + coreDatabaseConcurrencyBudget, + laneMaximum + ) + systemProperty(coreTestBackendProperty, backend) extensions.extraProperties["includeDockerTaggedTests"] = true - // Database tests mutate process-wide state and must remain sequential within each lane. - maxParallelForks = 1 + // Each Gradle worker owns its database server. JUnit execution remains serial inside it. + maxParallelForks = concurrencyResolution.forks() + systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") systemProperty("junit.jupiter.execution.parallel.enabled", "false") + systemProperty( + "gravitino.core.database.shard.telemetry.directory", + shardTelemetryDirectory.get().asFile.absolutePath + ) + inputs.property("coreDatabaseForks", concurrencyResolution.forks()) + inputs.property("coreDatabaseActiveLanes", coreDatabaseActiveLaneCount) + outputs.dir(shardTelemetryDirectory) + + doFirst { + project.delete(shardTelemetryDirectory) + logger.lifecycle( + "[CORE-DB-CONCURRENCY] task={} forks={} source={} limitingFactor={} " + + "safeMaximum={} allowedMaximum={} processors={} totalMemoryBytes={} " + + "availableTestMemoryBytes={} memoryBasis={} buildReserveBytes={} " + + "buildReserveSource={} memoryBytesPerFork={} testWorkerMaxHeapBytes={} " + + "forkOverheadBytes={} forkOverheadSource={} gradleMaxWorkers={} " + + "activeDatabaseLanes={} rolloutCap={} laneMaximum={} " + + "macDockerConnectorFixedNetwork={} junitParallel=false", + path, + concurrencyResolution.forks(), + concurrencyResolution.source(), + concurrencyResolution.limitingFactor(), + concurrencyResolution.safeMaximum(), + concurrencyResolution.allowedMaximum(), + coreDatabaseDetectedCapacity.processors(), + coreDatabaseTotalMemoryBytes, + coreDatabaseAvailableMemoryBytes, + coreDatabaseMemoryBasis, + coreDatabaseMemoryConfiguration.buildReserveBytes(), + coreDatabaseMemoryConfiguration.buildReserveSource(), + coreDatabaseConcurrencyBudget.memoryBytesPerFork(), + CoreDatabaseConcurrency.TEST_WORKER_MAX_HEAP_BYTES, + coreDatabaseMemoryConfiguration.forkOverheadBytes(), + coreDatabaseMemoryConfiguration.forkOverheadSource(), + coreDatabaseDetectedCapacity.gradleMaxWorkers(), + coreDatabaseDetectedCapacity.activeDatabaseLanes(), + coreDatabaseConcurrencyBudget.rolloutCap(), + laneMaximum, + macDockerConnectorFixedNetwork + ) + } + + doLast { + val shardRecords = + shardTelemetryDirectory + .get() + .asFile + .walkTopDown() + .filter { it.isFile && it.extension == "log" } + .flatMap { it.readLines().asSequence() } + .filter { it.startsWith("[CORE-DB-SHARD]") } + .sorted() + .toList() + if (shardRecords.isEmpty()) { + throw GradleException("The $backend core database lane emitted no shard telemetry.") + } + shardRecords.forEach { logger.lifecycle(it) } + } if (backend != "h2") { doFirst { diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index 38d421bd547..ee08e8ce72d 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -50,6 +50,7 @@ import org.apache.gravitino.integration.test.container.MySQLContainer; import org.apache.gravitino.integration.test.container.PGImageName; import org.apache.gravitino.integration.test.container.PostgreSQLContainer; +import org.apache.gravitino.integration.test.util.CloseContainerExtension; import org.apache.gravitino.integration.test.util.TestDatabaseName; import org.apache.gravitino.meta.TableEntity; import org.apache.gravitino.stats.PartitionRange; @@ -65,6 +66,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,6 +100,7 @@ public class TestJdbcPartitionStatisticStorageIT { * runs in. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @ExtendWith(CloseContainerExtension.class) abstract static class BaseJdbcPartitionStatisticStorageTest { protected JdbcPartitionStatisticStorage storage; diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index 26b634346b9..5a527881171 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java @@ -56,6 +56,9 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; import org.junit.jupiter.api.extension.TestWatcher; @@ -275,11 +278,13 @@ private static class BackendSetupCallback implements BeforeEachCallback, AfterEachCallback, LifecycleMethodExecutionExceptionHandler, + ParameterResolver, TestWatcher { private final String backendType; private final boolean reuseBackend; private final BackendFactory backendFactory; private BackendResource backendResource; + private DatabaseTestContext databaseTestContext; private boolean closeAfterEach; private BackendSetupCallback( @@ -304,18 +309,39 @@ public void beforeEach(ExtensionContext context) throws Exception { } backendResource = closeAfterEach ? newBackendResource() : getOrCreateClassBackend(context); backendResource.activate(); - injectBackend(context, backendResource.backend); + databaseTestContext = + new DatabaseTestContext(backendType, backendResource.backend, isolation); } catch (Exception e) { cleanupAfterSetupFailure(e); throw e; } } + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType() == DatabaseTestContext.class; + } + + @Override + public DatabaseTestContext resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + if (databaseTestContext == null) { + throw new ParameterResolutionException( + "DatabaseTestContext is unavailable before the database fixture starts"); + } + return databaseTestContext; + } + @Override public void afterEach(ExtensionContext context) throws Exception { - if (closeAfterEach && backendResource != null) { - backendResource.close(); - backendResource = null; + try { + if (closeAfterEach && backendResource != null) { + backendResource.close(); + backendResource = null; + } + } finally { + databaseTestContext = null; } } @@ -343,15 +369,6 @@ public void handleAfterEachMethodExecutionException( throw throwable; } - private void injectBackend(ExtensionContext context, RelationalBackend backend) { - Object testInstance = context.getRequiredTestInstance(); - if (testInstance instanceof TestJDBCBackend) { - LOG.info("Injecting {} backend into test instance", backendType); - ((TestJDBCBackend) testInstance).setBackend(backend); - ((TestJDBCBackend) testInstance).setBackendType(backendType); - } - } - private BackendResource getOrCreateClassBackend(ExtensionContext context) throws Exception { ConcurrentHashMap backendMap = backendMap(context); synchronized (backendMap) { @@ -403,6 +420,7 @@ private BackendResource newBackendResource() throws Exception { } private void cleanupAfterSetupFailure(Exception failure) { + databaseTestContext = null; if (backendResource == null) { return; } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java new file mode 100644 index 00000000000..6b8435dcf33 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.util.Objects; + +/** + * Provides the database fixture owned by the current test invocation. + * + *

    The extension owns the fixture lifecycle. Tests must not close the supplied backend. + */ +public final class DatabaseTestContext { + private final String backendType; + private final RelationalBackend backend; + private final DatabaseIsolation isolation; + + DatabaseTestContext(String backendType, RelationalBackend backend, DatabaseIsolation isolation) { + this.backendType = Objects.requireNonNull(backendType, "backendType"); + this.backend = Objects.requireNonNull(backend, "backend"); + this.isolation = Objects.requireNonNull(isolation, "isolation"); + } + + /** Returns the database implementation selected for this invocation. */ + public String backendType() { + return backendType; + } + + /** Returns the initialized relational backend owned by the test fixture. */ + public RelationalBackend backend() { + return backend; + } + + /** Returns the isolation policy requested by the test. */ + public DatabaseIsolation isolation() { + return isolation; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java index d9c3c30c142..47b878e7e35 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java @@ -20,7 +20,10 @@ package org.apache.gravitino.storage.relational; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; @@ -32,6 +35,7 @@ import java.io.IOException; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -44,6 +48,9 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestWatcher; @@ -56,7 +63,15 @@ void testSelectedBackendReusesClassFixture() throws Exception { TestContexts contexts = new TestContexts(DefaultIsolationTests.class); extension.beforeAll(contexts.classContext); - runInvocation(extension, true, contexts.methodContext("first")); + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + DatabaseTestContext databaseContext = + (DatabaseTestContext) + ((ParameterResolver) firstCallback).resolveParameter(parameterContext(0), firstContext); + assertEquals("h2", databaseContext.backendType()); + assertSame(factory.backends.get(0), databaseContext.backend()); + assertEquals(DatabaseIsolation.RESETTABLE_NAMESPACE, databaseContext.isolation()); + ((AfterEachCallback) firstCallback).afterEach(firstContext); runInvocation(extension, true, contexts.methodContext("second")); assertEquals(1, factory.backends.size()); @@ -272,8 +287,14 @@ void testDisplayNamePreservesMethodAndBackend() { .createInvocationContexts("testMethod", List.of("h2"), true) .findFirst() .orElseThrow(); + ParameterResolver resolver = (ParameterResolver) context.getAdditionalExtensions().get(0); assertEquals("testMethod[H2 Backend]", context.getDisplayName(1)); + assertTrue(resolver.supportsParameter(parameterContext(0), mock(ExtensionContext.class))); + assertFalse(resolver.supportsParameter(parameterContext(1), mock(ExtensionContext.class))); + assertThrows( + ParameterResolutionException.class, + () -> resolver.resolveParameter(parameterContext(0), mock(ExtensionContext.class))); } private static void runInvocation( @@ -314,6 +335,22 @@ private static Extension startInvocation( return callback; } + private static ParameterContext parameterContext(int index) { + Parameter parameter; + try { + parameter = + index == 0 + ? TestJDBCBackend.class.getDeclaredMethod("init", DatabaseTestContext.class) + .getParameters()[0] + : Object.class.getDeclaredMethod("equals", Object.class).getParameters()[0]; + } catch (NoSuchMethodException e) { + throw new AssertionError(e); + } + ParameterContext context = mock(ParameterContext.class); + when(context.getParameter()).thenReturn(parameter); + return context; + } + private static class CountingBackendFactory implements BackendTestExtension.BackendFactory { private final List backends = new ArrayList<>(); private int activationCount; diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 009c39d99f7..83deac6fc4b 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -98,16 +98,16 @@ public abstract class TestJDBCBackend { protected String backendType; protected RelationalBackend backend; - public void setBackend(RelationalBackend backend) { - this.backend = backend; - } - - public void setBackendType(String backendType) { - this.backendType = backendType; + /** Activates and resets the database fixture selected for the current test invocation. */ + @BeforeEach + public void init(DatabaseTestContext context) throws SQLException { + backend = context.backend(); + backendType = context.backendType(); + resetDatabase(); } - @BeforeEach - public void init() throws SQLException { + /** Clears data owned by the current database fixture. */ + protected void resetDatabase() throws SQLException { truncateAllTables(); } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java index e67f665dc28..c395cccf6f4 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java @@ -987,7 +987,7 @@ public void testModelVersionWithMultipleUris() throws IOException { @TestTemplate public void testDeleteModelVersionsInDeletion() throws IOException, SQLException { for (String param : new String[] {"model", "schema", "catalog", "metalake"}) { - init(); + resetDatabase(); performDeletionTestLogic(param); } } diff --git a/core/src/test/java/org/apache/gravitino/testing/CoreDatabaseShardTelemetryExtension.java b/core/src/test/java/org/apache/gravitino/testing/CoreDatabaseShardTelemetryExtension.java new file mode 100644 index 00000000000..31a7bd9b954 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/testing/CoreDatabaseShardTelemetryExtension.java @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.testing; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.LongSupplier; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestWatcher; + +/** Records class-level Core database-test evidence for each Gradle test worker. */ +public final class CoreDatabaseShardTelemetryExtension + implements BeforeAllCallback, AfterAllCallback, TestWatcher { + + static final String OUTPUT_DIRECTORY_PROPERTY = + "gravitino.core.database.shard.telemetry.directory"; + static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + + private static final Object FILE_WRITE_LOCK = new Object(); + + private final ConcurrentMap, ClassStatistics> statistics = new ConcurrentHashMap<>(); + private final LongSupplier nanoTime; + + /** Creates the extension using the system monotonic clock. */ + public CoreDatabaseShardTelemetryExtension() { + this(System::nanoTime); + } + + CoreDatabaseShardTelemetryExtension(LongSupplier nanoTime) { + this.nanoTime = nanoTime; + } + + @Override + public void beforeAll(ExtensionContext context) { + if (enabled()) { + statistics.put( + context.getRequiredTestClass(), + new ClassStatistics(context.getRequiredTestClass().getName(), nanoTime.getAsLong())); + } + } + + @Override + public void afterAll(ExtensionContext context) throws IOException { + if (!enabled()) { + return; + } + + Class testClass = context.getRequiredTestClass(); + ClassStatistics classStatistics = + statistics.computeIfAbsent( + testClass, ignored -> new ClassStatistics(testClass.getName(), nanoTime.getAsLong())); + statistics.remove(testClass); + + String backend = System.getProperty(BACKEND_PROPERTY, "unknown"); + String worker = System.getProperty("org.gradle.test.worker", "unknown"); + String record = classStatistics.toRecord(backend, worker, nanoTime.getAsLong()); + writeRecord(Paths.get(System.getProperty(OUTPUT_DIRECTORY_PROPERTY)), worker, record); + } + + @Override + public void testSuccessful(ExtensionContext context) { + statistics(context).recordPassed(); + } + + @Override + public void testAborted(ExtensionContext context, Throwable cause) { + statistics(context).recordSkipped(); + } + + @Override + public void testDisabled(ExtensionContext context, Optional reason) { + statistics(context).recordSkipped(); + } + + @Override + public void testFailed(ExtensionContext context, Throwable cause) { + statistics(context).recordFailed(); + } + + private boolean enabled() { + String outputDirectory = System.getProperty(OUTPUT_DIRECTORY_PROPERTY); + return outputDirectory != null && !outputDirectory.trim().isEmpty(); + } + + private ClassStatistics statistics(ExtensionContext context) { + Class testClass = context.getRequiredTestClass(); + return statistics.computeIfAbsent( + testClass, ignored -> new ClassStatistics(testClass.getName(), nanoTime.getAsLong())); + } + + static void writeRecord(Path outputDirectory, String worker, String record) throws IOException { + Files.createDirectories(outputDirectory); + String safeWorker = worker.replaceAll("[^A-Za-z0-9._-]", "_"); + Path workerRecord = outputDirectory.resolve("worker-" + safeWorker + ".log"); + synchronized (FILE_WRITE_LOCK) { + Files.write( + workerRecord, + (record + System.lineSeparator()).getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } + } + + static final class ClassStatistics { + private final String testClass; + private final long startNanos; + private final LongAdder passed = new LongAdder(); + private final LongAdder failed = new LongAdder(); + private final LongAdder skipped = new LongAdder(); + + ClassStatistics(String testClass, long startNanos) { + this.testClass = testClass; + this.startNanos = startNanos; + } + + void recordPassed() { + passed.increment(); + } + + void recordFailed() { + failed.increment(); + } + + void recordSkipped() { + skipped.increment(); + } + + String toRecord(String backend, String worker, long endNanos) { + long durationNanos = Math.max(0L, endNanos - startNanos); + long passedCount = passed.sum(); + long failedCount = failed.sum(); + long skippedCount = skipped.sum(); + return String.format( + Locale.ROOT, + "[CORE-DB-SHARD] backend=%s worker=%s class=%s " + + "tests=%d passed=%d failed=%d skipped=%d durationMs=%d", + backend, + worker, + testClass, + passedCount + failedCount + skippedCount, + passedCount, + failedCount, + skippedCount, + TimeUnit.NANOSECONDS.toMillis(durationNanos)); + } + } +} diff --git a/core/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseShardTelemetryExtension.java b/core/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseShardTelemetryExtension.java new file mode 100644 index 00000000000..692cee22998 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseShardTelemetryExtension.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nullable; +import org.apache.gravitino.testing.CoreDatabaseShardTelemetryExtension.ClassStatistics; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.io.TempDir; + +class TestCoreDatabaseShardTelemetryExtension { + + @Test + void formatsLeafOutcomesAndClassDuration() { + ClassStatistics statistics = new ClassStatistics("example.TestStorage", 1_000_000L); + statistics.recordPassed(); + statistics.recordPassed(); + statistics.recordFailed(); + statistics.recordSkipped(); + statistics.recordSkipped(); + + assertEquals( + "[CORE-DB-SHARD] backend=postgresql worker=2 class=example.TestStorage " + + "tests=5 passed=2 failed=1 skipped=2 durationMs=8", + statistics.toRecord("postgresql", "2", 9_000_000L)); + } + + @Test + void recordsExtensionCallbacksInWorkerFile(@TempDir Path temporaryDirectory) throws Exception { + String originalOutputDirectory = + System.getProperty(CoreDatabaseShardTelemetryExtension.OUTPUT_DIRECTORY_PROPERTY); + String originalBackend = + System.getProperty(CoreDatabaseShardTelemetryExtension.BACKEND_PROPERTY); + String originalWorker = System.getProperty("org.gradle.test.worker"); + try { + System.setProperty( + CoreDatabaseShardTelemetryExtension.OUTPUT_DIRECTORY_PROPERTY, + temporaryDirectory.toString()); + System.setProperty(CoreDatabaseShardTelemetryExtension.BACKEND_PROPERTY, "h2"); + System.setProperty("org.gradle.test.worker", "worker/7"); + + AtomicLong nanoTime = new AtomicLong(1_000_000L); + CoreDatabaseShardTelemetryExtension extension = + new CoreDatabaseShardTelemetryExtension(nanoTime::get); + ExtensionContext context = mock(ExtensionContext.class); + doReturn(SampleDatabaseTest.class).when(context).getRequiredTestClass(); + + extension.beforeAll(context); + extension.testSuccessful(context); + extension.testFailed(context, new AssertionError("expected")); + extension.testAborted(context, new IllegalStateException("expected")); + extension.testDisabled(context, Optional.of("expected")); + nanoTime.set(9_000_000L); + extension.afterAll(context); + + assertEquals( + "[CORE-DB-SHARD] backend=h2 worker=worker/7 class=" + + SampleDatabaseTest.class.getName() + + " tests=4 passed=1 failed=1 skipped=2 durationMs=8" + + System.lineSeparator(), + Files.readString(temporaryDirectory.resolve("worker-worker_7.log"))); + } finally { + restoreProperty( + CoreDatabaseShardTelemetryExtension.OUTPUT_DIRECTORY_PROPERTY, originalOutputDirectory); + restoreProperty(CoreDatabaseShardTelemetryExtension.BACKEND_PROPERTY, originalBackend); + restoreProperty("org.gradle.test.worker", originalWorker); + } + } + + private static void restoreProperty(String name, @Nullable String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + private static class SampleDatabaseTest {} +} diff --git a/core/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/core/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000000..5f8c7a37be5 --- /dev/null +++ b/core/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +org.apache.gravitino.testing.CoreDatabaseShardTelemetryExtension