diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30d5754e27a..c8c48030e41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -206,6 +206,8 @@ jobs: -x :catalogs-contrib:catalog-jdbc-clickhouse:test -x :catalogs-contrib:catalog-jdbc-hologres:test -x :catalogs-contrib:catalog-jdbc-oceanbase:test + # Core's tests run in the core-test job, one lane per job; :core:test is deprecated. + -x :core:test ) if [ "${{ needs.changes.outputs.mcp_server_changes }}" != "true" ]; then @@ -223,13 +225,179 @@ 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-build + # 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 + path: | + build/reports + catalogs-contrib/**/*.log + catalogs-contrib/**/*.tar + catalogs/**/*.log + catalogs/**/*.tar + + core-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java-version: [ 17 ] + # One job per core test lane, so each lane's results and coverage inputs are inspectable on + # their own. Lanes are defined in core/build.gradle.kts. + lane: [ unit, h2, mysql, postgresql ] + include: + - lane: unit + task: coreUnitTest + skip-docker-tests: true + - lane: h2 + task: coreH2Test + skip-docker-tests: true + - lane: mysql + task: coreMySQLTest + skip-docker-tests: false + - lane: postgresql + task: corePostgreSQLTest + skip-docker-tests: false + timeout-minutes: 120 + needs: changes + if: >- + needs.changes.outputs.source_changes == 'true' && + needs.changes.outputs.maintenance_module_only_changes != 'true' + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-java-toolchains + with: + java-version: ${{ matrix.java-version }} + + - name: Free up disk space + run: | + dev/ci/util_free_space.sh + + - name: Test database concurrency build logic + id: buildsrc-tests + if: matrix.lane == 'unit' + 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: Run core ${{ matrix.lane }} lane + run: | + ./gradlew :core:${{ matrix.task }} \ + -PskipWeb=true \ + -PskipITs \ + -PskipDockerTests=${{ matrix.skip-docker-tests }} + + - name: Validate core test evidence + run: | + junit_xml="$(find "core/build/test-results/${{ matrix.task }}" \ + -type f -name 'TEST-*.xml' -print -quit)" + test -n "${junit_xml}" + test -s "${junit_xml}" + test -s "build/reports/tests/core/${{ matrix.task }}/index.html" + test -s "core/build/jacoco/${{ matrix.task }}.exec" + if [ "${{ matrix.lane }}" != "unit" ]; then + shard_record="$(find \ + "core/build/test-results/${{ matrix.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 + uses: actions/upload-artifact@v7 + with: + name: core-${{ matrix.lane }}-test-evidence + path: | + core/build/test-results/${{ matrix.task }} + build/reports/tests/core/${{ matrix.task }} + core/build/jacoco/${{ matrix.task }}.exec + if-no-files-found: error + retention-days: 1 + + - name: Upload core test report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: core test report ${{ matrix.lane }} + path: | + build/reports/tests/core + core/build/test-results + + coverage: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [ changes, build, core-test ] + if: >- + always() && + github.event_name == 'pull_request' && + needs.build.result == 'success' && + (needs.changes.outputs.maintenance_module_only_changes == 'true' || + needs['core-test'].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 + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: jacoco-* + merge-multiple: true + + - name: Download core test evidence + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + uses: actions/download-artifact@v4 + with: + pattern: core-*-test-evidence + path: core-test-evidence + + - name: Generate combined core coverage + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + run: | + mkdir -p core/build/jacoco + for task in coreUnitTest coreH2Test coreMySQLTest corePostgreSQLTest; do + exec_file="$(find core-test-evidence -path "*/core/build/jacoco/${task}.exec" -print -quit)" + test -n "${exec_file}" + install -m 0644 "${exec_file}" "core/build/jacoco/${task}.exec" + done + ./gradlew \ + :core:jacocoTestReport \ + -PcoreSuiteCoverage=true \ + -PskipWeb=true + test -s core/build/reports/jacoco/test/jacocoTestReport.xml + - name: Generate Coverage Report id: coverage - if: github.event_name == 'pull_request' run: | python3 dev/ci/jacoco_report.py \ --base-ref "${{ github.base_ref }}" \ @@ -240,11 +408,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 @@ -253,19 +421,7 @@ 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 diff --git a/build.gradle.kts b/build.gradle.kts index 0161f01f724..427df0ba37c 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") } @@ -514,9 +514,14 @@ allprojects { val dockerTest = project.rootProject.extra["dockerTest"] as? Boolean ?: false param.environment("dockerTest", dockerTest.toString()) + // Must stay inside doFirst: tasks such as :core's database lanes set this flag in their own + // configuration action, which may run after this configureEach action registers. + 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) { @@ -1017,9 +1022,19 @@ 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() - finalizedBy(tasks.getByName("jacocoTestReport")) + val isCoreSuiteTask = + project.path == ":core" && + name in setOf( + "coreUnitTest", + "coreH2Test", + "coreMySQLTest", + "corePostgreSQLTest" + ) + if (!isCoreSuiteTask) { + finalizedBy(tasks.getByName("jacocoTestReport")) + } } } @@ -1630,6 +1645,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() { @@ -1637,6 +1653,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 bdd8cbbfc1c..c59110e080c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,8 @@ +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 java.lang.management.ManagementFactory /* * Licensed to the Apache Software Foundation (ASF) under one @@ -105,11 +109,229 @@ artifacts { add("testArtifacts", testJar) } -tasks.test { - val policiesDocument = rootProject.file("docs/policies.md") - inputs.file(policiesDocument) - systemProperty("gravitino.repositoryRoot", rootProject.projectDir.path) +// Core's tests run in four lanes: coreUnitTest, plus coreH2Test/coreMySQLTest/corePostgreSQLTest. +// A class joins a backend lane through its @CoreBackend.* annotation; see CoreBackend.java. +// Backend name -> the JUnit tag that admits a class to that lane. Keep in sync with CoreBackend. +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" +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, + 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 + + reports.html.outputLocation.set( + rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") + ) + + useJUnitPlatform { + if (backend == null) { + // 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") + // 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) + } + } + + 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) + // Read by the root setTestEnvironment inside doFirst, i.e. at execution time, after every + // configuration action has run - so it doesn't depend on register/configureEach ordering. + extensions.extraProperties["includeDockerTaggedTests"] = true + + // 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 + ) + 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 { + 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") } +tasks.named("jacocoTestReport") { + if (coreSuiteCoverage.get()) { + dependsOn(tasks.named("classes")) + executionData.setFrom(coreSuiteExecutionData) + } +} + +// :core:test is kept for IDEs and local habit, and is deprecated: run a lane instead. CI skips it +// (`build` passes `-x :core:test`) and runs each lane in its own `core-test` job. +// TestRestrictedRegoGoldenFile reads docs/policies.md. It is a unit test, so only coreUnitTest and +// the legacy :core:test run it; the database lanes don't need the input. +listOf("coreUnitTest", "test").forEach { + tasks.named(it) { + inputs.file(rootProject.file("docs/policies.md")) + systemProperty("gravitino.repositoryRoot", rootProject.projectDir.path) + } +} + +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." + ) + } val testMode = project.properties["testMode"] as? String ?: "embedded" if (testMode == "embedded") { environment("GRAVITINO_HOME", project.rootDir.path) 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/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index b1cac18a986..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; @@ -58,12 +59,14 @@ 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; 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; @@ -93,9 +96,11 @@ 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. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @ExtendWith(CloseContainerExtension.class) abstract static class BaseJdbcPartitionStatisticStorageTest { protected JdbcPartitionStatisticStorage storage; @@ -585,6 +590,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested + @CoreBackend.MySQL @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -655,6 +661,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested + @CoreBackend.PostgreSQL @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -728,6 +735,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested + @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 896fe90ca42..26498a1a498 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -52,6 +52,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; @@ -78,8 +79,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; @@ -95,6 +96,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.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; @@ -104,12 +107,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); @@ -119,21 +124,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} - }; - } - - @AfterEach - void closeSuit() throws IOException { - // todo: refactor TestEntityStorage to extend TestJDBCBackend, otherwise, each test will start - // and stop the container suite. - ContainerSuite.getInstance().close(); + 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); } protected void init(String type, Config config) throws IllegalAccessException { 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..7eb4760f571 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +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.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** 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 testStorageProviderUsesSelectedBackend() { + System.setProperty(BACKEND_PROPERTY, "postgresql"); + + assertArrayEquals( + new Object[][] {{"postgresql", true}, {"postgresql", false}}, + AbstractEntityStorageTest.storageProvider()); + } +} 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 e3ccdb44ccf..3affa9dca49 100644 --- a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java +++ b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java @@ -38,6 +38,8 @@ import java.util.TreeSet; 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; @@ -45,6 +47,7 @@ import org.junit.jupiter.api.TestTemplate; import org.opentest4j.AssertionFailedError; +@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 9961e30bb1c..e9a4b275fce 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 @@ -38,6 +38,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; @@ -50,12 +51,18 @@ 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.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; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,34 +77,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; + } } } @@ -109,6 +126,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 createInvocationContexts( + testMethodName, Collections.singletonList(selectedBackend.get()), true); + } + List backendsToTest = new ArrayList<>(); backendsToTest.add("h2"); // Always test with H2 @@ -122,52 +147,65 @@ 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 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 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 public String getDisplayName(int invocationIndex) { - return String.format("[%s Backend]", 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 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)) @@ -179,20 +217,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"); @@ -213,15 +246,255 @@ 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, + 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( + 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"); + } + + closeAfterEach = !reuseBackend || isolation == DatabaseIsolation.FRESH_NAMESPACE; + try { + if (reuseBackend && isolation == DatabaseIsolation.FRESH_NAMESPACE) { + closeClassBackend(context); + } + backendResource = closeAfterEach ? newBackendResource() : getOrCreateClassBackend(context); + backendResource.activate(); + 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 { + try { + if (closeAfterEach && backendResource != null) { + backendResource.close(); + backendResource = null; + } + } finally { + databaseTestContext = 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 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; + } - // 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; + private BackendResource newBackendResource() throws Exception { + return backendFactory.create(backendType); + } + + private void cleanupAfterSetupFailure(Exception failure) { + databaseTestContext = null; + 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; + } } } @@ -229,9 +502,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/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/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java new file mode 100644 index 00000000000..893bea308f8 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.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; + +/** + * Annotations that choose which core database test lane(s) a class runs in. Each lane task ({@code + * coreH2Test}, {@code coreMySQLTest}, {@code corePostgreSQLTest}) includes only classes with its + * tag; stack annotations to join several lanes, or use {@link All}. A class with none of them is a + * unit test and runs in {@code coreUnitTest}. {@code TestCoreLaneMembership} fails if a class + * carries {@code gravitino-docker-test} but no backend tag, since it would run in no lane. + * + *

The lane, not the class, decides whether Docker is needed: MySQL and PostgreSQL require it and + * H2 does not, so a class tagged for H2 must not need a container itself. The tag strings must stay + * in sync with {@code coreBackendTestTags} in {@code core/build.gradle.kts}. + */ +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/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/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 new file mode 100644 index 00000000000..47b878e7e35 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java @@ -0,0 +1,427 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 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.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; +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.lang.reflect.Parameter; +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.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; + +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); + + 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()); + 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(); + 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( + 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 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; + + @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/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java new file mode 100644 index 00000000000..a3c02117bf3 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Modifier; +import java.net.URL; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.annotation.Testable; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * Fails when a core test class runs in no lane: it carries {@code gravitino-docker-test}, which + * keeps it out of {@code coreUnitTest}, but no {@link CoreBackend} tag, which keeps it out of every + * database lane. Nothing else notices a class that leaves every lane at once. + */ +class TestCoreLaneMembership { + + private static final String DOCKER_TAG = "gravitino-docker-test"; + + private static final Set BACKEND_TAGS = + Set.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, CoreBackend.POSTGRESQL_TAG); + + @Test + void everyDockerTaggedTestClassHasABackendLane() { + URL coreTestClasses = codeLocation(TestCoreLaneMembership.class); + List orphans = + ReflectionSupport.findAllClassesInPackage( + "org.apache.gravitino", + c -> coreTestClasses.equals(codeLocation(c)) && declaresTests(c), + name -> true) + .stream() + .filter( + c -> { + Set tags = effectiveTags(c); + return tags.contains(DOCKER_TAG) + && tags.stream().noneMatch(BACKEND_TAGS::contains); + }) + .map(Class::getName) + .sorted() + .collect(Collectors.toList()); + + assertTrue( + orphans.isEmpty(), + "These classes carry gravitino-docker-test but no @CoreBackend.* tag, so they run in no " + + "core test lane. Add @CoreBackend.H2/MySQL/PostgreSQL or @CoreBackend.All: " + + orphans); + } + + /** + * Whether JUnit would run {@code c}: concrete, with at least one {@code @Test}-style method (all + * meta-annotated with {@link Testable}), own or inherited. + */ + private static boolean declaresTests(Class c) { + return !Modifier.isAbstract(c.getModifiers()) + && !ReflectionSupport.findMethods( + c, + m -> AnnotationSupport.isAnnotated(m, Testable.class), + HierarchyTraversalMode.TOP_DOWN) + .isEmpty(); + } + + /** + * Own and inherited tags, plus the enclosing classes' tags for non-static {@code @Nested} + * classes, which JUnit applies to them. Static member classes are discovered as top-level classes + * and don't inherit their enclosing class's tags. + */ + private static Set effectiveTags(Class c) { + Set tags = new HashSet<>(); + for (Class k = c; + k != null; + k = Modifier.isStatic(k.getModifiers()) ? null : k.getEnclosingClass()) { + AnnotationSupport.findRepeatableAnnotations(k, Tag.class).forEach(t -> tags.add(t.value())); + } + return tags; + } + + private static URL codeLocation(Class c) { + return c.getProtectionDomain().getCodeSource() == null + ? null + : c.getProtectionDomain().getCodeSource().getLocation(); + } +} 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 5c878525c27..ea1191cedf6 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,6 +84,7 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; +@CoreBackend.All @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, @@ -97,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/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/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 a83f646909d..903c751128e 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 @@ -988,7 +988,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 diff --git a/docs/how-to-test.md b/docs/how-to-test.md index 373a510e06d..2e567054882 100644 --- a/docs/how-to-test.md +++ b/docs/how-to-test.md @@ -35,6 +35,29 @@ To run the unit tests, run the following command: This command runs all the unit tests and skips the integration tests. +### Core test lanes + +`core` tests run in four lanes, one CI job each: + +| Lane | Runs | Needs Docker | +| --- | --- | --- | +| `./gradlew :core:coreUnitTest` | tests with no database backend | no | +| `./gradlew :core:coreH2Test` | classes tagged for H2 | no | +| `./gradlew :core:coreMySQLTest -PskipDockerTests=false` | classes tagged for MySQL | yes | +| `./gradlew :core:corePostgreSQLTest -PskipDockerTests=false` | classes tagged for PostgreSQL | yes | + +A class joins a database lane through `@CoreBackend.H2`, `@CoreBackend.MySQL`, `@CoreBackend.PostgreSQL` or `@CoreBackend.All`. Subclasses of `TestJDBCBackend` and `AbstractEntityStorageTest` inherit `@CoreBackend.All`. A class with none of these runs in `coreUnitTest`. A class tagged `gravitino-docker-test` with none of these runs in no lane, and `TestCoreLaneMembership` fails until you add one. + +When you add or move a core test, check that it runs in the lane you expect: + +```shell +./gradlew :core:coreH2Test --tests 'org.apache.gravitino.storage.relational.service.TestYourService' +``` + +If the class is not in that lane, Gradle fails with "No tests found for given includes". + +In CI, `build (17)` skips core tests; their results appear under the `core-test (17, )` checks. `./gradlew :core:test` still runs all core tests in one task locally (H2 only unless you pass `-PskipDockerTests=false`), but it is deprecated and no CI job runs core tests through it. + ## Run the Integration Tests Gravitino has two modes to run the integration tests, the default `embedded` mode and `deploy` mode. 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 88e4cae9392..0ad9c4dee0e 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 @@ -386,10 +386,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 = @@ -443,10 +443,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 = @@ -972,7 +972,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; @@ -986,8 +987,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..51becc11527 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); } } @@ -47,6 +44,8 @@ private static class CloseableContainer implements ExtensionContext.Store.Closea private static final Logger LOGGER = LoggerFactory.getLogger(CloseableContainer.class); private static final ContainerSuite CONTAINER_SUITE = ContainerSuite.getInstance(); + // Log instead of failing: this runs for every integration-test module at the end of the plan, + // and a slow container teardown must not fail an otherwise green run. @Override public void close() { try {