From 8dc2cb6ad3befd433a562765b2cdee54b913ef59 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sat, 26 Sep 2026 22:27:17 -0700 Subject: [PATCH 01/30] test(core): define explicit database test lanes --- build.gradle.kts | 17 +- core/build.gradle.kts | 104 ++++ .../TestJdbcPartitionStatisticStorageIT.java | 5 + .../storage/AbstractEntityStorageTest.java | 24 +- .../storage/TestBackendTestSelector.java | 138 ++++ .../relational/BackendTestExtension.java | 18 +- .../relational/BackendTestSelector.java | 71 +++ .../storage/relational/TestJDBCBackend.java | 2 + .../service/TestJobMetaService.java | 48 -- .../service/TestJobMetaServiceValidation.java | 78 +++ dev/ci/core_test_identity.py | 587 ++++++++++++++++++ .../core_test_identity/h2/TEST-backend.xml | 27 + .../core_test_identity/legacy/TEST-legacy.xml | 49 ++ .../core_test_identity/mysql/TEST-backend.xml | 27 + .../postgresql/TEST-backend.xml | 29 + .../core_test_identity/unit/TEST-unit.xml | 26 + dev/ci/tests/test_core_test_identity.py | 407 ++++++++++++ 17 files changed, 1596 insertions(+), 61 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java create mode 100644 dev/ci/core_test_identity.py create mode 100644 dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml create mode 100644 dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml create mode 100644 dev/ci/tests/test_core_test_identity.py diff --git a/build.gradle.kts b/build.gradle.kts index 0161f01f724..059e2f21690 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -514,9 +514,12 @@ allprojects { val dockerTest = project.rootProject.extra["dockerTest"] as? Boolean ?: false param.environment("dockerTest", dockerTest.toString()) + val includeDockerTaggedTests = + param.extensions.extraProperties.properties["includeDockerTaggedTests"] as? Boolean + ?: dockerTest val dorisMultiVersion = project.hasProperty("dorisMultiVersionTest") param.useJUnitPlatform { - if (!dockerTest) { + if (!includeDockerTaggedTests) { excludeTags("gravitino-docker-test") } if (!dorisMultiVersion) { @@ -1019,7 +1022,17 @@ subprojects { val extraArgs = project.property("extraJvmArgs") as List jvmArgs = listOf("-Xmx4G") + extraArgs useJUnitPlatform() - finalizedBy(tasks.getByName("jacocoTestReport")) + val isCoreSuiteTask = + project.path == ":core" && + name in setOf( + "coreUnitTest", + "coreH2Test", + "coreMySQLTest", + "corePostgreSQLTest" + ) + if (!isCoreSuiteTask) { + finalizedBy(tasks.getByName("jacocoTestReport")) + } } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index bdd8cbbfc1c..07abc95f71b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,7 @@ import net.ltgt.gradle.errorprone.errorprone +import org.gradle.api.tasks.testing.Test +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport /* * Licensed to the Apache Software Foundation (ASF) under one @@ -105,6 +108,107 @@ artifacts { add("testArtifacts", testJar) } +val coreDatabaseTestTag = "gravitino-core-database-test" +val coreH2TestTag = "gravitino-core-h2-test" +val coreMySQLTestTag = "gravitino-core-mysql-test" +val corePostgreSQLTestTag = "gravitino-core-postgresql-test" +val coreTestBackendProperty = "gravitino.core.test.backend" + +fun registerCoreTestTask( + taskName: String, + backend: String? = null +) = tasks.register(taskName) { + group = "verification" + description = + if (backend == null) { + "Runs core unit tests." + } else { + "Runs core database tests against $backend." + } + + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + + inputs.property("coreTestSuite", backend ?: "unit") + inputs.property("coreTestBackend", backend ?: "none") + inputs.property("includeDockerTaggedTests", backend != null) + reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) + reports.html.outputLocation.set( + rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") + ) + + extensions.configure { + destinationFile = layout.buildDirectory.file("jacoco/$taskName.exec").get().asFile + } + + useJUnitPlatform { + if (backend == null) { + excludeTags(coreDatabaseTestTag, "gravitino-docker-test") + } else { + includeTags(coreDatabaseTestTag) + when (backend) { + "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) + "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) + "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) + else -> throw GradleException("Unsupported core test backend: $backend") + } + } + } + + if (backend != null) { + systemProperty(coreTestBackendProperty, backend) + extensions.extraProperties["includeDockerTaggedTests"] = true + + // Database tests mutate process-wide state and must remain sequential within each lane. + maxParallelForks = 1 + systemProperty("junit.jupiter.execution.parallel.enabled", "false") + + if (backend != "h2") { + doFirst { + if (rootProject.extra["dockerTest"] != true) { + throw GradleException( + "$path requires Docker; use -PskipDockerTests=false with Docker running." + ) + } + } + } + } +} + +registerCoreTestTask("coreUnitTest") +registerCoreTestTask("coreH2Test", "h2") +registerCoreTestTask("coreMySQLTest", "mysql") +registerCoreTestTask("corePostgreSQLTest", "postgresql") + +val coreSuiteCoverage = + providers.gradleProperty("coreSuiteCoverage").map(String::toBoolean).orElse(false) +val coreSuiteTaskNames = + listOf("coreUnitTest", "coreH2Test", "coreMySQLTest", "corePostgreSQLTest") +val coreSuiteExecutionData = + coreSuiteTaskNames.map { layout.buildDirectory.file("jacoco/$it.exec") } +val validateCoreSuiteCoverage by tasks.registering { + inputs.files(coreSuiteExecutionData) + + doLast { + val missingExecutionData = + coreSuiteExecutionData + .map { it.get().asFile } + .filterNot { it.isFile && it.length() > 0L } + if (missingExecutionData.isNotEmpty()) { + throw GradleException( + "Missing core JaCoCo execution data: ${missingExecutionData.joinToString()}" + ) + } + } +} + +tasks.named("jacocoTestReport") { + if (coreSuiteCoverage.get()) { + dependsOn(tasks.named("classes"), validateCoreSuiteCoverage) + executionData.setFrom(coreSuiteExecutionData) + } +} + tasks.test { val policiesDocument = rootProject.file("docs/policies.md") inputs.file(policiesDocument) diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index b1cac18a986..42b5938596a 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -85,6 +85,7 @@ *
  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ +@Tag("gravitino-core-database-test") @Tag("gravitino-docker-test") public class TestJdbcPartitionStatisticStorageIT { @@ -95,6 +96,7 @@ public class TestJdbcPartitionStatisticStorageIT { * Abstract base class containing all test logic. Each database-specific test class extends this * and implements the database setup. */ + @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { @@ -585,6 +587,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested + @Tag("gravitino-core-mysql-test") @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -655,6 +658,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested + @Tag("gravitino-core-postgresql-test") @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -728,6 +732,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested + @Tag("gravitino-core-h2-test") static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index 896fe90ca42..2fb2641646c 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; @@ -95,6 +96,7 @@ import org.apache.gravitino.meta.TopicEntity; import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; +import org.apache.gravitino.storage.relational.BackendTestSelector; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -106,10 +108,12 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +@Tag("gravitino-core-database-test") abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); @@ -119,14 +123,18 @@ abstract class AbstractEntityStorageTest { protected static final String H2_FILE = DB_DIR + ".mv.db"; static Object[][] storageProvider() { - return new Object[][] { - {"h2", true}, - {"h2", false}, - {"mysql", true}, - {"mysql", false}, - {"postgresql", true}, - {"postgresql", false} - }; + Object[][] backends = + new Object[][] { + {"h2", true}, + {"h2", false}, + {"mysql", true}, + {"mysql", false}, + {"postgresql", true}, + {"postgresql", false} + }; + return Arrays.stream(backends) + .filter(arguments -> BackendTestSelector.isSelected((String) arguments[0])) + .toArray(Object[][]::new); } @AfterEach diff --git a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java new file mode 100644 index 00000000000..68e57488392 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.apache.gravitino.storage.relational.BackendTestExtension; +import org.apache.gravitino.storage.relational.BackendTestSelector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestTemplateInvocationContext; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import org.mockito.Mockito; + +/** Tests backend selection for the core database test suites. */ +@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE) +public class TestBackendTestSelector { + + private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + + private Optional originalBackend = Optional.empty(); + + @BeforeEach + void saveAndClearBackendProperty() { + originalBackend = Optional.ofNullable(System.getProperty(BACKEND_PROPERTY)); + System.clearProperty(BACKEND_PROPERTY); + } + + @AfterEach + void restoreBackendProperty() { + System.clearProperty(BACKEND_PROPERTY); + originalBackend.ifPresent(value -> System.setProperty(BACKEND_PROPERTY, value)); + } + + @Test + void testAbsentSelectionPreservesLegacyBehavior() { + assertEquals(Optional.empty(), BackendTestSelector.selectedBackend()); + assertTrue(BackendTestSelector.isSelected("h2")); + assertTrue(BackendTestSelector.isSelected("mysql")); + assertTrue(BackendTestSelector.isSelected("postgresql")); + } + + @Test + void testSelectionIsNormalizedAndValidated() { + System.setProperty(BACKEND_PROPERTY, " MySQL "); + + assertEquals(Optional.of("mysql"), BackendTestSelector.selectedBackend()); + assertTrue(BackendTestSelector.isSelected("MYSQL")); + assertFalse(BackendTestSelector.isSelected("h2")); + + System.setProperty(BACKEND_PROPERTY, "unsupported"); + assertThrows(IllegalArgumentException.class, BackendTestSelector::selectedBackend); + } + + @Test + void testTemplateProviderUsesSelectedBackendAndMethodName() throws NoSuchMethodException { + System.setProperty(BACKEND_PROPERTY, "mysql"); + BackendTestExtension extension = new BackendTestExtension(); + + String firstDisplayName = + selectedInvocation(extension, "firstTemplateMethod").getDisplayName(1); + String secondDisplayName = + selectedInvocation(extension, "secondTemplateMethod").getDisplayName(1); + + assertEquals("firstTemplateMethod()[MYSQL Backend]", firstDisplayName); + assertEquals("secondTemplateMethod()[MYSQL Backend]", secondDisplayName); + assertNotEquals(firstDisplayName, secondDisplayName); + } + + @Test + void testStorageProviderPreservesLegacyMatrix() { + assertArrayEquals( + new Object[][] { + {"h2", true}, + {"h2", false}, + {"mysql", true}, + {"mysql", false}, + {"postgresql", true}, + {"postgresql", false} + }, + AbstractEntityStorageTest.storageProvider()); + } + + @Test + void testStorageProviderUsesSelectedBackend() { + System.setProperty(BACKEND_PROPERTY, "postgresql"); + + assertArrayEquals( + new Object[][] {{"postgresql", true}, {"postgresql", false}}, + AbstractEntityStorageTest.storageProvider()); + } + + private static TestTemplateInvocationContext selectedInvocation( + BackendTestExtension extension, String methodName) throws NoSuchMethodException { + ExtensionContext context = Mockito.mock(ExtensionContext.class); + Mockito.when(context.getRequiredTestMethod()) + .thenReturn(TemplateMethods.class.getDeclaredMethod(methodName)); + + List invocations = + extension.provideTestTemplateInvocationContexts(context).toList(); + + assertEquals(1, invocations.size()); + return invocations.get(0); + } + + private static class TemplateMethods { + void firstTemplateMethod() {} + + void secondTemplateMethod() {} + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index 9961e30bb1c..f59364cef7e 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; @@ -109,6 +110,14 @@ public boolean supportsTestTemplate(ExtensionContext context) { @Override public Stream provideTestTemplateInvocationContexts( ExtensionContext context) { + String testMethodName = context.getRequiredTestMethod().getName(); + Optional selectedBackend = BackendTestSelector.selectedBackend(); + if (selectedBackend.isPresent()) { + LOG.info("Running tests with the selected {} backend.", selectedBackend.get()); + return Stream.of(selectedBackend.get()) + .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + } + List backendsToTest = new ArrayList<>(); backendsToTest.add("h2"); // Always test with H2 @@ -122,19 +131,22 @@ public Stream provideTestTemplateInvocationContex "Running tests with H2 backend only. Set env var 'dockerTest=true' to include all backends."); } - return backendsToTest.stream().map(BackendInvocationContext::new); + return backendsToTest.stream() + .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); } private static class BackendInvocationContext implements TestTemplateInvocationContext { + private final String testMethodName; private final String backendType; - public BackendInvocationContext(String backendType) { + public BackendInvocationContext(String testMethodName, String backendType) { + this.testMethodName = testMethodName; this.backendType = backendType; } @Override public String getDisplayName(int invocationIndex) { - return String.format("[%s Backend]", backendType.toUpperCase()); + return String.format("%s()[%s Backend]", testMethodName, backendType.toUpperCase()); } @Override diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java new file mode 100644 index 00000000000..3f794dbea80 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestSelector.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** Selects a single JDBC backend for the core database test suites. */ +public final class BackendTestSelector { + + private static final String BACKEND_PROPERTY = "gravitino.core.test.backend"; + private static final Set SUPPORTED_BACKENDS = Set.of("h2", "mysql", "postgresql"); + + private BackendTestSelector() {} + + /** + * Returns the selected backend, or an empty value when the legacy all-applicable-backends + * behavior should be used. + * + * @return the normalized selected backend + * @throws IllegalArgumentException if the configured backend is unsupported + */ + public static Optional selectedBackend() { + String configuredBackend = System.getProperty(BACKEND_PROPERTY); + if (configuredBackend == null) { + return Optional.empty(); + } + + return Optional.of(validate(configuredBackend)); + } + + /** + * Returns whether a backend should run under the current selection. + * + * @param backend backend to test + * @return true when no backend is selected or the backend matches the selection + * @throws IllegalArgumentException if either backend value is unsupported + */ + public static boolean isSelected(String backend) { + String normalizedBackend = validate(backend); + return selectedBackend().map(normalizedBackend::equals).orElse(true); + } + + private static String validate(String backend) { + String normalizedBackend = backend.trim().toLowerCase(Locale.ROOT); + if (!SUPPORTED_BACKENDS.contains(normalizedBackend)) { + throw new IllegalArgumentException( + String.format( + "Unsupported core test backend '%s'; expected one of %s", + backend, SUPPORTED_BACKENDS)); + } + return normalizedBackend; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 5c878525c27..2891f52e78f 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java @@ -81,9 +81,11 @@ import org.apache.gravitino.utils.NamespaceUtil; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; +@Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ExtendWith({ BackendTestExtension.class, diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java index 9ddbfe51c4b..abd5802a537 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java @@ -35,7 +35,6 @@ import org.apache.gravitino.utils.NameIdentifierUtil; import org.apache.gravitino.utils.NamespaceUtil; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; public class TestJobMetaService extends TestJDBCBackend { @@ -492,51 +491,4 @@ public void testUpdateJobWithMismatchedIdThrowsIllegalArgumentException() throws .withFinishedAt(oldJob.finishedAt()) .build())); } - - @Test - public void testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"), e -> e)); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .updateJob( - NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX), e -> e)); - } - - @Test - public void testGetJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .getJobByIdentifier( - NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); - } - - @Test - public void testDeleteJobWithMalformedIdentifierThrowsNoSuchEntityException() { - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); - - Assertions.assertThrows( - NoSuchEntityException.class, - () -> - JobMetaService.getInstance() - .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); - } } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java new file mode 100644 index 00000000000..2d7f6ea6176 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaServiceValidation.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational.service; + +import org.apache.gravitino.exceptions.NoSuchEntityException; +import org.apache.gravitino.job.JobHandle; +import org.apache.gravitino.utils.NameIdentifierUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests job identifier validation that does not require a JDBC backend. */ +public class TestJobMetaServiceValidation { + + private static final String METALAKE_NAME = "metalake_test_job_meta_service"; + + @Test + void testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"), e -> e)); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .updateJob( + NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX), e -> e)); + } + + @Test + void testGetJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .getJobByIdentifier( + NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); + } + + @Test + void testDeleteJobWithMalformedIdentifierThrowsNoSuchEntityException() { + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"))); + + Assertions.assertThrows( + NoSuchEntityException.class, + () -> + JobMetaService.getInstance() + .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, JobHandle.JOB_ID_PREFIX))); + } +} diff --git a/dev/ci/core_test_identity.py b/dev/ci/core_test_identity.py new file mode 100644 index 00000000000..84b888126be --- /dev/null +++ b/dev/ci/core_test_identity.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Build and reconcile normalized core-test identity manifests. + +Gradle writes one JUnit XML directory per Test task. This tool turns those +reports into stable, backend-neutral identity multisets so the H2, MySQL, and +PostgreSQL lanes can prove that they exercised the same test contract. It also +records status counts and elapsed test time for CI artifacts. A separate +legacy comparison proves that the four split lanes preserve the exact test +multiset previously exercised by ``:core:test``. +""" + +import argparse +from collections import Counter +from decimal import Decimal, InvalidOperation +import hashlib +import json +import math +from pathlib import Path +import re +import sys +import xml.etree.ElementTree as ET + + +SCHEMA_VERSION = 1 +LANES = ("unit", "h2", "mysql", "postgresql") +DATABASE_LANES = LANES[1:] +MANIFEST_LANES = LANES + ("legacy",) +STATUS_KEYS = ("passed", "skipped", "failures", "errors") + +BACKEND_NAME_PATTERN = r"h2|mysql|postgresql" +TEST_TEMPLATE_BACKEND_RE = re.compile( + rf"\[(?P{BACKEND_NAME_PATTERN})\s+Backend\]", re.IGNORECASE +) +STATS_BACKEND_CLASS_RE = re.compile( + rf"(?PTestJdbcPartitionStatisticStorageIT)\$" + rf"(?P{BACKEND_NAME_PATTERN})Test(?=$|\$)", + re.IGNORECASE, +) +BACKEND_TOKEN_RE = re.compile( + rf"(?{BACKEND_NAME_PATTERN})(?![A-Za-z0-9])", + re.IGNORECASE, +) +TRAILING_INVOCATION_INDEX_RE = re.compile( + r"\[(?:#)?\d+\](?=(?:\s*\[BACKEND Backend\])?\s*$)", re.IGNORECASE +) + + +class ManifestError(ValueError): + """Raised when test results cannot form a trustworthy manifest.""" + + +def _local_name(tag): + """Return an XML element name without its optional namespace.""" + return tag.rsplit("}", 1)[-1] + + +def _canonical_backend(value): + """Return the canonical spelling of a recognized backend.""" + return value.lower() + + +def _classname_backend_markers(value): + """Find backend markers in the backend-specific nested stats classes.""" + return { + _canonical_backend(match.group("backend")) + for match in STATS_BACKEND_CLASS_RE.finditer(value) + } + + +def _test_name_backend_markers(value): + """Find structured backend markers in a testcase name.""" + markers = { + _canonical_backend(match.group("backend")) + for match in TEST_TEMPLATE_BACKEND_RE.finditer(value) + } + markers.update( + _canonical_backend(match.group("backend")) + for match in BACKEND_TOKEN_RE.finditer(value) + ) + return markers + + +def _normalize_classname(value): + """Normalize backend-specific nested stats class names.""" + return STATS_BACKEND_CLASS_RE.sub( + lambda match: f"{match.group('prefix')}$BackendTest", value + ) + + +def _normalize_test_name(value): + """Normalize backend markers and trailing parameterized indices.""" + normalized = TEST_TEMPLATE_BACKEND_RE.sub("[BACKEND Backend]", value) + normalized = BACKEND_TOKEN_RE.sub("BACKEND", normalized) + return TRAILING_INVOCATION_INDEX_RE.sub("[INDEX]", normalized) + + +def normalize_identity(lane, classname, test_name): + """Validate lane markers and return a normalized test identity pair.""" + classname = (classname or "").strip() + test_name = (test_name or "").strip() + if not classname or not test_name: + raise ManifestError("Every must have non-empty classname and name attributes") + + markers = _classname_backend_markers(classname) | _test_name_backend_markers(test_name) + if lane == "unit" and markers: + raise ManifestError( + "Unit test result contains an explicit backend marker " + f"{sorted(markers)}: {classname}::{test_name}" + ) + if lane in DATABASE_LANES: + foreign_markers = markers - {lane} + if foreign_markers: + raise ManifestError( + f"{lane} test result contains foreign backend marker(s) " + f"{sorted(foreign_markers)}: {classname}::{test_name}" + ) + + return ( + _normalize_classname(classname), + _normalize_test_name(test_name), + ) + + +def _testcase_status(testcase): + """Classify one JUnit testcase element.""" + child_tags = {_local_name(child.tag) for child in testcase} + if "failure" in child_tags: + return "failures" + if "error" in child_tags: + return "errors" + if "skipped" in child_tags: + return "skipped" + return "passed" + + +def _testcase_duration(testcase, source_file): + """Parse one JUnit testcase duration as a non-negative Decimal.""" + value = testcase.get("time", "0") + try: + duration = Decimal(value) + except InvalidOperation as error: + raise ManifestError( + f"Invalid testcase duration {value!r} in {source_file}" + ) from error + if not duration.is_finite() or duration < 0: + raise ManifestError(f"Invalid testcase duration {value!r} in {source_file}") + return duration + + +def _identities_as_json(identities): + """Convert an identity Counter to deterministic JSON records.""" + return [ + {"classname": classname, "name": name, "count": count} + for (classname, name), count in sorted(identities.items()) + ] + + +def _identities_from_json(manifest, source_file): + """Validate and restore an identity Counter from a manifest.""" + records = manifest.get("identities") + if not isinstance(records, list): + raise ManifestError(f"Manifest {source_file} has no identities list") + + identities = Counter() + for record in records: + if not isinstance(record, dict): + raise ManifestError(f"Manifest {source_file} has an invalid identity record") + classname = record.get("classname") + name = record.get("name") + count = record.get("count") + if not isinstance(classname, str) or not classname: + raise ManifestError(f"Manifest {source_file} has an invalid classname") + if not isinstance(name, str) or not name: + raise ManifestError(f"Manifest {source_file} has an invalid testcase name") + if not isinstance(count, int) or isinstance(count, bool) or count <= 0: + raise ManifestError(f"Manifest {source_file} has an invalid identity count") + identity = (classname, name) + if identity in identities: + raise ManifestError(f"Manifest {source_file} repeats identity {identity}") + identities[identity] = count + return identities + + +def _identity_digest(identities): + """Return a stable digest of an identity multiset.""" + digest = hashlib.sha256() + for (classname, name), count in sorted(identities.items()): + digest.update(classname.encode("utf-8")) + digest.update(b"\0") + digest.update(name.encode("utf-8")) + digest.update(b"\0") + digest.update(str(count).encode("ascii")) + digest.update(b"\n") + return digest.hexdigest() + + +def build_manifest(lane, results_directory): + """Parse Gradle JUnit XML reports and return a normalized manifest.""" + if lane not in MANIFEST_LANES: + raise ManifestError( + f"Unknown lane {lane!r}; expected one of {', '.join(MANIFEST_LANES)}" + ) + + results_directory = Path(results_directory) + if not results_directory.is_dir(): + raise ManifestError(f"Results directory does not exist: {results_directory}") + + xml_files = sorted(results_directory.rglob("TEST-*.xml")) + if not xml_files: + raise ManifestError(f"No TEST-*.xml files found under {results_directory}") + + identities = Counter() + statuses = Counter({key: 0 for key in STATUS_KEYS}) + duration = Decimal("0") + source_files = [] + + for xml_file in xml_files: + relative_source = xml_file.relative_to(results_directory).as_posix() + source_files.append(relative_source) + try: + root = ET.parse(xml_file).getroot() + except (ET.ParseError, OSError) as error: + raise ManifestError(f"Could not parse {xml_file}: {error}") from error + + testcases = ( + element + for element in root.iter() + if _local_name(element.tag) == "testcase" + ) + for testcase in testcases: + identity = normalize_identity( + lane, testcase.get("classname"), testcase.get("name") + ) + identities[identity] += 1 + statuses[_testcase_status(testcase)] += 1 + duration += _testcase_duration(testcase, relative_source) + + test_count = sum(identities.values()) + if test_count == 0: + raise ManifestError(f"No entries found under {results_directory}") + if statuses["failures"] or statuses["errors"]: + raise ManifestError( + f"Lane {lane} contains {statuses['failures']} failure(s) and " + f"{statuses['errors']} error(s)" + ) + + return { + "schema_version": SCHEMA_VERSION, + "lane": lane, + "successful": True, + "test_count": test_count, + "unique_identity_count": len(identities), + "duration_seconds": float(duration), + "status_counts": {key: statuses[key] for key in STATUS_KEYS}, + "source_files": source_files, + "identity_digest": _identity_digest(identities), + "identities": _identities_as_json(identities), + } + + +def write_json(document, output_file): + """Write one deterministic JSON document.""" + output_file = Path(output_file) + output_file.parent.mkdir(parents=True, exist_ok=True) + with output_file.open("w", encoding="utf-8") as output: + json.dump(document, output, indent=2, sort_keys=True) + output.write("\n") + + +def _load_manifest(manifest_file): + """Load and validate the common fields of one manifest.""" + manifest_file = Path(manifest_file) + try: + with manifest_file.open(encoding="utf-8") as source: + manifest = json.load(source) + except (OSError, json.JSONDecodeError) as error: + raise ManifestError(f"Could not read manifest {manifest_file}: {error}") from error + + if not isinstance(manifest, dict): + raise ManifestError(f"Manifest {manifest_file} must contain a JSON object") + if manifest.get("schema_version") != SCHEMA_VERSION: + raise ManifestError(f"Manifest {manifest_file} has an unsupported schema version") + lane = manifest.get("lane") + if lane not in MANIFEST_LANES: + raise ManifestError(f"Manifest {manifest_file} has invalid lane {lane!r}") + if manifest.get("successful") is not True: + raise ManifestError(f"Manifest {manifest_file} is not successful") + + identities = _identities_from_json(manifest, manifest_file) + test_count = manifest.get("test_count") + if not isinstance(test_count, int) or isinstance(test_count, bool) or test_count <= 0: + raise ManifestError(f"Manifest {manifest_file} has invalid test_count") + if sum(identities.values()) != test_count: + raise ManifestError(f"Manifest {manifest_file} identity counts do not match test_count") + + unique_identity_count = manifest.get("unique_identity_count") + if ( + not isinstance(unique_identity_count, int) + or isinstance(unique_identity_count, bool) + or unique_identity_count <= 0 + or unique_identity_count != len(identities) + ): + raise ManifestError( + f"Manifest {manifest_file} has invalid unique_identity_count" + ) + + statuses = manifest.get("status_counts") + if not isinstance(statuses, dict): + raise ManifestError(f"Manifest {manifest_file} has invalid status_counts") + for key in STATUS_KEYS: + value = statuses.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ManifestError(f"Manifest {manifest_file} has invalid status {key}") + if sum(statuses[key] for key in STATUS_KEYS) != test_count: + raise ManifestError(f"Manifest {manifest_file} statuses do not match test_count") + if statuses["failures"] or statuses["errors"]: + raise ManifestError(f"Manifest {manifest_file} contains failed tests") + + duration = manifest.get("duration_seconds") + if ( + not isinstance(duration, (int, float)) + or isinstance(duration, bool) + or not math.isfinite(duration) + or duration < 0 + ): + raise ManifestError(f"Manifest {manifest_file} has invalid duration_seconds") + + source_files = manifest.get("source_files") + if ( + not isinstance(source_files, list) + or not source_files + or any(not isinstance(source, str) or not source for source in source_files) + or len(set(source_files)) != len(source_files) + ): + raise ManifestError(f"Manifest {manifest_file} has invalid source_files") + + identity_digest = manifest.get("identity_digest") + if identity_digest != _identity_digest(identities): + raise ManifestError(f"Manifest {manifest_file} has invalid identity_digest") + + return lane, manifest, identities + + +def _format_identity_difference(reference, actual): + """Format a bounded explanation of a Counter mismatch.""" + differences = [] + for label, values in (("missing", reference - actual), ("extra", actual - reference)): + for (classname, name), count in sorted(values.items())[:5]: + differences.append(f"{label} {count} x {classname}::{name}") + return "; ".join(differences) + + +def _format_identities(identities): + """Format a bounded identity Counter for an error message.""" + return "; ".join( + f"{count} x {classname}::{name}" + for (classname, name), count in sorted(identities.items())[:5] + ) + + +def _load_split_manifests(manifest_files): + """Load exactly one trustworthy manifest for each split lane.""" + manifest_files = [Path(path) for path in manifest_files] + if len(manifest_files) != len(LANES): + raise ManifestError(f"Expected exactly {len(LANES)} manifests, got {len(manifest_files)}") + + by_lane = {} + counters = {} + for manifest_file in manifest_files: + lane, manifest, identities = _load_manifest(manifest_file) + if lane not in LANES: + raise ManifestError( + f"Expected a split-lane manifest, got lane {lane} from {manifest_file}" + ) + if lane in by_lane: + raise ManifestError(f"Received more than one manifest for lane {lane}") + by_lane[lane] = manifest + counters[lane] = identities + + missing_lanes = set(LANES) - set(by_lane) + if missing_lanes: + raise ManifestError(f"Missing manifest lane(s): {', '.join(sorted(missing_lanes))}") + return by_lane, counters + + +def _combine_counters(counters): + """Combine identity Counters without changing their inputs.""" + combined = Counter() + for counter in counters: + combined.update(counter) + return combined + + +def _lane_summary(manifest, identities): + """Return the evidence retained for one successfully loaded lane.""" + return { + "test_count": manifest["test_count"], + "unique_identity_count": manifest["unique_identity_count"], + "duration_seconds": manifest["duration_seconds"], + "status_counts": manifest["status_counts"], + "source_file_count": len(manifest["source_files"]), + "source_files": manifest["source_files"], + "identity_digest": _identity_digest(identities), + } + + +def reconcile_manifests(manifest_files): + """Require four lanes and reconcile the three database identity multisets.""" + by_lane, counters = _load_split_manifests(manifest_files) + + reference = counters["h2"] + for lane in DATABASE_LANES[1:]: + if counters[lane] != reference: + difference = _format_identity_difference(reference, counters[lane]) + raise ManifestError( + f"Database identity mismatch between h2 and {lane}: {difference}" + ) + + unit_database_overlap = counters["unit"] & reference + if unit_database_overlap: + raise ManifestError( + "Unit/database identity overlap: " + f"{_format_identities(unit_database_overlap)}" + ) + + combined_statuses = { + key: sum(by_lane[lane]["status_counts"][key] for lane in LANES) + for key in STATUS_KEYS + } + lane_summaries = { + lane: _lane_summary(by_lane[lane], counters[lane]) for lane in LANES + } + + return { + "schema_version": SCHEMA_VERSION, + "successful": True, + "database_identities_equal": True, + "unit_database_disjoint": True, + "database_test_count_per_lane": sum(reference.values()), + "database_unique_identity_count": len(reference), + "database_identity_digest": _identity_digest(reference), + "combined_test_count": sum(by_lane[lane]["test_count"] for lane in LANES), + "combined_duration_seconds": float( + sum( + ( + Decimal(str(by_lane[lane]["duration_seconds"])) + for lane in LANES + ), + Decimal("0"), + ) + ), + "combined_status_counts": combined_statuses, + "lanes": lane_summaries, + } + + +def compare_legacy_partition(legacy_manifest_file, split_manifest_files): + """Prove that legacy core:test equals the exact sum of the split lanes.""" + legacy_lane, legacy_manifest, legacy_identities = _load_manifest( + legacy_manifest_file + ) + if legacy_lane != "legacy": + raise ManifestError( + f"Expected a legacy manifest, got lane {legacy_lane} from {legacy_manifest_file}" + ) + + reconciliation = reconcile_manifests(split_manifest_files) + split_manifests, split_counters = _load_split_manifests(split_manifest_files) + split_identities = _combine_counters(split_counters[lane] for lane in LANES) + if legacy_identities != split_identities: + difference = _format_identity_difference(legacy_identities, split_identities) + raise ManifestError(f"Legacy/split identity mismatch: {difference}") + + split_statuses = { + key: sum(split_manifests[lane]["status_counts"][key] for lane in LANES) + for key in STATUS_KEYS + } + return { + "schema_version": SCHEMA_VERSION, + "successful": True, + "partition_equal": True, + "legacy": _lane_summary(legacy_manifest, legacy_identities), + "split": { + "test_count": sum(split_identities.values()), + "unique_identity_count": len(split_identities), + "duration_seconds": reconciliation["combined_duration_seconds"], + "status_counts": split_statuses, + "source_file_count": sum( + len(split_manifests[lane]["source_files"]) for lane in LANES + ), + "source_files": { + lane: split_manifests[lane]["source_files"] for lane in LANES + }, + "identity_digest": _identity_digest(split_identities), + }, + "lanes": reconciliation["lanes"], + } + + +def _create_argument_parser(): + """Create the command-line parser.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest_parser = subparsers.add_parser( + "manifest", help="Create one normalized manifest from Gradle JUnit XML" + ) + manifest_parser.add_argument("--lane", required=True, choices=MANIFEST_LANES) + manifest_parser.add_argument( + "--results", required=True, type=Path, help="Directory containing TEST-*.xml" + ) + manifest_parser.add_argument( + "--output", required=True, type=Path, help="JSON manifest to write" + ) + + reconcile_parser = subparsers.add_parser( + "reconcile", help="Reconcile unit and database lane manifests" + ) + reconcile_parser.add_argument( + "--manifests", required=True, nargs="+", type=Path, help="The four lane manifests" + ) + reconcile_parser.add_argument( + "--output", required=True, type=Path, help="Combined JSON summary to write" + ) + + comparison_parser = subparsers.add_parser( + "compare-legacy", + help="Compare a legacy core:test manifest with the four split lanes", + ) + comparison_parser.add_argument( + "--legacy-manifest", + required=True, + type=Path, + help="Manifest generated from the legacy :core:test results", + ) + comparison_parser.add_argument( + "--split-manifests", + required=True, + nargs="+", + type=Path, + help="The unit, H2, MySQL, and PostgreSQL manifests", + ) + comparison_parser.add_argument( + "--output", required=True, type=Path, help="Partition comparison JSON to write" + ) + return parser + + +def main(argv=None): + """Run the command-line interface.""" + parser = _create_argument_parser() + args = parser.parse_args(argv) + try: + if args.command == "manifest": + document = build_manifest(args.lane, args.results) + elif args.command == "reconcile": + document = reconcile_manifests(args.manifests) + else: + document = compare_legacy_partition( + args.legacy_manifest, args.split_manifests + ) + write_json(document, args.output) + except ManifestError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml new file mode 100644 index 00000000000..7b6bfdd4bbc --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml b/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml new file mode 100644 index 00000000000..e1b8809b57e --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/legacy/TEST-legacy.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml new file mode 100644 index 00000000000..47a84c52d1e --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml new file mode 100644 index 00000000000..dd1aad76ca3 --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml b/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml new file mode 100644 index 00000000000..7c169da3478 --- /dev/null +++ b/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py new file mode 100644 index 00000000000..045f60034e6 --- /dev/null +++ b/dev/ci/tests/test_core_test_identity.py @@ -0,0 +1,407 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import copy +from collections import Counter +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).parents[1] / "core_test_identity.py" +SPEC = importlib.util.spec_from_file_location("core_test_identity", SCRIPT_PATH) +core_test_identity = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(core_test_identity) + +FIXTURES = Path(__file__).parent / "fixtures" / "core_test_identity" + + +def identity_counter(manifest): + """Return the manifest identities in their natural Counter form.""" + return Counter( + { + (record["classname"], record["name"]): record["count"] + for record in manifest["identities"] + } + ) + + +def write_report(directory, testcases): + """Write a minimal Gradle-compatible JUnit XML report.""" + directory.mkdir(parents=True, exist_ok=True) + (directory / "TEST-fixture.xml").write_text( + "\n" + f"{testcases}\n", + encoding="utf-8", + ) + + +class TestCoreTestIdentity(unittest.TestCase): + def test_manifest_normalizes_database_identities_as_multisets(self): + manifests = { + lane: core_test_identity.build_manifest(lane, FIXTURES / lane) + for lane in core_test_identity.LANES + } + + database_counters = [ + identity_counter(manifests[lane]) + for lane in core_test_identity.DATABASE_LANES + ] + self.assertEqual(database_counters[0], database_counters[1]) + self.assertEqual(database_counters[0], database_counters[2]) + self.assertEqual( + database_counters[0][ + ( + "org.apache.gravitino.stats.storage." + "TestJdbcPartitionStatisticStorageIT$BackendTest", + "writesPartitionStats()[INDEX]", + ) + ], + 2, + ) + self.assertIn( + ( + "org.apache.gravitino.TestCatalogMetaService", + "testCreateCatalog()[BACKEND Backend]", + ), + database_counters[0], + ) + self.assertIn( + ( + "org.apache.gravitino.TestCatalogMetaService", + "testDropCatalog()[BACKEND Backend]", + ), + database_counters[0], + ) + self.assertIn( + ("org.apache.gravitino.BackendTokenTest", "roundTrip[BACKEND]"), + database_counters[0], + ) + self.assertIn( + ( + "org.apache.gravitino.UnmarkedBackendTest", + "unmarkedSharedCase()", + ), + database_counters[0], + ) + + unit = manifests["unit"] + self.assertEqual(unit["test_count"], 3) + self.assertEqual(unit["duration_seconds"], 0.7) + self.assertEqual(unit["status_counts"]["passed"], 2) + self.assertEqual(unit["status_counts"]["skipped"], 1) + self.assertEqual(unit["source_files"], ["TEST-unit.xml"]) + self.assertIn( + ( + "org.apache.gravitino.TestH2ExceptionConverter", + "testH2Converter()", + ), + identity_counter(unit), + ) + self.assertIn( + ( + "org.apache.gravitino.storage.relational.mapper.provider.postgresql." + "TestCatalogMetaPostgreSQLProvider", + "testInsertSql()", + ), + identity_counter(unit), + ) + + def test_lane_validation_rejects_explicit_wrong_backend_markers(self): + cases = ( + ( + "unit", + '', + "Unit test result contains an explicit backend marker", + ), + ( + "h2", + '', + "foreign backend marker", + ), + ( + "postgresql", + '', + "foreign backend marker", + ), + ) + for lane, testcase, message in cases: + with self.subTest(lane=lane), tempfile.TemporaryDirectory() as temp_dir: + results = Path(temp_dir) + write_report(results, testcase) + with self.assertRaisesRegex(core_test_identity.ManifestError, message): + core_test_identity.build_manifest(lane, results) + + def test_manifest_fails_closed_on_missing_or_untrustworthy_results(self): + with tempfile.TemporaryDirectory() as temp_dir: + with self.assertRaisesRegex(core_test_identity.ManifestError, "No TEST-"): + core_test_identity.build_manifest("unit", temp_dir) + + invalid_cases = ( + ("", "No "), + ("", "Could not parse"), + ( + '', + "contains 1 failure", + ), + ( + '', + "and 1 error", + ), + ) + for xml, message in invalid_cases: + with self.subTest(message=message), tempfile.TemporaryDirectory() as temp_dir: + results = Path(temp_dir) + (results / "TEST-invalid.xml").write_text(xml, encoding="utf-8") + with self.assertRaisesRegex(core_test_identity.ManifestError, message): + core_test_identity.build_manifest("unit", results) + + def test_reconcile_emits_combined_timing_and_identity_summary(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + summary = core_test_identity.reconcile_manifests(manifest_files) + + self.assertTrue(summary["successful"]) + self.assertTrue(summary["database_identities_equal"]) + self.assertTrue(summary["unit_database_disjoint"]) + self.assertEqual(summary["database_test_count_per_lane"], 6) + self.assertEqual(summary["database_unique_identity_count"], 5) + self.assertEqual(summary["combined_test_count"], 21) + self.assertEqual(summary["combined_duration_seconds"], 7.3) + self.assertEqual(summary["combined_status_counts"]["skipped"], 2) + self.assertEqual(set(summary["lanes"]), set(core_test_identity.LANES)) + self.assertEqual( + summary["lanes"]["h2"]["source_files"], ["TEST-backend.xml"] + ) + + def test_reconcile_requires_exactly_four_matching_lanes(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifests = {} + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifests[lane] = manifest_file + + with self.assertRaisesRegex(core_test_identity.ManifestError, "exactly 4"): + core_test_identity.reconcile_manifests(list(manifests.values())[:3]) + + mismatched = copy.deepcopy( + core_test_identity.build_manifest("mysql", FIXTURES / "mysql") + ) + mismatched["identities"][0]["name"] += "-different" + mismatched["identity_digest"] = core_test_identity._identity_digest( + identity_counter(mismatched) + ) + mismatched_file = output_directory / "mysql-mismatched.json" + core_test_identity.write_json(mismatched, mismatched_file) + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Database identity mismatch" + ): + core_test_identity.reconcile_manifests( + [ + manifests["unit"], + manifests["h2"], + mismatched_file, + manifests["postgresql"], + ] + ) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "more than one manifest for lane h2" + ): + core_test_identity.reconcile_manifests( + [ + manifests["unit"], + manifests["h2"], + manifests["h2"], + manifests["postgresql"], + ] + ) + + def test_reconcile_rejects_unit_database_overlap(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + overlapping_results = output_directory / "overlapping-unit-results" + write_report( + overlapping_results, + '', + ) + + manifest_files = [] + for lane in core_test_identity.LANES: + results = ( + overlapping_results if lane == "unit" else FIXTURES / lane + ) + manifest = core_test_identity.build_manifest(lane, results) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Unit/database identity overlap" + ): + core_test_identity.reconcile_manifests(manifest_files) + + def test_reconcile_rejects_tampered_identity_evidence(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + if lane == "h2": + manifest["identity_digest"] = "0" * 64 + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + manifest_files.append(manifest_file) + + with self.assertRaisesRegex( + core_test_identity.ManifestError, "invalid identity_digest" + ): + core_test_identity.reconcile_manifests(manifest_files) + + def test_compare_legacy_partition_requires_exact_split_multiset(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + split_manifest_files = [] + for lane in core_test_identity.LANES: + manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) + manifest_file = output_directory / f"{lane}.json" + core_test_identity.write_json(manifest, manifest_file) + split_manifest_files.append(manifest_file) + + legacy = core_test_identity.build_manifest( + "legacy", FIXTURES / "legacy" + ) + legacy_file = output_directory / "legacy.json" + core_test_identity.write_json(legacy, legacy_file) + comparison = core_test_identity.compare_legacy_partition( + legacy_file, split_manifest_files + ) + + self.assertTrue(comparison["successful"]) + self.assertTrue(comparison["partition_equal"]) + self.assertEqual(comparison["legacy"]["test_count"], 21) + self.assertEqual(comparison["split"]["test_count"], 21) + self.assertEqual( + comparison["legacy"]["identity_digest"], + comparison["split"]["identity_digest"], + ) + self.assertEqual( + comparison["legacy"]["source_files"], ["TEST-legacy.xml"] + ) + + mismatched = copy.deepcopy(legacy) + mismatched["identities"][0]["name"] += "-different" + mismatched["identity_digest"] = core_test_identity._identity_digest( + identity_counter(mismatched) + ) + mismatched_file = output_directory / "legacy-mismatched.json" + core_test_identity.write_json(mismatched, mismatched_file) + with self.assertRaisesRegex( + core_test_identity.ManifestError, "Legacy/split identity mismatch" + ): + core_test_identity.compare_legacy_partition( + mismatched_file, split_manifest_files + ) + + def test_cli_writes_manifest_and_reconciliation_output(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_directory = Path(temp_dir) + manifest_files = [] + for lane in core_test_identity.LANES: + manifest_file = output_directory / f"{lane}.json" + return_code = core_test_identity.main( + [ + "manifest", + "--lane", + lane, + "--results", + str(FIXTURES / lane), + "--output", + str(manifest_file), + ] + ) + self.assertEqual(return_code, 0) + self.assertTrue(manifest_file.is_file()) + manifest_files.append(manifest_file) + + summary_file = output_directory / "summary.json" + return_code = core_test_identity.main( + [ + "reconcile", + "--manifests", + *(str(path) for path in manifest_files), + "--output", + str(summary_file), + ] + ) + self.assertEqual(return_code, 0) + with summary_file.open(encoding="utf-8") as source: + summary = json.load(source) + self.assertTrue(summary["database_identities_equal"]) + + legacy_manifest_file = output_directory / "legacy.json" + return_code = core_test_identity.main( + [ + "manifest", + "--lane", + "legacy", + "--results", + str(FIXTURES / "legacy"), + "--output", + str(legacy_manifest_file), + ] + ) + self.assertEqual(return_code, 0) + + comparison_file = output_directory / "legacy-comparison.json" + return_code = core_test_identity.main( + [ + "compare-legacy", + "--legacy-manifest", + str(legacy_manifest_file), + "--split-manifests", + *(str(path) for path in manifest_files), + "--output", + str(comparison_file), + ] + ) + self.assertEqual(return_code, 0) + with comparison_file.open(encoding="utf-8") as source: + comparison = json.load(source) + self.assertTrue(comparison["partition_equal"]) + + +if __name__ == "__main__": + unittest.main() From 00d8f0d48b5cb04922abee2712248ca961f62402 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:52:01 +0000 Subject: [PATCH 02/30] fix(ci): address #13517 review nits from jerryshao - Revert build.yml timeout from 90 to 120 minutes; 90 was too tight given the MySQL lane alone measures ~69 min locally with no margin. - Rename the coreH2Test/coreMySQLTest/corePostgreSQLTest inputs.property key from includeDockerTaggedTests to coreTestIncludesDockerTaggedTests, distinguishing it from the unrelated extensions.extraProperties[includeDockerTaggedTests] flag consumed by root build.gradle.kts's shared test setup. - Drop the hardcoded "()" from BackendTestExtension's getDisplayName(): @TestTemplate methods can declare real parameters, so an empty parameter list misrepresented the method's signature in JUnit XML/HTML reports. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 5 ++++- .../gravitino/storage/relational/BackendTestExtension.java | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 07abc95f71b..2ee6a5e5e1d 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -131,7 +131,10 @@ fun registerCoreTestTask( inputs.property("coreTestSuite", backend ?: "unit") inputs.property("coreTestBackend", backend ?: "none") - inputs.property("includeDockerTaggedTests", backend != null) + // Distinct from the extensions.extraProperties["includeDockerTaggedTests"] flag set below, + // which is a different mechanism (read by root build.gradle.kts's shared test-environment + // setup to decide JUnit tag filtering) - this is only a Gradle up-to-date-check input. + inputs.property("coreTestIncludesDockerTaggedTests", backend != null) reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) reports.html.outputLocation.set( rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index f59364cef7e..d442fb3113e 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 @@ -146,7 +146,10 @@ public BackendInvocationContext(String testMethodName, String backendType) { @Override public String getDisplayName(int invocationIndex) { - return String.format("%s()[%s Backend]", testMethodName, backendType.toUpperCase()); + // No trailing "()" here: @TestTemplate methods can declare parameters (e.g. an injected + // DatabaseTestContext), and a hardcoded empty parameter list would misrepresent the + // method's actual signature in JUnit XML/HTML reports. + return String.format("%s[%s Backend]", testMethodName, backendType.toUpperCase()); } @Override From 57e3f4f423ad837a49b8c336e19a2a2d10647fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:51:35 +0000 Subject: [PATCH 03/30] improvement(test): replace per-backend exclude-tags with @DatabaseTest + BackendLaneCondition The three excludeTags(otherTwoBackends) clauses in registerCoreTestTask allowed two silent failure modes with no build-time check: a class carrying two backend tags was excluded from every lane, and a class missing its restriction tag ran redundantly in all three. Replace them with a single typed source of truth: - DatabaseBackend: the three core database backends. - DatabaseTest: annotation declaring backends() (defaults to all three), meta- annotated with the lane-inclusion tag and @ExtendWith(BackendLaneCondition). - BackendLaneCondition: an ExecutionCondition that reads the lane's gravitino.core.test.backend system property (already published by registerCoreTestTask) and enables/disables the class accordingly, producing a visible, reported skip rather than silent exclusion from Gradle's discovery. - TestDatabaseTestClassification: a build-time reflection check (runs in coreUnitTest, no database, no Docker) asserting no @DatabaseTest(backends={}) and no leftover raw per-backend @Tag usages anywhere in core. core/build.gradle.kts's three-way excludeTags table collapses to a single includeTags(coreDatabaseTestTag); backend restriction now lives entirely in test source. Migrated all three existing usage patterns: AbstractEntityStorageTest (parameter-driven multi-backend via storageProvider()/BackendTestSelector), TestJDBCBackend (@TestTemplate-driven via BackendTestExtension), and TestJdbcPartitionStatisticStorageIT's three single-backend @Nested classes. Verified via TestBackendLaneCondition (direct unit coverage) and a real :core:coreH2Test run before/after the change: identical 520 executed tests and outcomes: the only delta is 2 classes (14 tests) that were previously excluded from Gradle's own discovery and now appear as an explicit, reported skip. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015k3E9XykKTzbTWaBPwqTyE --- core/build.gradle.kts | 18 +-- .../TestJdbcPartitionStatisticStorageIT.java | 15 ++- .../storage/AbstractEntityStorageTest.java | 6 +- .../relational/BackendLaneCondition.java | 64 +++++++++ .../storage/relational/DatabaseBackend.java | 48 +++++++ .../storage/relational/DatabaseTest.java | 64 +++++++++ .../relational/TestBackendLaneCondition.java | 122 ++++++++++++++++++ .../TestDatabaseTestClassification.java | 116 +++++++++++++++++ .../storage/relational/TestJDBCBackend.java | 5 +- 9 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestBackendLaneCondition.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestDatabaseTestClassification.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 2ee6a5e5e1d..a6b7ca71532 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -108,10 +108,11 @@ artifacts { add("testArtifacts", testJar) } +// Must match DatabaseTest.TAG (core/src/test/java/.../storage/relational/DatabaseTest.java) - +// build scripts can't see a project's own test-source classes at configuration time, so this +// string is kept in sync by hand; TestDatabaseTestClassificationCheck pins the literal. val coreDatabaseTestTag = "gravitino-core-database-test" -val coreH2TestTag = "gravitino-core-h2-test" -val coreMySQLTestTag = "gravitino-core-mysql-test" -val corePostgreSQLTestTag = "gravitino-core-postgresql-test" +val coreSupportedBackends = setOf("h2", "mysql", "postgresql") val coreTestBackendProperty = "gravitino.core.test.backend" fun registerCoreTestTask( @@ -148,17 +149,16 @@ fun registerCoreTestTask( if (backend == null) { excludeTags(coreDatabaseTestTag, "gravitino-docker-test") } else { + // Lane membership only; which backend(s) a @DatabaseTest class actually runs under is + // decided per-class by BackendLaneCondition, reading the system property set below. includeTags(coreDatabaseTestTag) - when (backend) { - "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) - "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) - "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) - else -> throw GradleException("Unsupported core test backend: $backend") - } } } if (backend != null) { + if (backend !in coreSupportedBackends) { + throw GradleException("Unsupported core test backend: $backend") + } systemProperty(coreTestBackendProperty, backend) extensions.extraProperties["includeDockerTaggedTests"] = true diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index 42b5938596a..c5ed94fc53d 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -58,6 +58,8 @@ import org.apache.gravitino.stats.PartitionStatisticsUpdate; import org.apache.gravitino.stats.StatisticValue; import org.apache.gravitino.stats.StatisticValues; +import org.apache.gravitino.storage.relational.DatabaseBackend; +import org.apache.gravitino.storage.relational.DatabaseTest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; @@ -85,8 +87,8 @@ *
  • Database-specific SQL syntax (MySQL ON DUPLICATE KEY vs PostgreSQL ON CONFLICT) * */ -@Tag("gravitino-core-database-test") -@Tag("gravitino-docker-test") +// Not itself a @DatabaseTest: this class holds no @Test methods of its own, only the @Nested +// classes below, each of which declares its own single-backend @DatabaseTest. public class TestJdbcPartitionStatisticStorageIT { private static final Logger LOG = @@ -94,9 +96,8 @@ public class TestJdbcPartitionStatisticStorageIT { /** * Abstract base class containing all test logic. Each database-specific test class extends this - * and implements the database setup. + * and implements the database setup, declaring its own single-backend {@code @DatabaseTest}. */ - @Tag("gravitino-core-database-test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract static class BaseJdbcPartitionStatisticStorageTest { @@ -587,7 +588,7 @@ protected void cleanupAllStatistics() throws IOException { /** MySQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-mysql-test") + @DatabaseTest(backends = DatabaseBackend.MYSQL) @Tag("gravitino-docker-test") static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -658,7 +659,7 @@ private void createMySQLSchema() throws SQLException { /** PostgreSQL-specific tests using Docker container. */ @Nested - @Tag("gravitino-core-postgresql-test") + @DatabaseTest(backends = DatabaseBackend.POSTGRESQL) @Tag("gravitino-docker-test") static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest { @@ -732,7 +733,7 @@ private void createPostgreSQLSchema() throws SQLException { /** H2-specific tests using embedded in-memory database. */ @Nested - @Tag("gravitino-core-h2-test") + @DatabaseTest(backends = DatabaseBackend.H2) static class H2Test extends BaseJdbcPartitionStatisticStorageTest { private static final String H2_JDBC_URL = diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index 2fb2641646c..879ce549e95 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -97,6 +97,7 @@ import org.apache.gravitino.meta.UserEntity; import org.apache.gravitino.rel.types.Type; import org.apache.gravitino.storage.relational.BackendTestSelector; +import org.apache.gravitino.storage.relational.DatabaseTest; import org.apache.gravitino.storage.relational.RelationalBackend; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.RelationalGarbageCollector; @@ -108,12 +109,13 @@ import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Tag; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@Tag("gravitino-core-database-test") +// No backends() given: every concrete subclass here runs against every backend (h2/mysql/ +// postgresql), selected per-invocation by storageProvider()'s own BackendTestSelector filtering. +@DatabaseTest abstract class AbstractEntityStorageTest { protected static final Logger LOG = LoggerFactory.getLogger(AbstractEntityStorageTest.class); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java new file mode 100644 index 00000000000..69bf9791638 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/BackendLaneCondition.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * Restricts a {@link DatabaseTest} class to the backends it declares. Replaces the three {@code + * excludeTags(...)} clauses {@code registerCoreTestTask} used to hand-maintain per lane: with this + * condition, {@code core/build.gradle.kts} only needs {@code includeTags(coreDatabaseTestTag)} plus + * the {@code gravitino.core.test.backend} system property each lane already publishes for {@link + * BackendTestSelector}. + */ +public class BackendLaneCondition implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + Optional annotation = + context + .getTestClass() + .flatMap(c -> AnnotationSupport.findAnnotation(c, DatabaseTest.class)); + if (!annotation.isPresent()) { + return ConditionEvaluationResult.enabled("Not a @DatabaseTest class"); + } + + Optional selectedBackend = BackendTestSelector.selectedBackend(); + if (!selectedBackend.isPresent()) { + // No lane-specific backend was selected (e.g. the legacy :core:test invocation) - run + // under every backend this class declares, same as before this condition existed. + return ConditionEvaluationResult.enabled("No lane backend selected"); + } + + boolean applicable = + Arrays.stream(annotation.get().backends()) + .anyMatch(backend -> backend.propertyValue().equals(selectedBackend.get())); + + return applicable + ? ConditionEvaluationResult.enabled("Applies to backend " + selectedBackend.get()) + : ConditionEvaluationResult.disabled( + "@DatabaseTest does not declare backend " + selectedBackend.get()); + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java new file mode 100644 index 00000000000..02c94ce83b9 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseBackend.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +/** + * The core database backends exercised by {@code core/build.gradle.kts}'s database test lanes. The + * enum constant name is what a test declares in {@link DatabaseTest#backends()}; {@link + * #propertyValue()} is the wire value the lane tasks publish via the {@code + * gravitino.core.test.backend} system property and {@link BackendTestSelector} reads back. + */ +public enum DatabaseBackend { + H2("h2"), + MYSQL("mysql"), + POSTGRESQL("postgresql"); + + private final String propertyValue; + + DatabaseBackend(String propertyValue) { + this.propertyValue = propertyValue; + } + + /** + * Returns the value this backend is identified by in the {@code gravitino.core.test.backend} + * system property. + * + * @return the property value + */ + public String propertyValue() { + return propertyValue; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java new file mode 100644 index 00000000000..4b4b8e93dd1 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Marks a test class as a core database test and declares which backends it applies to. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Because {@code dev/ci/core_test_identity.py}'s {@code reconcile} step (invoked from {@code + * .github/workflows/build.yml}, not from {@code core/build.gradle.kts}) requires the + * h2/mysql/postgresql lanes to run the exact same set of normalized test identities, a single- or + * multi- (but not all-) backend class is only CI-legal as a normalized sibling of matching classes + * in the other backend(s) it omits - see {@code TestJdbcPartitionStatisticStorageIT}'s {@code + * H2Test}/{@code MySQLTest}/{@code PostgreSQLTest} nested classes for the pattern this currently + * requires. */ public final class CoreBackend { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java index 416e7b5cdaf..2aeb7cc0f93 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java @@ -57,7 +57,13 @@ private static Stream annotationsAndExpectedTags() { }); } - @ParameterizedTest + // Default JUnit display names include argument toStrings - for expectedTags that would print a + // tag string like "[gravitino-core-h2-test]" into this class's own JUnit XML, which is itself + // part of the coreUnitTest lane, and core_test_identity.py's manifest step rejects any standalone + // backend token there as a foreign-lane marker. Naming on {0} (the class under test) only avoids + // that: its simple name (e.g. H2Annotated) has no such token, since "h2"/"mysql"/"postgresql" is + // never followed by a non-letter there. + @ParameterizedTest(name = "{index}: {0}") @MethodSource("annotationsAndExpectedTags") void testExpandsToExpectedTags(Class annotatedClass, List expectedTags) { assertEquals(expectedTags, tagsOf(annotatedClass)); diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md index 20e0792dfc6..7c590d07ea7 100644 --- a/design-docs/testing/core-db-split.md +++ b/design-docs/testing/core-db-split.md @@ -258,6 +258,24 @@ Contributor-facing documentation for the lanes lives in this document (Appendix `docs/how-to-test.md`, which is a repo-wide doc unrelated to this split (it only documents the root `./gradlew test` task and does not mention `core` or its lanes) - no edit there was needed. +Found and fixed during review, before merge: `TestCoreDatabaseLaneAnnotations`'s +`@ParameterizedTest` used the default display name, which embeds each case's expected-tags +argument (e.g. `[gravitino-core-h2-test]`) into its own JUnit XML - since that test carries no +`@CoreBackend.*` annotation itself, it runs in `coreUnitTest`, and `manifest`'s unit-lane check +rejects any standalone backend token there as a foreign marker. Reproduced directly +(`./gradlew :core:coreUnitTest --tests ...TestCoreDatabaseLaneAnnotations` then `manifest --lane +unit` failed with "contains an explicit backend marker ['h2']"), fixed by naming on the class +under test only (`@ParameterizedTest(name = "{index}: {0}")`), re-verified clean, and confirmed +by scanning all 1968 `coreUnitTest` testcases through the real `normalize_identity` function with +zero marker errors. + +**Optional follow-up, not required to ship:** `STATS_BACKEND_CLASS_RE` (B.2/B.3) is hard-coded to +one outer class name, so it does not generalize to a second nested-per-backend class without +editing the regex. Appendix C now tells contributors to prefer `@CoreBackend.All` and flags this +constraint explicitly rather than silently teaching a pattern that fails `reconcile`; generalizing +the regex to any outer class name is a real improvement but touches CI-wired parsing logic and +needs its own fixtures/tests, so it was left out of this pass. + **Remaining, in scope, not yet done:** - Reply to the open review thread on #13517 and update the PR description to match the final @@ -620,20 +638,26 @@ Pick the annotation that matches where the class needs to run, from `org.apache.gravitino.storage.relational.CoreBackend`: ```java -@CoreBackend.H2 // only against H2 -public class MyPlainDbTest { ... } - -@CoreBackend.H2 -@CoreBackend.MySQL // against H2 and MySQL, not PostgreSQL -public class MyPartialTest { ... } - -@CoreBackend.All // against all three backends +@CoreBackend.All // against all three backends - the default choice public abstract class MyMultiBackendTest { ... } + +@CoreBackend.H2 // only against H2 - a genuinely H2-only test +public class MyH2OnlyTest { ... } ``` -No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. See -A.1's usage-pattern table for the three concrete shapes already in the codebase -(parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). +No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. + +**Use `@CoreBackend.All` unless the class is genuinely single-backend.** Stacking a subset (e.g. +`@CoreBackend.H2 @CoreBackend.MySQL`) compiles and each lane it names runs the class, but CI's +`reconcile` step then requires a *normalized sibling* in every backend lane it omits (Appendix +A.1/A.4, `CoreBackend`'s Javadoc) - today the only shape that satisfies that is one `@Nested` +class per backend under a shared outer class, following +`TestJdbcPartitionStatisticStorageIT`'s `H2Test`/`MySQLTest`/`PostgreSQLTest` pattern exactly +(`core_test_identity.py`'s normalization is hard-coded to that one outer class name - see B.3). A +standalone partial-backend class without that sibling structure passes locally in the lanes it +runs in and then fails `reconcile` in CI with "Database identity mismatch". If in doubt, use +`@CoreBackend.All`. See A.1's usage-pattern table for the three concrete shapes already in the +codebase (parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). ### Checking where a class lands, before running anything From 9a027efcf0a486bd4d498e22988d6e5aad2507e2 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 30 Sep 2026 10:35:10 -0700 Subject: [PATCH 13/30] improvement(test): fail when a core test class runs in no lane A class tagged gravitino-docker-test with no CoreBackend tag is excluded from coreUnitTest and from every database lane, and reconcile only compares the database lanes with each other, so it could drop out of all three at once with CI green. TestCoreLaneMembership runs in coreUnitTest and fails, naming each such class. --- .../relational/TestCoreLaneMembership.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java 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..0e3083f6a28 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.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. {@code core_test_identity.py reconcile} only compares the database lanes with each + * other, so a class that leaves all three at once would otherwise go unnoticed. + */ +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); + } + + // A class JUnit would run: concrete, with at least one @Test/@ParameterizedTest/... method + // (all meta-annotated with @Testable), own or inherited. Excludes containers such as + // TestJdbcPartitionStatisticStorageIT, whose tests live in its static member classes. + 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 @Nested classes, which + // JUnit applies to them. Static member classes are discovered as top-level classes and don't. + 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(); + } +} From 81ae97d867b02893ad99ee119dda24d4c7091749 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 30 Sep 2026 10:39:44 -0700 Subject: [PATCH 14/30] docs(build): note that includeDockerTaggedTests is read in doFirst The root setTestEnvironment reads the flag inside param.doFirst, at execution time, after every register/configureEach action has run, so the core database lanes keep their Docker-tagged tests regardless of configuration order. Comment both ends so the read stays in doFirst. --- build.gradle.kts | 2 ++ core/build.gradle.kts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 059e2f21690..ea7cd68e2b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -514,6 +514,8 @@ 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 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 1b9ade0dee6..3d1ea6735c3 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -179,6 +179,8 @@ fun registerCoreTestTask( if (backend != null) { 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 // Database tests mutate process-wide state and must remain sequential within each lane. From 64c0c4838bd3e5a29a8c75e18201ddc4cc923aaf Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 30 Sep 2026 10:45:07 -0700 Subject: [PATCH 15/30] docs(build): correct when CI realizes :core:test Only the build suite excludes :core:test. BackendIT's others shard still realizes it through the root test task under -PskipTests, where it matches nothing in core, so the deprecation warning can appear there. --- core/build.gradle.kts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3d1ea6735c3..7ac057bcfc5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -256,9 +256,10 @@ tasks.named("jacocoTestReport") { // above (coreUnitTest / coreH2Test / coreMySQLTest / corePostgreSQLTest), never :core:test - // it predates the lane split and does not correspond to any CI lane. Check where a class runs // with `./gradlew :core:coreTestLaneOf -PclassName=...` instead of guessing. -// - CI CUJ: no change needed here. CI never invokes :core:test - dev/ci/test-shards.sh emits -// `-x :core:test` for the `others` shard, so the warning below only ever fires for a developer -// running it directly. +// - CI CUJ: no change needed here. The `build` suite never runs :core:test - dev/ci/test-shards.sh +// emits `-x :core:test` for its `others` shard. BackendIT's `others` shard still realizes it +// via the root `test` task under -PskipTests, where the `**/integration/test/**` filter matches +// nothing in core, so the warning below can also appear in those logs. tasks.test { val policiesDocument = rootProject.file("docs/policies.md") inputs.file(policiesDocument) From 78b5cf71596f6abfdda948220ef8d8fe972dcfbd Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 30 Sep 2026 10:48:50 -0700 Subject: [PATCH 16/30] improvement(test): report member classes in coreTestLaneOf For a class with no tests of its own, such as TestJdbcPartitionStatisticStorageIT, coreTestLaneOf now reports each member test class and its lanes instead of warning that the class runs nowhere. Non-static @Nested classes also pick up their enclosing classes' tags. TestCoreLaneMembership reuses the same helpers. --- .../storage/relational/CoreTestLaneOf.java | 78 +++++++++++++++---- .../relational/TestCoreLaneMembership.java | 37 +-------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java index a9759a37bf3..4c1aca81f5d 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java @@ -18,23 +18,32 @@ */ package org.apache.gravitino.storage.relational; +import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +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.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; /** * A local, discovery-only check: prints which core database test lane(s) a class runs in, from its * tags, without running anything. Reads a class's tags with the same lookup the Jupiter engine uses * at discovery time (see {@link CoreBackend}), so the answer matches what {@code - * core/build.gradle.kts}'s lane tasks would actually do. + * core/build.gradle.kts}'s lane tasks would actually do. A class with no tests of its own, such as + * {@code TestJdbcPartitionStatisticStorageIT}, is reported through its member test classes. * *

    Run with {@code ./gradlew :core:coreTestLaneOf -PclassName=}. */ public final class CoreTestLaneOf { + static final String DOCKER_TAG = "gravitino-docker-test"; + private CoreTestLaneOf() {} /** @@ -58,11 +67,55 @@ public static void main(String[] args) { return; } - Set tags = - AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() - .map(Tag::value) - .collect(Collectors.toSet()); + if (declaresTests(testClass)) { + report(testClass); + return; + } + + List> members = new ArrayList<>(); + Arrays.stream(testClass.getDeclaredClasses()) + .filter(CoreTestLaneOf::declaresTests) + .sorted(Comparator.comparing(Class::getName)) + .forEach(members::add); + if (members.isEmpty()) { + System.out.println(args[0] + " has no tests of its own or in its member classes."); + return; + } + System.out.println(args[0] + " has no tests of its own; reporting its member test classes:"); + members.forEach(CoreTestLaneOf::report); + } + /** + * Whether JUnit would run {@code c}: concrete, with at least one {@code @Test}-style method (all + * meta-annotated with {@link Testable}), own or inherited. + */ + 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. + */ + 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 void report(Class c) { + String name = c.getName(); + Set tags = effectiveTags(c); List lanes = new ArrayList<>(); if (tags.contains(CoreBackend.H2_TAG)) { lanes.add("coreH2Test"); @@ -74,20 +127,17 @@ public static void main(String[] args) { lanes.add("corePostgreSQLTest"); } - System.out.println(args[0] + " tags: " + tags); + System.out.println(name + " tags: " + tags); if (!lanes.isEmpty()) { - System.out.println(args[0] + " runs in: " + String.join(", ", lanes)); - return; - } - - if (tags.contains("gravitino-docker-test")) { + System.out.println(name + " runs in: " + String.join(", ", lanes)); + } else if (tags.contains(DOCKER_TAG)) { System.out.println( - args[0] + name + " carries gravitino-docker-test but no backend tag - it will NOT run in ANY" + " lane. Add @CoreBackend.H2/@CoreBackend.MySQL/@CoreBackend.PostgreSQL or" + " @CoreBackend.All."); } else { - System.out.println(args[0] + " carries no backend tag - runs in coreUnitTest."); + System.out.println(name + " carries no backend tag - runs in coreUnitTest."); } } } 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 index 0e3083f6a28..2edc976d781 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java @@ -20,17 +20,11 @@ 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; /** @@ -41,7 +35,6 @@ */ 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); @@ -51,13 +44,13 @@ void everyDockerTaggedTestClassHasABackendLane() { List orphans = ReflectionSupport.findAllClassesInPackage( "org.apache.gravitino", - c -> coreTestClasses.equals(codeLocation(c)) && declaresTests(c), + c -> coreTestClasses.equals(codeLocation(c)) && CoreTestLaneOf.declaresTests(c), name -> true) .stream() .filter( c -> { - Set tags = effectiveTags(c); - return tags.contains(DOCKER_TAG) + Set tags = CoreTestLaneOf.effectiveTags(c); + return tags.contains(CoreTestLaneOf.DOCKER_TAG) && tags.stream().noneMatch(BACKEND_TAGS::contains); }) .map(Class::getName) @@ -71,30 +64,6 @@ void everyDockerTaggedTestClassHasABackendLane() { + orphans); } - // A class JUnit would run: concrete, with at least one @Test/@ParameterizedTest/... method - // (all meta-annotated with @Testable), own or inherited. Excludes containers such as - // TestJdbcPartitionStatisticStorageIT, whose tests live in its static member classes. - 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 @Nested classes, which - // JUnit applies to them. Static member classes are discovered as top-level classes and don't. - 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 From 05511e48ba36bd435e63a9d3b32081adfcb1de93 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 30 Sep 2026 10:49:28 -0700 Subject: [PATCH 17/30] docs(design): note the orphan-lane guard in the core DB split doc TestCoreLaneMembership now fails coreUnitTest for a Docker-tagged class with no backend tag, so the doc no longer describes that case as unguarded. --- design-docs/testing/core-db-split.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md index 7c590d07ea7..4c150c8555b 100644 --- a/design-docs/testing/core-db-split.md +++ b/design-docs/testing/core-db-split.md @@ -45,12 +45,12 @@ Concretely: These are deliberate exclusions, not oversights. -- **No CI guard for the orphan case.** A class that carries `gravitino-docker-test` but no - `@CoreBackend.*` annotation is excluded from `coreUnitTest` (by the Docker tag) and from every - backend lane (no backend tag), so it runs nowhere. The design does not add a CI step that - scans for this. Mitigation is developer self-service: `./gradlew :core:coreTestLaneOf` - prints an explicit warning for exactly this shape, and the build script comment on - `coreBackendTestTags` documents it. +- **A unit-test guard, not a CI step, for the orphan case.** A class that carries + `gravitino-docker-test` but no `@CoreBackend.*` annotation is excluded from `coreUnitTest` (by + the Docker tag) and from every backend lane (no backend tag), so it would run nowhere. + `TestCoreLaneMembership` runs in `coreUnitTest` and fails, naming each such class. Keeping every + test running is still the contributor's job; `./gradlew :core:coreTestLaneOf` lets them check a + class locally. - **No cross-lane report aggregation.** Each lane writes its own JUnit XML, HTML report, and JaCoCo `.exec` under a lane-specific path. CI uploads them as four separate evidence artifacts and only the JaCoCo data is merged (for coverage). There is no tool that merges the four JUnit @@ -215,9 +215,9 @@ and included in `coreH2Test`, that the manifest step passes on the lane output, ## Q6. What are the risks? -- **Orphan classes run nowhere and nothing in CI says so.** A `gravitino-docker-test` class with - no `@CoreBackend.*` annotation is dropped by every lane. Accepted by design (Q2); mitigated by - `coreTestLaneOf`'s explicit warning and by documentation, not enforcement. +- **Orphan classes would run nowhere.** A `gravitino-docker-test` class with no + `@CoreBackend.*` annotation is dropped by every lane. Mitigated by `TestCoreLaneMembership`, + which fails `coreUnitTest` for such a class (Q2), and by `coreTestLaneOf` for local checks. - **Tag string drift.** `CoreBackend.H2_TAG` / `MYSQL_TAG` / `POSTGRESQL_TAG` must stay equal to the values in `coreBackendTestTags`. They are declared in two places (Kotlin build script and Java test source) with no shared source. Mitigated by a comment on each side naming the other; From f728749a3e00be70eeb576c855419d377cc31ebb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:53:25 +0000 Subject: [PATCH 18/30] ci: run core database test lanes in their own build.yml jobs Wire coreUnitTest/coreH2Test/coreMySQLTest/corePostgreSQLTest into a core-test matrix job in build.yml directly, so the PR no longer depends on the Backend IT sharding in #13495 (dev/ci/test-shards.sh). The build job skips :core:test and uploads its JaCoCo reports; a coverage job merges them with the four core lane .exec files after core-test-contract reconciles the lanes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019TX7XCnuCMGSJ7xX2iubTH --- .github/workflows/build.yml | 218 ++++++++++++++++++++++++--- core/build.gradle.kts | 8 +- design-docs/testing/core-db-split.md | 29 ++-- 3 files changed, 220 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30d5754e27a..49b5fd5f65a 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,207 @@ 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: Run core ${{ matrix.lane }} lane + run: | + ./gradlew :core:${{ matrix.task }} \ + -PskipWeb=true \ + -PskipITs \ + -PskipDockerTests=${{ matrix.skip-docker-tests }} + + - name: Generate core test manifest + run: | + mkdir -p core/build/test-manifests + python3 dev/ci/core_test_identity.py manifest \ + --lane "${{ matrix.lane }}" \ + --results "core/build/test-results/${{ matrix.task }}" \ + --output "core/build/test-manifests/${{ matrix.lane }}.json" + + - name: Validate core test evidence + run: | + test -s "core/build/test-manifests/${{ matrix.lane }}.json" + 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" + + - name: Upload core test evidence + uses: actions/upload-artifact@v7 + with: + name: core-${{ matrix.lane }}-test-evidence + path: | + core/build/test-manifests/${{ matrix.lane }}.json + 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 + core/build/test-manifests + + core-test-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [ changes, core-test ] + if: >- + always() && + needs.changes.outputs.source_changes == 'true' && + needs.changes.outputs.maintenance_module_only_changes != 'true' && + needs['core-test'].result == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Test core identity tool + run: python3 -B -m unittest discover -s dev/ci/tests -p 'test_core_test_identity.py' + + - name: Download core test evidence + uses: actions/download-artifact@v4 + with: + pattern: core-*-test-evidence + path: core-test-evidence + + - name: Reconcile core test identities + run: | + mkdir -p core/build/test-manifests + python3 dev/ci/core_test_identity.py reconcile \ + --manifests \ + core-test-evidence/core-unit-test-evidence/core/build/test-manifests/unit.json \ + core-test-evidence/core-h2-test-evidence/core/build/test-manifests/h2.json \ + core-test-evidence/core-mysql-test-evidence/core/build/test-manifests/mysql.json \ + core-test-evidence/core-postgresql-test-evidence/core/build/test-manifests/postgresql.json \ + --output core/build/test-manifests/summary.json + + - name: Upload core test contract + uses: actions/upload-artifact@v7 + with: + name: core-test-contract + path: core/build/test-manifests/summary.json + if-no-files-found: error + retention-days: 1 + + coverage: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [ changes, build, core-test-contract ] + if: >- + always() && + github.event_name == 'pull_request' && + needs.build.result == 'success' && + (needs.changes.outputs.maintenance_module_only_changes == 'true' || + needs['core-test-contract'].result == 'success') + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-java-toolchains + if: needs.changes.outputs.maintenance_module_only_changes != 'true' + with: + java-version: 17 + + - name: Fetch base branch for coverage diff run: git fetch origin ${{ github.base_ref }} --depth=1 + - 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 +436,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 +449,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/core/build.gradle.kts b/core/build.gradle.kts index 7ac057bcfc5..43fa9112fdf 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -256,10 +256,10 @@ tasks.named("jacocoTestReport") { // above (coreUnitTest / coreH2Test / coreMySQLTest / corePostgreSQLTest), never :core:test - // it predates the lane split and does not correspond to any CI lane. Check where a class runs // with `./gradlew :core:coreTestLaneOf -PclassName=...` instead of guessing. -// - CI CUJ: no change needed here. The `build` suite never runs :core:test - dev/ci/test-shards.sh -// emits `-x :core:test` for its `others` shard. BackendIT's `others` shard still realizes it -// via the root `test` task under -PskipTests, where the `**/integration/test/**` filter matches -// nothing in core, so the warning below can also appear in those logs. +// - CI CUJ: no change needed here. build.yml's `build` job passes `-x :core:test` and runs each +// lane in its own `core-test` job. Backend IT still realizes it via the root `test` task under +// -PskipTests, where the `**/integration/test/**` filter matches nothing in core, so the +// warning below can also appear in those logs. tasks.test { val policiesDocument = rootProject.file("docs/policies.md") inputs.file(policiesDocument) diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md index 4c150c8555b..e0fd8c443a3 100644 --- a/design-docs/testing/core-db-split.md +++ b/design-docs/testing/core-db-split.md @@ -167,9 +167,9 @@ build script runs. Removing it outright risks breaking any tooling (IDE run butt that targets `::test` by convention across the whole repo, not just `core`. Instead its `tasks.test { ... }` configuration block gained a `doFirst` that logs a warning naming the four lanes and `coreTestLaneOf` every time it runs, so it still works exactly as before but visibly -tells a developer it is the wrong task. No CI change was needed: `dev/ci/test-shards.sh` already -emits `-x :core:test` for the `others` shard, so the warning only ever fires for someone running -it directly. See Appendix C for the day-to-day commands this replaces. +tells a developer it is the wrong task. The CI `build` job passes `-x :core:test`, since core's +tests run in the `core-test` job, so the warning only ever fires for someone running it +directly. See Appendix C for the day-to-day commands this replaces. ### Alternatives considered (brief) @@ -248,9 +248,10 @@ and included in `coreH2Test`, that the manifest step passes on the lane output, - Migration of all three existing usage patterns (`AbstractEntityStorageTest`, `TestJDBCBackend`, `TestJdbcPartitionStatisticStorageIT`) and removal of the gatekeeper tag. - Removal of the dead `compare-legacy` subcommand from `core_test_identity.py`. -- CI wiring: `build` matrix shards `core-unit`/`core-h2`/`core-mysql`/`core-postgresql` each run - one lane and upload evidence; `core-test-contract` (`needs: [changes, build]`) runs the tool's - own unit tests, downloads the four evidence artifacts, and reconciles. +- CI wiring: a `core-test` job with a `lane` matrix (`unit`/`h2`/`mysql`/`postgresql`) runs one + lane per job and uploads evidence; `build` skips `:core:test`; `core-test-contract` + (`needs: [changes, core-test]`) runs the tool's own unit tests, downloads the four evidence + artifacts, and reconciles. - `:core:test` deprecated in place: a `doFirst` warning on its `tasks.test` block names the four lanes and `coreTestLaneOf`; the task still runs and still passes. @@ -305,7 +306,7 @@ Each criterion is concrete and checkable. **Final (CI, on every PR touching core):** -7. All four `build` shards succeed and each uploads `core--test-evidence` containing a +7. All four `core-test` lane jobs succeed and each uploads `core--test-evidence` containing a non-empty `.json` manifest, JUnit XML, HTML report, and JaCoCo `.exec`. 8. `core-test-contract` passes: `reconcile` reports `database_identities_equal: true` and `unit_database_disjoint: true`, i.e. the H2, MySQL, and PostgreSQL manifests hold identical @@ -574,24 +575,24 @@ summaries, and combined counts. Any violation exits 1 and fails the `core-test-c ### B.4 End-to-end flow in CI ``` -changes ──► build (matrix: core-unit | core-h2 | core-mysql | core-postgresql | ...) - │ each core-* shard: +changes ──► core-test (matrix lane: unit | h2 | mysql | postgresql) + │ each lane job: │ ./gradlew :core: ... (tag filter prunes at discovery) │ core_test_identity.py manifest --lane L (fails on foreign markers) │ validate evidence files exist and are non-empty │ upload core--test-evidence ▼ - core-test-contract (needs: [changes, build]) + core-test-contract (needs: [changes, core-test]) │ unittest dev/ci/tests/test_core_test_identity.py │ download the four evidence artifacts │ core_test_identity.py reconcile --manifests unit h2 mysql postgresql │ upload summary.json as core-test-contract ▼ - coverage (needs core-test-contract; merges the four JaCoCo .exec files) + coverage (needs build and core-test-contract; merges the four JaCoCo .exec files) ``` -`dev/ci/test-shards.sh` maps `build/core-` to `:core:` and emits `-x :core:test` -for the `others` shard so the unsplit task never runs in CI. +The `core-test` matrix maps each lane to its `:core:`, and the `build` job passes +`-x :core:test` so the unsplit task never runs in CI. ### B.5 Invariants, in one place @@ -630,7 +631,7 @@ neither Docker nor that flag. Each lane runs its `Test` task sequentially (`maxParallelForks = 1`) because database tests mutate process-wide state. Do **not** run `./gradlew :core:test` - it is deprecated in place (Q4/A.2): it still works, but -warns and does not correspond to any of the four lanes above or any CI shard. +warns and does not correspond to any of the four lanes above or any CI job. ### Tagging a new test class From 7cc881a71a0066d96f4ed0208da8e52b8e2dbafe Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 11:34:35 -0700 Subject: [PATCH 19/30] improvement(test): keep only the core database lane changes Drop the design doc, the Python test-identity tool with its tests and fixtures, the core-test-contract job and manifest steps, and the coreTestLaneOf helper task. TestCoreLaneMembership keeps the orphan-lane guard; its two tag helpers move into the test itself. --- .github/workflows/build.yml | 55 +- core/build.gradle.kts | 27 +- .../storage/relational/CoreBackend.java | 15 +- .../storage/relational/CoreTestLaneOf.java | 143 ---- .../TestCoreDatabaseLaneAnnotations.java | 8 +- .../relational/TestCoreLaneMembership.java | 45 +- design-docs/testing/core-db-split.md | 672 ------------------ dev/ci/core_test_identity.py | 509 ------------- .../core_test_identity/h2/TEST-backend.xml | 27 - .../core_test_identity/mysql/TEST-backend.xml | 27 - .../postgresql/TEST-backend.xml | 29 - .../core_test_identity/unit/TEST-unit.xml | 26 - dev/ci/tests/test_core_test_identity.py | 331 --------- 13 files changed, 51 insertions(+), 1863 deletions(-) delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java delete mode 100644 design-docs/testing/core-db-split.md delete mode 100644 dev/ci/core_test_identity.py delete mode 100644 dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml delete mode 100644 dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml delete mode 100644 dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml delete mode 100644 dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml delete mode 100644 dev/ci/tests/test_core_test_identity.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49b5fd5f65a..98dd113bb7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -295,17 +295,8 @@ jobs: -PskipITs \ -PskipDockerTests=${{ matrix.skip-docker-tests }} - - name: Generate core test manifest - run: | - mkdir -p core/build/test-manifests - python3 dev/ci/core_test_identity.py manifest \ - --lane "${{ matrix.lane }}" \ - --results "core/build/test-results/${{ matrix.task }}" \ - --output "core/build/test-manifests/${{ matrix.lane }}.json" - - name: Validate core test evidence run: | - test -s "core/build/test-manifests/${{ matrix.lane }}.json" junit_xml="$(find "core/build/test-results/${{ matrix.task }}" \ -type f -name 'TEST-*.xml' -print -quit)" test -n "${junit_xml}" @@ -318,7 +309,6 @@ jobs: with: name: core-${{ matrix.lane }}-test-evidence path: | - core/build/test-manifests/${{ matrix.lane }}.json core/build/test-results/${{ matrix.task }} build/reports/tests/core/${{ matrix.task }} core/build/jacoco/${{ matrix.task }}.exec @@ -333,58 +323,17 @@ jobs: path: | build/reports/tests/core core/build/test-results - core/build/test-manifests - - core-test-contract: - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [ changes, core-test ] - if: >- - always() && - needs.changes.outputs.source_changes == 'true' && - needs.changes.outputs.maintenance_module_only_changes != 'true' && - needs['core-test'].result == 'success' - steps: - - uses: actions/checkout@v4 - - - name: Test core identity tool - run: python3 -B -m unittest discover -s dev/ci/tests -p 'test_core_test_identity.py' - - - name: Download core test evidence - uses: actions/download-artifact@v4 - with: - pattern: core-*-test-evidence - path: core-test-evidence - - - name: Reconcile core test identities - run: | - mkdir -p core/build/test-manifests - python3 dev/ci/core_test_identity.py reconcile \ - --manifests \ - core-test-evidence/core-unit-test-evidence/core/build/test-manifests/unit.json \ - core-test-evidence/core-h2-test-evidence/core/build/test-manifests/h2.json \ - core-test-evidence/core-mysql-test-evidence/core/build/test-manifests/mysql.json \ - core-test-evidence/core-postgresql-test-evidence/core/build/test-manifests/postgresql.json \ - --output core/build/test-manifests/summary.json - - - name: Upload core test contract - uses: actions/upload-artifact@v7 - with: - name: core-test-contract - path: core/build/test-manifests/summary.json - if-no-files-found: error - retention-days: 1 coverage: runs-on: ubuntu-latest timeout-minutes: 30 - needs: [ changes, build, core-test-contract ] + 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-contract'].result == 'success') + needs['core-test'].result == 'success') steps: - uses: actions/checkout@v4 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 43fa9112fdf..08ab76342f5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -119,8 +119,8 @@ artifacts { // @CoreBackend.H2 @CoreBackend.MySQL -> runs in coreH2Test and coreMySQLTest // @CoreBackend.All -> runs in all three backend lanes // (no CoreBackend annotation at all) -> a plain unit test, runs in coreUnitTest -// A class needing Docker but carrying no backend tag runs in no lane at all - check locally -// with `./gradlew :core:coreTestLaneOf -PclassName=`. +// A class needing Docker but carrying no backend tag runs in no lane at all, and +// TestCoreLaneMembership fails coreUnitTest for it. // // Backend name -> JUnit tag that admits a test class to that backend's lane. Adding a backend // here is enough to teach the lane filtering below about it; also add it to CoreBackend.java. @@ -204,22 +204,6 @@ registerCoreTestTask("coreH2Test", "h2") registerCoreTestTask("coreMySQLTest", "mysql") registerCoreTestTask("corePostgreSQLTest", "postgresql") -tasks.register("coreTestLaneOf") { - group = "verification" - description = "Prints which core database test lane(s) a class runs in, from its tags, " + - "without running anything. Usage: -PclassName=" - dependsOn(tasks.named("testClasses")) - classpath = sourceSets["test"].runtimeClasspath - mainClass.set("org.apache.gravitino.storage.relational.CoreTestLaneOf") - doFirst { - val className = project.findProperty("className") as? String - ?: throw GradleException( - "Usage: ./gradlew :core:coreTestLaneOf -PclassName=" - ) - args(className) - } -} - val coreSuiteCoverage = providers.gradleProperty("coreSuiteCoverage").map(String::toBoolean).orElse(false) val coreSuiteTaskNames = @@ -254,8 +238,7 @@ tasks.named("jacocoTestReport") { // deprecated in place, not removed: // - Dev CUJ: a contributor running tests locally should target one of the four lanes registered // above (coreUnitTest / coreH2Test / coreMySQLTest / corePostgreSQLTest), never :core:test - -// it predates the lane split and does not correspond to any CI lane. Check where a class runs -// with `./gradlew :core:coreTestLaneOf -PclassName=...` instead of guessing. +// it predates the lane split and does not correspond to any CI lane. // - CI CUJ: no change needed here. build.yml's `build` job passes `-x :core:test` and runs each // lane in its own `core-test` job. Backend IT still realizes it via the root `test` task under // -PskipTests, where the `**/integration/test/**` filter matches nothing in core, so the @@ -268,9 +251,7 @@ tasks.test { doFirst { logger.warn( "WARNING: :core:test is deprecated and does not correspond to any CI lane. Use " + - "coreUnitTest, coreH2Test, coreMySQLTest, or corePostgreSQLTest instead - run " + - "./gradlew :core:coreTestLaneOf -PclassName= to check which " + - "one(s) a class belongs to." + "coreUnitTest, coreH2Test, coreMySQLTest, or corePostgreSQLTest instead." ) } val testMode = project.properties["testMode"] as? String ?: "embedded" diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java index 2e2d122ee32..c1f64aef34c 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -36,12 +36,11 @@ * *

      *
    • {@link H2}, {@link MySQL}, {@link PostgreSQL} pin a class to one lane; stack more than one - * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. A class tagged - * for some but not all backends must satisfy the CI-legality constraint below. + * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. *
    • {@link All} runs a class under all three lanes. *
    • A class with none of these is a unit test and runs in {@code coreUnitTest} - unless it also - * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane at all. - * Check with {@code ./gradlew :core:coreTestLaneOf -PclassName=}. + * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane at all and + * {@code TestCoreLaneMembership} fails. *
    * *

    Each is a plain JUnit composed annotation: meta-annotated with {@link Tag} and nothing else, @@ -51,14 +50,6 @@ * core/build.gradle.kts} only needs the tag strings themselves and no execution-time condition is * involved. {@link #H2_TAG}, {@link #MYSQL_TAG}, and {@link #POSTGRESQL_TAG} must stay in sync with * {@code coreBackendTestTags} in {@code core/build.gradle.kts}. - * - *

    Because {@code dev/ci/core_test_identity.py}'s {@code reconcile} step (invoked from {@code - * .github/workflows/build.yml}, not from {@code core/build.gradle.kts}) requires the - * h2/mysql/postgresql lanes to run the exact same set of normalized test identities, a single- or - * multi- (but not all-) backend class is only CI-legal as a normalized sibling of matching classes - * in the other backend(s) it omits - see {@code TestJdbcPartitionStatisticStorageIT}'s {@code - * H2Test}/{@code MySQLTest}/{@code PostgreSQLTest} nested classes for the pattern this currently - * requires. */ public final class CoreBackend { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java deleted file mode 100644 index 4c1aca81f5d..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.gravitino.storage.relational; - -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.Tag; -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; - -/** - * A local, discovery-only check: prints which core database test lane(s) a class runs in, from its - * tags, without running anything. Reads a class's tags with the same lookup the Jupiter engine uses - * at discovery time (see {@link CoreBackend}), so the answer matches what {@code - * core/build.gradle.kts}'s lane tasks would actually do. A class with no tests of its own, such as - * {@code TestJdbcPartitionStatisticStorageIT}, is reported through its member test classes. - * - *

    Run with {@code ./gradlew :core:coreTestLaneOf -PclassName=}. - */ -public final class CoreTestLaneOf { - - static final String DOCKER_TAG = "gravitino-docker-test"; - - private CoreTestLaneOf() {} - - /** - * Entry point. - * - * @param args exactly one fully-qualified class name to inspect - */ - public static void main(String[] args) { - if (args.length != 1) { - System.err.println("Usage: CoreTestLaneOf "); - System.exit(1); - return; - } - - Class testClass; - try { - testClass = Class.forName(args[0]); - } catch (ClassNotFoundException e) { - System.err.println("Class not found on the test classpath: " + args[0]); - System.exit(1); - return; - } - - if (declaresTests(testClass)) { - report(testClass); - return; - } - - List> members = new ArrayList<>(); - Arrays.stream(testClass.getDeclaredClasses()) - .filter(CoreTestLaneOf::declaresTests) - .sorted(Comparator.comparing(Class::getName)) - .forEach(members::add); - if (members.isEmpty()) { - System.out.println(args[0] + " has no tests of its own or in its member classes."); - return; - } - System.out.println(args[0] + " has no tests of its own; reporting its member test classes:"); - members.forEach(CoreTestLaneOf::report); - } - - /** - * Whether JUnit would run {@code c}: concrete, with at least one {@code @Test}-style method (all - * meta-annotated with {@link Testable}), own or inherited. - */ - 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. - */ - 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 void report(Class c) { - String name = c.getName(); - Set tags = effectiveTags(c); - List lanes = new ArrayList<>(); - if (tags.contains(CoreBackend.H2_TAG)) { - lanes.add("coreH2Test"); - } - if (tags.contains(CoreBackend.MYSQL_TAG)) { - lanes.add("coreMySQLTest"); - } - if (tags.contains(CoreBackend.POSTGRESQL_TAG)) { - lanes.add("corePostgreSQLTest"); - } - - System.out.println(name + " tags: " + tags); - if (!lanes.isEmpty()) { - System.out.println(name + " runs in: " + String.join(", ", lanes)); - } else if (tags.contains(DOCKER_TAG)) { - System.out.println( - name - + " carries gravitino-docker-test but no backend tag - it will NOT run in ANY" - + " lane. Add @CoreBackend.H2/@CoreBackend.MySQL/@CoreBackend.PostgreSQL or" - + " @CoreBackend.All."); - } else { - System.out.println(name + " carries no backend tag - runs in coreUnitTest."); - } - } -} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java index 2aeb7cc0f93..6c5ca0de024 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java @@ -57,12 +57,8 @@ private static Stream annotationsAndExpectedTags() { }); } - // Default JUnit display names include argument toStrings - for expectedTags that would print a - // tag string like "[gravitino-core-h2-test]" into this class's own JUnit XML, which is itself - // part of the coreUnitTest lane, and core_test_identity.py's manifest step rejects any standalone - // backend token there as a foreign-lane marker. Naming on {0} (the class under test) only avoids - // that: its simple name (e.g. H2Annotated) has no such token, since "h2"/"mysql"/"postgresql" is - // never followed by a non-letter there. + // Named on {0} (the class under test) so the report reads e.g. "1: H2Annotated" rather than + // printing the expected tag list. @ParameterizedTest(name = "{index}: {0}") @MethodSource("annotationsAndExpectedTags") void testExpandsToExpectedTags(Class annotatedClass, List expectedTags) { 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 index 2edc976d781..a3c02117bf3 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java @@ -20,21 +20,28 @@ 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. {@code core_test_identity.py reconcile} only compares the database lanes with each - * other, so a class that leaves all three at once would otherwise go unnoticed. + * 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); @@ -44,13 +51,13 @@ void everyDockerTaggedTestClassHasABackendLane() { List orphans = ReflectionSupport.findAllClassesInPackage( "org.apache.gravitino", - c -> coreTestClasses.equals(codeLocation(c)) && CoreTestLaneOf.declaresTests(c), + c -> coreTestClasses.equals(codeLocation(c)) && declaresTests(c), name -> true) .stream() .filter( c -> { - Set tags = CoreTestLaneOf.effectiveTags(c); - return tags.contains(CoreTestLaneOf.DOCKER_TAG) + Set tags = effectiveTags(c); + return tags.contains(DOCKER_TAG) && tags.stream().noneMatch(BACKEND_TAGS::contains); }) .map(Class::getName) @@ -64,6 +71,34 @@ void everyDockerTaggedTestClassHasABackendLane() { + 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 diff --git a/design-docs/testing/core-db-split.md b/design-docs/testing/core-db-split.md deleted file mode 100644 index e0fd8c443a3..00000000000 --- a/design-docs/testing/core-db-split.md +++ /dev/null @@ -1,672 +0,0 @@ - - -# SPIP: Split core tests into explicit database lanes - -| | | -|---|---| -| **Status** | Implemented on PR [apache/gravitino#13517](https://github.com/apache/gravitino/pull/13517); this document records the final design retroactively | -| **Scope** | `core` module test execution, `core/build.gradle.kts`, `dev/ci/core_test_identity.py`, `.github/workflows/build.yml` | -| **Format** | Apache Spark SPIP (Heilmeier Catechism) | - -## Q1. What are you trying to do? - -Make it explicit, per test class, which database(s) a core test runs against, and make the -build honor that declaration exactly. - -Concretely: - -- Replace the single `:core:test` run with four Gradle lanes: `coreUnitTest` (no database, no - Docker), `coreH2Test`, `coreMySQLTest`, and `corePostgreSQLTest`. -- Give contributors one typed way to put a class into a lane: `@CoreBackend.H2`, - `@CoreBackend.MySQL`, `@CoreBackend.PostgreSQL`, or `@CoreBackend.All`. No raw tag strings. -- Guarantee that a class not tagged for a lane leaves no trace in that lane's output, so CI can - prove the three database lanes ran the same test contract. -- Give a developer a way to ask "which lane does my class run in?" without running any test. -- Warn a developer off the old, unsplit `:core:test` entry point without deleting it. - -## Q2. What problem is this proposal NOT designed to solve? - -These are deliberate exclusions, not oversights. - -- **A unit-test guard, not a CI step, for the orphan case.** A class that carries - `gravitino-docker-test` but no `@CoreBackend.*` annotation is excluded from `coreUnitTest` (by - the Docker tag) and from every backend lane (no backend tag), so it would run nowhere. - `TestCoreLaneMembership` runs in `coreUnitTest` and fails, naming each such class. Keeping every - test running is still the contributor's job; `./gradlew :core:coreTestLaneOf` lets them check a - class locally. -- **No cross-lane report aggregation.** Each lane writes its own JUnit XML, HTML report, and - JaCoCo `.exec` under a lane-specific path. CI uploads them as four separate evidence artifacts - and only the JaCoCo data is merged (for coverage). There is no tool that merges the four JUnit - reports into one; readers open the lane they care about. -- **Not removing `:core:test`.** It is the `java` plugin's built-in `test` task; other tooling - (IDEs, scripts) may still target it by convention, so it stays registered and functional rather - than being deleted. It is deprecated *in place* instead (see Q4/Appendix C): running it directly - now prints a warning naming the four real lanes, but it still runs and still passes. -- **Not changing how a test selects its backend at runtime.** `BackendTestSelector` (reads the - `gravitino.core.test.backend` system property), `BackendTestExtension` (`@TestTemplate` - invocation contexts), and the `storageProvider()` parameter pattern are unchanged. The lane - decides *whether* a class runs; these decide *what it does* once it runs. - -## Q3. How is it done today, and what are the limits of current practice? - -Before this PR, `core` ran every test through one `:core:test` task. H2-backed, MySQL-backed, -and PostgreSQL-backed tests were all discovered by the same task, and the only filtering was -the repository-wide `excludeTags("gravitino-docker-test")` applied when Docker was unavailable. -There was no notion of a backend lane at all, so there was nothing for CI to reconcile. - -The first iteration on this PR branch introduced four lane tasks but decided membership with a -gatekeeper tag plus modifier tags: - -```kotlin -// state at 654cfe33a, since replaced -useJUnitPlatform { - if (backend == null) { - excludeTags(coreDatabaseTestTag, "gravitino-docker-test") - } else { - includeTags(coreDatabaseTestTag) // gatekeeper - when (backend) { - "h2" -> excludeTags(coreMySQLTestTag, corePostgreSQLTestTag) - "mysql" -> excludeTags(coreH2TestTag, corePostgreSQLTestTag) - "postgresql" -> excludeTags(coreH2TestTag, coreMySQLTestTag) - } - } -} -``` - -A class had to carry `gravitino-core-database-test` to enter any database lane, and then -optionally carried per-backend tags whose *absence* meant "all backends". This had three -concrete failure modes, all silent: - -1. **Gatekeeper desync.** A class with correct per-backend tags but no gatekeeper tag was - invisible to every lane. Nothing failed; the tests just never ran. -2. **Two tags meant zero lanes.** Because each lane *excluded* the other two backends' tags, a - class tagged for both H2 and MySQL was excluded from H2 (carries MySQL tag), from MySQL - (carries H2 tag), and from PostgreSQL. An intermediate fix (46eef2130) replaced the - `when` with a boolean tag expression to make multi-tagged classes run under each lane, but - the expression was hard to read and still depended on the gatekeeper. -3. **Typos compiled.** Tags were raw `@Tag("gravitino-core-h2-test")` string literals. A - misspelling was a valid, unrelated tag, so the class silently dropped out of its lane. - -There was also no way to answer "where will this class run?" other than running a lane and -grepping the resulting XML. - -## Q4. What is new in your approach, and why do you think it will be successful? - -### Three peer tags, nothing else - -The gatekeeper is gone. Lane membership is decided by exactly three JUnit tags, defined once -in `core/build.gradle.kts` and mirrored as constants in `CoreBackend`: - -```kotlin -val coreBackendTestTags = linkedMapOf( - "h2" to "gravitino-core-h2-test", - "mysql" to "gravitino-core-mysql-test", - "postgresql" to "gravitino-core-postgresql-test" -) -``` - -Each backend lane is a plain `includeTags(ownBackendTag)`. The unit lane is -`excludeTags(, "gravitino-docker-test")`. "Runs on all backends" means carrying all -three tags explicitly, never carrying none. A class tagged for two backends runs in both, because -each lane only looks for its own tag and never excludes another backend's. - -### Typed annotations that are pure `@Tag` composition - -Contributors never write tag strings. `CoreBackend` is a final namespace class with four nested -marker annotations; each is meta-annotated with `@Tag` (one or three) and the standard -`@Documented @Inherited @Retention(RUNTIME) @Target(TYPE)` set, and nothing else. This is the -single most important property of the design: **filtering happens at JUnit discovery time**. -Gradle's `includeTags`/`excludeTags` become a JUnit Platform `PostDiscoveryFilter`, which reads -class tags reflectively via `AnnotationSupport.findRepeatableAnnotations(clazz, Tag.class)`. -Because the annotations carry no `@ExtendWith` or other execution-time hook, an excluded class is -pruned from the discovered test plan before execution begins and therefore produces no -`` element, not even a `` one, in the lane's JUnit XML. - -That hard-zero property is what `dev/ci/core_test_identity.py reconcile` depends on (Appendix -B). An earlier redesign on this same branch (`@DatabaseTest` + a `BackendLaneCondition` -`ExecutionCondition`, commit efee6a4c9) was reverted (547e9bdf0) precisely because an -`ExecutionCondition` runs after discovery: the disabled class still appeared in every lane's XML -as ``, the manifest step saw foreign backend markers, and reconcile failed. - -`TestCoreDatabaseLaneAnnotations` pins this contract as a regression guard: it asserts the exact -tag expansion of each annotation, that stacking two annotations yields both tags, that subclasses -inherit the tags, and that none of the four annotation types carries any meta-annotation outside -`{Documented, Inherited, Retention, Target, Tag, Tags}`. Re-introducing an execution-time hook -fails that test. - -### A local, discovery-only lane check - -`./gradlew :core:coreTestLaneOf -PclassName=` runs `CoreTestLaneOf`, which loads the class, -calls the same `AnnotationSupport.findRepeatableAnnotations` lookup the engine uses, maps the -resulting tag set to lane task names, and prints them. If the class carries -`gravitino-docker-test` but no backend tag, it prints an explicit "will NOT run in ANY lane" -warning. Because it re-derives the answer through the identical mechanism, its output is -trustworthy without running a single test. - -### `:core:test` deprecated in place, not removed - -`:core:test` is the `java` plugin's built-in `test` task, auto-registered before this module's -build script runs. Removing it outright risks breaking any tooling (IDE run buttons, scripts) -that targets `::test` by convention across the whole repo, not just `core`. Instead its -`tasks.test { ... }` configuration block gained a `doFirst` that logs a warning naming the four -lanes and `coreTestLaneOf` every time it runs, so it still works exactly as before but visibly -tells a developer it is the wrong task. The CI `build` job passes `-x :core:test`, since core's -tests run in the `core-test` job, so the warning only ever fires for someone running it -directly. See Appendix C for the day-to-day commands this replaces. - -### Alternatives considered (brief) - -- **Parameterized annotation** (`@CoreDBTest(backends = {H2, MYSQL})`): mechanically possible - with a custom `PostDiscoveryFilter` that reads the attribute, but rejected. JUnit's built-in - tag resolution only reads an annotation type's fixed meta-annotations, never a per-usage - attribute, so it would need a bespoke filter to interpret. More importantly, - `core_test_identity.py reconcile` requires a fixed, small set of lanes with exact identity - equality across the three database lanes; a free-form attribute-driven subset per class would - undermine that invariant rather than express it. -- **Flat top-level annotation names** (`@CoreH2Test`, `@CoreMySQLTest`, ...): rejected after a - real, reproduced compile error. `TestJdbcPartitionStatisticStorageIT` already declares nested - classes named `H2Test`, `MySQLTest`, and `PostgreSQLTest`; an unqualified annotation import of - the same simple name was shadowed by the nested class, producing - `H2Test cannot be converted to Annotation`. Namespacing under `CoreBackend` makes the - reference always qualified, so the collision is structurally impossible, and it matches the - package's existing "Backend" vocabulary (`BackendTestExtension`, `BackendTestSelector`). -- **Execution-time condition** (`@DatabaseTest` + `ExecutionCondition`): implemented, reverted; - see above. - -### Why it will work - -The design has already been run, not just reasoned about. Real `:core:coreH2Test`, -`:core:coreUnitTest`, and `:core:coreTestLaneOf` invocations on the branch confirmed that only -the matching nested class's XML is produced for `TestJdbcPartitionStatisticStorageIT`, that an -`@CoreBackend.All` class is excluded from `coreUnitTest` ("No tests found for given includes") -and included in `coreH2Test`, that the manifest step passes on the lane output, and that -`coreTestLaneOf` reports lanes for a real class and warns for a synthetic orphan. - -## Q5. Who cares? If you are successful, what difference will it make? - -- **Contributors adding core storage tests** get a two-line, compile-checked way to declare - where a test runs, and a five-second local command to confirm it. The "my test never ran and - nobody noticed" class of bug is reduced to one remaining shape (the orphan), which the local - tool names explicitly. -- **Reviewers** can read lane membership off the class declaration instead of reconstructing it - from a tag expression in Gradle. -- **CI maintainers** get a lane filter that is three trivial include/exclude lines, a - reconcile step whose hard-zero precondition is guaranteed by construction, and a regression - test that fails if anyone reintroduces an execution-time hook. -- **The project** keeps the ability to prove, on every PR, that H2, MySQL, and PostgreSQL ran - the identical normalized test contract, and that unit and database identities are disjoint. - -## Q6. What are the risks? - -- **Orphan classes would run nowhere.** A `gravitino-docker-test` class with no - `@CoreBackend.*` annotation is dropped by every lane. Mitigated by `TestCoreLaneMembership`, - which fails `coreUnitTest` for such a class (Q2), and by `coreTestLaneOf` for local checks. -- **Tag string drift.** `CoreBackend.H2_TAG` / `MYSQL_TAG` / `POSTGRESQL_TAG` must stay equal to - the values in `coreBackendTestTags`. They are declared in two places (Kotlin build script and - Java test source) with no shared source. Mitigated by a comment on each side naming the other; - a mismatch surfaces as an empty lane ("No tests found for given includes") rather than silent - success, because the lane would then include a tag no class carries. -- **`:core:test` still works and still means "everything".** It is deprecated in place, not - removed (Q4), so a developer can still run the unsplit task locally and get results that do not - correspond to any CI lane; the `doFirst` warning is advisory, not a hard failure, so it is easy - to miss in a noisy log. -- **Sharded reports.** With no aggregation tooling, someone looking for "all core test - results" must open up to four reports. Accepted as scope simplification. -- **Partial-backend classes are only CI-legal as normalized siblings.** A class annotated with a - single backend (or two) passes reconcile only if matching classes exist for the backends it - omits and `core_test_identity.py`'s normalization maps them to the same identity (today: the - `TestJdbcPartitionStatisticStorageIT$H2Test/$MySQLTest/$PostgreSQLTest` shape, handled by - `STATS_BACKEND_CLASS_RE`). A new single-backend class without siblings fails reconcile with - "Database identity mismatch". This is the intended contract, but it is a constraint contributors - must know; it is documented in `CoreBackend`'s Javadoc. - -## Q7. How long will it take? - -**Done, on the PR branch:** - -- `CoreBackend` annotation namespace (`H2`, `MySQL`, `PostgreSQL`, `All`). -- Four Gradle lane tasks via `registerCoreTestTask`, with the three-peer-tag filter. -- `coreTestLaneOf` JavaExec task and `CoreTestLaneOf` main class. -- `TestCoreDatabaseLaneAnnotations` regression guard. -- Migration of all three existing usage patterns (`AbstractEntityStorageTest`, - `TestJDBCBackend`, `TestJdbcPartitionStatisticStorageIT`) and removal of the gatekeeper tag. -- Removal of the dead `compare-legacy` subcommand from `core_test_identity.py`. -- CI wiring: a `core-test` job with a `lane` matrix (`unit`/`h2`/`mysql`/`postgresql`) runs one - lane per job and uploads evidence; `build` skips `:core:test`; `core-test-contract` - (`needs: [changes, core-test]`) runs the tool's own unit tests, downloads the four evidence - artifacts, and reconciles. -- `:core:test` deprecated in place: a `doFirst` warning on its `tasks.test` block names the four - lanes and `coreTestLaneOf`; the task still runs and still passes. - -Contributor-facing documentation for the lanes lives in this document (Appendix C) rather than in -`docs/how-to-test.md`, which is a repo-wide doc unrelated to this split (it only documents the -root `./gradlew test` task and does not mention `core` or its lanes) - no edit there was needed. - -Found and fixed during review, before merge: `TestCoreDatabaseLaneAnnotations`'s -`@ParameterizedTest` used the default display name, which embeds each case's expected-tags -argument (e.g. `[gravitino-core-h2-test]`) into its own JUnit XML - since that test carries no -`@CoreBackend.*` annotation itself, it runs in `coreUnitTest`, and `manifest`'s unit-lane check -rejects any standalone backend token there as a foreign marker. Reproduced directly -(`./gradlew :core:coreUnitTest --tests ...TestCoreDatabaseLaneAnnotations` then `manifest --lane -unit` failed with "contains an explicit backend marker ['h2']"), fixed by naming on the class -under test only (`@ParameterizedTest(name = "{index}: {0}")`), re-verified clean, and confirmed -by scanning all 1968 `coreUnitTest` testcases through the real `normalize_identity` function with -zero marker errors. - -**Optional follow-up, not required to ship:** `STATS_BACKEND_CLASS_RE` (B.2/B.3) is hard-coded to -one outer class name, so it does not generalize to a second nested-per-backend class without -editing the regex. Appendix C now tells contributors to prefer `@CoreBackend.All` and flags this -constraint explicitly rather than silently teaching a pattern that fails `reconcile`; generalizing -the regex to any outer class name is a real improvement but touches CI-wired parsing logic and -needs its own fixtures/tests, so it was left out of this pass. - -**Remaining, in scope, not yet done:** - -- Reply to the open review thread on #13517 and update the PR description to match the final - design. - -## Q8. What are the mid-term and final "exams" to check for success? - -Each criterion is concrete and checkable. - -**Mid-term (already verifiable on the branch):** - -1. `./gradlew :core:coreUnitTest -PskipITs --tests '*TestCoreDatabaseLaneAnnotations*'` passes: each - annotation expands to exactly its expected tags, stacking and inheritance hold, and no - annotation carries a non-tag meta-annotation. -2. `./gradlew :core:coreH2Test` produces JUnit XML under `core/build/test-results/coreH2Test/` - containing no `` whose classname or name carries a `mysql` or `postgresql` marker - (`python3 dev/ci/core_test_identity.py manifest --lane h2 ...` exits 0). -3. `./gradlew :core:coreUnitTest` on an `@CoreBackend.All` class reports "No tests found for - given includes" for that class. -4. `./gradlew :core:coreTestLaneOf -PclassName=org.apache.gravitino.storage.relational.TestJDBCBackend` - prints `runs in: coreH2Test, coreMySQLTest, corePostgreSQLTest` in under five seconds once - test classes are compiled, without executing any test. -5. `coreTestLaneOf` on a class tagged only `gravitino-docker-test` prints the "will NOT run in - ANY lane" warning. -6. `./gradlew :core:test -PskipITs --tests ` still passes and now also logs the - `:core:test is deprecated ...` warning naming the four lanes and `coreTestLaneOf`. - -**Final (CI, on every PR touching core):** - -7. All four `core-test` lane jobs succeed and each uploads `core--test-evidence` containing a - non-empty `.json` manifest, JUnit XML, HTML report, and JaCoCo `.exec`. -8. `core-test-contract` passes: `reconcile` reports `database_identities_equal: true` and - `unit_database_disjoint: true`, i.e. the H2, MySQL, and PostgreSQL manifests hold identical - normalized identity multisets and the unit manifest shares none of them. -9. No `@Tag("gravitino-core-*-test")` string literal exists in `core/src/test` (all lane - membership goes through `@CoreBackend.*`). - ---- - -## Appendix A: API Changes - -### A.1 `CoreBackend` annotations - -Location: `core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java`. -Test-source only; not part of any published artifact. - -```java -public final class CoreBackend { - public static final String H2_TAG = "gravitino-core-h2-test"; - public static final String MYSQL_TAG = "gravitino-core-mysql-test"; - public static final String POSTGRESQL_TAG = "gravitino-core-postgresql-test"; - - private CoreBackend() {} - - @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) - @Tag(H2_TAG) - public @interface H2 {} - - @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) - @Tag(MYSQL_TAG) - public @interface MySQL {} - - @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) - @Tag(POSTGRESQL_TAG) - public @interface PostgreSQL {} - - @Documented @Inherited @Retention(RUNTIME) @Target(TYPE) - @Tag(H2_TAG) @Tag(MYSQL_TAG) @Tag(POSTGRESQL_TAG) - public @interface All {} -} -``` - -Semantics: - -| Declaration | Tags carried | Lanes | -|---|---|---| -| (none) | none | `coreUnitTest` | -| `@CoreBackend.H2` | `h2` | `coreH2Test` | -| `@CoreBackend.H2 @CoreBackend.MySQL` | `h2`, `mysql` | `coreH2Test`, `coreMySQLTest` | -| `@CoreBackend.All` | `h2`, `mysql`, `postgresql` | all three backend lanes | -| `@Tag("gravitino-docker-test")` only | `docker` | **none** (orphan) | - -`@Target(TYPE)` restricts the annotations to classes. `@Inherited` means an abstract base class -can carry the annotation and every concrete subclass (including Jupiter `@Nested` classes and -`TestJDBCBackend` subclasses) inherits lane membership. The three `*_TAG` constants are the -Java-side mirror of `coreBackendTestTags` in `core/build.gradle.kts` and must be kept equal. - -Usage patterns as migrated on the PR: - -```java -// Multi-backend via a parameter provider; the lane's system property narrows storageProvider(). -@CoreBackend.All -abstract class AbstractEntityStorageTest { - static Object[][] storageProvider() { /* filtered by BackendTestSelector.isSelected */ } -} - -// Multi-backend via @TestTemplate; BackendTestExtension emits one invocation for the lane's backend. -@CoreBackend.All -@ExtendWith({BackendTestExtension.class, ...}) -public abstract class TestJDBCBackend { ... } - -// One @Nested class per single backend; each nested class is its own lane member. -@Tag("gravitino-docker-test") -public class TestJdbcPartitionStatisticStorageIT { - @Nested @CoreBackend.MySQL @Tag("gravitino-docker-test") static class MySQLTest extends Base {} - @Nested @CoreBackend.PostgreSQL @Tag("gravitino-docker-test") static class PostgreSQLTest extends Base {} - @Nested @CoreBackend.H2 static class H2Test extends Base {} -} -``` - -### A.2 Gradle tasks - -Location: `core/build.gradle.kts`. - -```kotlin -val coreBackendTestTags = linkedMapOf( - "h2" to "gravitino-core-h2-test", - "mysql" to "gravitino-core-mysql-test", - "postgresql" to "gravitino-core-postgresql-test" -) -val coreTestBackendProperty = "gravitino.core.test.backend" - -fun registerCoreTestTask(taskName: String, backend: String? = null) = - tasks.register(taskName) { ... } - -registerCoreTestTask("coreUnitTest") -registerCoreTestTask("coreH2Test", "h2") -registerCoreTestTask("coreMySQLTest", "mysql") -registerCoreTestTask("corePostgreSQLTest", "postgresql") - -tasks.register("coreTestLaneOf") { ... } -``` - -Per-task configuration set by `registerCoreTestTask`: - -| Property | Unit lane (`backend == null`) | Backend lane | -|---|---|---| -| `useJUnitPlatform` filter | `excludeTags(h2, mysql, postgresql, "gravitino-docker-test")` | `includeTags(coreBackendTestTags[backend])` | -| `systemProperty(gravitino.core.test.backend)` | not set | `backend` | -| `extraProperties["includeDockerTaggedTests"]` | not set (root build applies its default) | `true` (root build does not add `excludeTags("gravitino-docker-test")`) | -| `maxParallelForks` / `junit.jupiter.execution.parallel.enabled` | default | `1` / `false` | -| Docker precondition (`doFirst`) | none | for `mysql`/`postgresql`: fail unless `rootProject.extra["dockerTest"] == true` | -| JUnit XML | `core/build/test-results//` | same | -| HTML report | `build/reports/tests/core//` | same | -| JaCoCo exec | `core/build/jacoco/.exec` | same | -| Up-to-date inputs | `coreTestSuite=unit`, `coreTestBackend=none`, `coreTestIncludesDockerTaggedTests=false` | `coreTestSuite=`, `coreTestBackend=`, `coreTestIncludesDockerTaggedTests=true` | - -Adding a backend is one map entry plus one `registerCoreTestTask(...)` call plus one nested -annotation in `CoreBackend`; the filter code needs no change. - -The lane JaCoCo files feed `validateCoreSuiteCoverage` and `jacocoTestReport` when -`-PcoreSuiteCoverage=true`, which is how the CI `coverage` job merges the four lanes. - -### A.3 `coreTestLaneOf` task and `CoreTestLaneOf` tool - -Gradle side: - -```kotlin -tasks.register("coreTestLaneOf") { - group = "verification" - dependsOn(tasks.named("testClasses")) - classpath = sourceSets["test"].runtimeClasspath - mainClass.set("org.apache.gravitino.storage.relational.CoreTestLaneOf") - doFirst { - val className = project.findProperty("className") as? String - ?: throw GradleException("Usage: ./gradlew :core:coreTestLaneOf -PclassName=") - args(className) - } -} -``` - -Java side, `core/src/test/java/org/apache/gravitino/storage/relational/CoreTestLaneOf.java`: - -```java -public final class CoreTestLaneOf { - public static void main(String[] args) // exactly one arg: a fully qualified class name -} -``` - -Behaviour: - -- `args.length != 1` or class not on the test classpath: message to stderr, exit code 1. -- Otherwise prints ` tags: [...]` followed by exactly one of: - - ` runs in: coreH2Test, coreMySQLTest, corePostgreSQLTest` (subset, in that order), - - ` carries gravitino-docker-test but no backend tag - it will NOT run in ANY lane. Add ...`, - - ` carries no backend tag - runs in coreUnitTest.` -- Exit code 0 in all three printed cases; the orphan case is a warning, not a failure, so it can - be used interactively without special-casing. - -It runs only `testClasses` (compilation), never a `Test` task. - -### A.4 `dev/ci/core_test_identity.py` - -Two subcommands remain (the unwired `compare-legacy` subcommand was removed on this PR): - -``` -core_test_identity.py manifest --lane {unit,h2,mysql,postgresql} --results

    --output -core_test_identity.py reconcile --manifests --output -``` - -Unit tests: `dev/ci/tests/test_core_test_identity.py`, run by the `core-test-contract` job -before reconcile. - ---- - -## Appendix B: Design Sketch - -### B.1 Discovery-time versus execution-time filtering - -JUnit Platform runs a test task in two phases. **Discovery** builds a `TestPlan`: the Jupiter -engine scans the class directories, creates a `ClassTestDescriptor` per test class, and attaches -each descriptor's tags. `PostDiscoveryFilter`s then prune descriptors from that plan. **Execution** -walks the surviving plan, evaluates `ExecutionCondition`s, and runs (or skips) each node. - -Gradle's `useJUnitPlatform { includeTags(...) / excludeTags(...) }` is compiled into a -`TagFilter`, which is a `PostDiscoveryFilter`. A descriptor excluded by it is removed from the plan -before execution starts. Gradle's XML reporter only sees the executed plan, so an excluded class -contributes no `` and no `` elements at all. - -An `ExecutionCondition` (what the reverted `BackendLaneCondition` was) runs at execution time. A -disabled class is still in the plan; Jupiter reports it as skipped, and Gradle writes a -`` for each of its methods. That is a *trace*, and the -reconcile invariant in B.3 tolerates no trace. - -The shipped design therefore uses tags only. The `@CoreBackend.*` annotations exist solely so that -contributors do not type tag strings; at the JUnit level they are indistinguishable from writing -`@Tag("gravitino-core-h2-test")` directly. - -### B.2 How JUnit resolves the tags - -Jupiter collects a class's tags with -`AnnotationSupport.findRepeatableAnnotations(clazz, Tag.class)`. That lookup: - -1. Reads the class's directly present annotations. -2. Follows `@Inherited` annotations up the superclass chain. -3. For each annotation found, recursively inspects the annotation *type's* own meta-annotations, - so a `@CoreBackend.All` on the class yields the three `@Tag` meta-annotations declared on - `CoreBackend.All`. -4. Unwraps the `@Tags` container so repeated `@Tag`s are returned individually. - -Two consequences shape the API: - -- Tags come from an annotation type's fixed meta-annotations, never from an attribute value on - the usage site. This is why a parameterized `backends = {...}` attribute cannot participate in - standard tag filtering and would need a custom filter. -- Only annotations reachable through this reflective walk count. Any annotation whose meaning - depends on code running (an `@ExtendWith` extension, a condition) is invisible to the tag - filter. `TestCoreDatabaseLaneAnnotations.testNoneOfThemCarryAnExecutionTimeHook` enforces that - the four annotation types declare nothing outside `{Documented, Inherited, Retention, Target, - Tag, Tags}`, so the annotations cannot acquire execution-time behaviour without breaking the - test. - -`CoreTestLaneOf` calls exactly this `findRepeatableAnnotations` method and then applies the same -membership rule the Gradle filter applies (`tags ∩ {h2, mysql, postgresql}`), which is why its -answer is authoritative without running a lane. `TestCoreDatabaseLaneAnnotations.tagsOf` uses the -same call, so the regression guard, the local tool, and the engine share one lookup. - -### B.3 How `core_test_identity.py` validates lane membership - -**`manifest --lane L --results DIR`** parses every `TEST-*.xml` under `DIR` and, for each -``: - -1. Extracts backend markers from `C` (via `STATS_BACKEND_CLASS_RE`, matching - `TestJdbcPartitionStatisticStorageIT$Test`) and from `N` (via - `TEST_TEMPLATE_BACKEND_RE`, matching `[ Backend]` as emitted by - `BackendTestExtension`, and `BACKEND_TOKEN_RE`, matching a standalone `h2`/`mysql`/`postgresql` - token as emitted by parameterized `storageProvider()` names). -2. Fails closed (`ManifestError`, exit 1) if `L == unit` and any marker is present, or if `L` is - a database lane and any marker other than `L` is present ("foreign backend marker"). -3. Normalizes the identity: nested stats class names collapse to `...$BackendTest`, backend - tokens collapse to `BACKEND`, `[BACKEND Backend]`, and trailing `[n]` invocation indices - collapse to `[INDEX]`. -4. Counts the normalized `(classname, name)` pair in a multiset, counts status - (`passed`/`skipped`/`failures`/`errors`), and sums `time`. - -It also fails closed on missing XML, zero testcases, any failure or error, and malformed -durations. The output JSON carries the identity list, counts, and a SHA-256 `identity_digest`. - -Note step 2 does not look at status: a `` testcase is still a testcase. This is the -precise reason execution-time skipping is incompatible with the design. Under the reverted -`ExecutionCondition` scheme, `TestJdbcPartitionStatisticStorageIT$MySQLTest` appeared in the H2 -lane's XML as skipped, step 2 saw a `mysql` marker in the `h2` lane, and the manifest step -failed. Even a class with no recognizable markers would have broken reconcile, since a skipped -entry present in one lane but absent from another changes the multiset. - -**`reconcile --manifests unit h2 mysql postgresql`** loads exactly four manifests (one per lane, -no duplicates, no missing), re-validates each (schema, counts, digest, no failures), then asserts: - -- `counters[mysql] == counters[h2]` and `counters[postgresql] == counters[h2]` as multisets, - reporting up to five missing/extra identities on mismatch. -- `counters[unit] & counters[h2]` is empty (unit and database identities are disjoint). - -It writes `summary.json` with `database_identities_equal`, `unit_database_disjoint`, per-lane -summaries, and combined counts. Any violation exits 1 and fails the `core-test-contract` job. - -### B.4 End-to-end flow in CI - -``` -changes ──► core-test (matrix lane: unit | h2 | mysql | postgresql) - │ each lane job: - │ ./gradlew :core: ... (tag filter prunes at discovery) - │ core_test_identity.py manifest --lane L (fails on foreign markers) - │ validate evidence files exist and are non-empty - │ upload core--test-evidence - ▼ - core-test-contract (needs: [changes, core-test]) - │ unittest dev/ci/tests/test_core_test_identity.py - │ download the four evidence artifacts - │ core_test_identity.py reconcile --manifests unit h2 mysql postgresql - │ upload summary.json as core-test-contract - ▼ - coverage (needs build and core-test-contract; merges the four JaCoCo .exec files) -``` - -The `core-test` matrix maps each lane to its `:core:`, and the `build` job passes -`-x :core:test` so the unsplit task never runs in CI. - -### B.5 Invariants, in one place - -1. A class is in lane `L` iff its `findRepeatableAnnotations(Tag)` set contains `L`'s tag - (backend lanes) or contains none of the three backend tags and not `gravitino-docker-test` - (unit lane). -2. Every `@CoreBackend.*` type declares only `Tag`/`Tags` plus the four standard - meta-annotations. (Guarded by `TestCoreDatabaseLaneAnnotations`.) -3. A lane's JUnit XML contains testcases only for classes in that lane. (Follows from 1 and 2 - via discovery-time pruning; checked by `manifest`'s foreign-marker rule.) -4. The h2, mysql, and postgresql lanes yield identical normalized identity multisets, and the - unit lane is disjoint from them. (Checked by `reconcile`.) -5. `CoreBackend.*_TAG == coreBackendTestTags[*]`. (Not machine-checked; documented on both - sides.) - ---- - -## Appendix C: How to run core tests locally - -This is the contributor-facing walkthrough; `docs/how-to-test.md` covers the repo-wide -`./gradlew test` task and does not mention `core` specifically, so it was left unchanged and this -appendix is the source of truth for the lanes instead. - -### Running a lane - -```bash -./gradlew :core:coreUnitTest # no database, no Docker - the default -./gradlew :core:coreH2Test # H2-backed tests, no Docker -./gradlew :core:coreMySQLTest -PskipDockerTests=false # MySQL-backed, needs Docker -./gradlew :core:corePostgreSQLTest -PskipDockerTests=false # PostgreSQL-backed, needs Docker -``` - -`coreMySQLTest`/`corePostgreSQLTest` fail fast with a clear `GradleException` if Docker isn't -running and `-PskipDockerTests=false` wasn't passed - they won't silently no-op. `coreH2Test` needs -neither Docker nor that flag. Each lane runs its `Test` task sequentially -(`maxParallelForks = 1`) because database tests mutate process-wide state. - -Do **not** run `./gradlew :core:test` - it is deprecated in place (Q4/A.2): it still works, but -warns and does not correspond to any of the four lanes above or any CI job. - -### Tagging a new test class - -Pick the annotation that matches where the class needs to run, from -`org.apache.gravitino.storage.relational.CoreBackend`: - -```java -@CoreBackend.All // against all three backends - the default choice -public abstract class MyMultiBackendTest { ... } - -@CoreBackend.H2 // only against H2 - a genuinely H2-only test -public class MyH2OnlyTest { ... } -``` - -No annotation at all means the class is a plain unit test and runs only in `coreUnitTest`. - -**Use `@CoreBackend.All` unless the class is genuinely single-backend.** Stacking a subset (e.g. -`@CoreBackend.H2 @CoreBackend.MySQL`) compiles and each lane it names runs the class, but CI's -`reconcile` step then requires a *normalized sibling* in every backend lane it omits (Appendix -A.1/A.4, `CoreBackend`'s Javadoc) - today the only shape that satisfies that is one `@Nested` -class per backend under a shared outer class, following -`TestJdbcPartitionStatisticStorageIT`'s `H2Test`/`MySQLTest`/`PostgreSQLTest` pattern exactly -(`core_test_identity.py`'s normalization is hard-coded to that one outer class name - see B.3). A -standalone partial-backend class without that sibling structure passes locally in the lanes it -runs in and then fails `reconcile` in CI with "Database identity mismatch". If in doubt, use -`@CoreBackend.All`. See A.1's usage-pattern table for the three concrete shapes already in the -codebase (parameter-provider, `@TestTemplate`, and one `@Nested` class per backend). - -### Checking where a class lands, before running anything - -```bash -./gradlew :core:coreTestLaneOf -PclassName=org.apache.gravitino.storage.relational.TestJDBCBackend -``` - -Compiles test sources (nothing else) and prints the class's tags and the lane(s) it runs in, or an -explicit warning if it carries `gravitino-docker-test` with no `@CoreBackend.*` annotation - the -one case that silently drops a class out of every lane (Q6). Run this after adding or changing an -annotation on a database-touching test class, before pushing. diff --git a/dev/ci/core_test_identity.py b/dev/ci/core_test_identity.py deleted file mode 100644 index c11c22cfe16..00000000000 --- a/dev/ci/core_test_identity.py +++ /dev/null @@ -1,509 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -"""Build and reconcile normalized core-test identity manifests. - -Gradle writes one JUnit XML directory per Test task. This tool turns those -reports into stable, backend-neutral identity multisets so the H2, MySQL, and -PostgreSQL lanes can prove that they exercised the same test contract. It also -records status counts and elapsed test time for CI artifacts. -""" - -import argparse -from collections import Counter -from decimal import Decimal, InvalidOperation -import hashlib -import json -import math -from pathlib import Path -import re -import sys -import xml.etree.ElementTree as ET - - -SCHEMA_VERSION = 1 -LANES = ("unit", "h2", "mysql", "postgresql") -DATABASE_LANES = LANES[1:] -MANIFEST_LANES = LANES -STATUS_KEYS = ("passed", "skipped", "failures", "errors") - -BACKEND_NAME_PATTERN = r"h2|mysql|postgresql" -TEST_TEMPLATE_BACKEND_RE = re.compile( - rf"\[(?P{BACKEND_NAME_PATTERN})\s+Backend\]", re.IGNORECASE -) -STATS_BACKEND_CLASS_RE = re.compile( - rf"(?PTestJdbcPartitionStatisticStorageIT)\$" - rf"(?P{BACKEND_NAME_PATTERN})Test(?=$|\$)", - re.IGNORECASE, -) -BACKEND_TOKEN_RE = re.compile( - rf"(?{BACKEND_NAME_PATTERN})(?![A-Za-z0-9])", - re.IGNORECASE, -) -TRAILING_INVOCATION_INDEX_RE = re.compile( - r"\[(?:#)?\d+\](?=(?:\s*\[BACKEND Backend\])?\s*$)", re.IGNORECASE -) - - -class ManifestError(ValueError): - """Raised when test results cannot form a trustworthy manifest.""" - - -def _local_name(tag): - """Return an XML element name without its optional namespace.""" - return tag.rsplit("}", 1)[-1] - - -def _canonical_backend(value): - """Return the canonical spelling of a recognized backend.""" - return value.lower() - - -def _classname_backend_markers(value): - """Find backend markers in the backend-specific nested stats classes.""" - return { - _canonical_backend(match.group("backend")) - for match in STATS_BACKEND_CLASS_RE.finditer(value) - } - - -def _test_name_backend_markers(value): - """Find structured backend markers in a testcase name.""" - markers = { - _canonical_backend(match.group("backend")) - for match in TEST_TEMPLATE_BACKEND_RE.finditer(value) - } - markers.update( - _canonical_backend(match.group("backend")) - for match in BACKEND_TOKEN_RE.finditer(value) - ) - return markers - - -def _normalize_classname(value): - """Normalize backend-specific nested stats class names.""" - return STATS_BACKEND_CLASS_RE.sub( - lambda match: f"{match.group('prefix')}$BackendTest", value - ) - - -def _normalize_test_name(value): - """Normalize backend markers and trailing parameterized indices.""" - normalized = TEST_TEMPLATE_BACKEND_RE.sub("[BACKEND Backend]", value) - normalized = BACKEND_TOKEN_RE.sub("BACKEND", normalized) - return TRAILING_INVOCATION_INDEX_RE.sub("[INDEX]", normalized) - - -def normalize_identity(lane, classname, test_name): - """Validate lane markers and return a normalized test identity pair.""" - classname = (classname or "").strip() - test_name = (test_name or "").strip() - if not classname or not test_name: - raise ManifestError("Every must have non-empty classname and name attributes") - - markers = _classname_backend_markers(classname) | _test_name_backend_markers(test_name) - if lane == "unit" and markers: - raise ManifestError( - "Unit test result contains an explicit backend marker " - f"{sorted(markers)}: {classname}::{test_name}" - ) - if lane in DATABASE_LANES: - foreign_markers = markers - {lane} - if foreign_markers: - raise ManifestError( - f"{lane} test result contains foreign backend marker(s) " - f"{sorted(foreign_markers)}: {classname}::{test_name}" - ) - - return ( - _normalize_classname(classname), - _normalize_test_name(test_name), - ) - - -def _testcase_status(testcase): - """Classify one JUnit testcase element.""" - child_tags = {_local_name(child.tag) for child in testcase} - if "failure" in child_tags: - return "failures" - if "error" in child_tags: - return "errors" - if "skipped" in child_tags: - return "skipped" - return "passed" - - -def _testcase_duration(testcase, source_file): - """Parse one JUnit testcase duration as a non-negative Decimal.""" - value = testcase.get("time", "0") - try: - duration = Decimal(value) - except InvalidOperation as error: - raise ManifestError( - f"Invalid testcase duration {value!r} in {source_file}" - ) from error - if not duration.is_finite() or duration < 0: - raise ManifestError(f"Invalid testcase duration {value!r} in {source_file}") - return duration - - -def _identities_as_json(identities): - """Convert an identity Counter to deterministic JSON records.""" - return [ - {"classname": classname, "name": name, "count": count} - for (classname, name), count in sorted(identities.items()) - ] - - -def _identities_from_json(manifest, source_file): - """Validate and restore an identity Counter from a manifest.""" - records = manifest.get("identities") - if not isinstance(records, list): - raise ManifestError(f"Manifest {source_file} has no identities list") - - identities = Counter() - for record in records: - if not isinstance(record, dict): - raise ManifestError(f"Manifest {source_file} has an invalid identity record") - classname = record.get("classname") - name = record.get("name") - count = record.get("count") - if not isinstance(classname, str) or not classname: - raise ManifestError(f"Manifest {source_file} has an invalid classname") - if not isinstance(name, str) or not name: - raise ManifestError(f"Manifest {source_file} has an invalid testcase name") - if not isinstance(count, int) or isinstance(count, bool) or count <= 0: - raise ManifestError(f"Manifest {source_file} has an invalid identity count") - identity = (classname, name) - if identity in identities: - raise ManifestError(f"Manifest {source_file} repeats identity {identity}") - identities[identity] = count - return identities - - -def _identity_digest(identities): - """Return a stable digest of an identity multiset.""" - digest = hashlib.sha256() - for (classname, name), count in sorted(identities.items()): - digest.update(classname.encode("utf-8")) - digest.update(b"\0") - digest.update(name.encode("utf-8")) - digest.update(b"\0") - digest.update(str(count).encode("ascii")) - digest.update(b"\n") - return digest.hexdigest() - - -def build_manifest(lane, results_directory): - """Parse Gradle JUnit XML reports and return a normalized manifest.""" - if lane not in MANIFEST_LANES: - raise ManifestError( - f"Unknown lane {lane!r}; expected one of {', '.join(MANIFEST_LANES)}" - ) - - results_directory = Path(results_directory) - if not results_directory.is_dir(): - raise ManifestError(f"Results directory does not exist: {results_directory}") - - xml_files = sorted(results_directory.rglob("TEST-*.xml")) - if not xml_files: - raise ManifestError(f"No TEST-*.xml files found under {results_directory}") - - identities = Counter() - statuses = Counter({key: 0 for key in STATUS_KEYS}) - duration = Decimal("0") - source_files = [] - - for xml_file in xml_files: - relative_source = xml_file.relative_to(results_directory).as_posix() - source_files.append(relative_source) - try: - root = ET.parse(xml_file).getroot() - except (ET.ParseError, OSError) as error: - raise ManifestError(f"Could not parse {xml_file}: {error}") from error - - testcases = ( - element - for element in root.iter() - if _local_name(element.tag) == "testcase" - ) - for testcase in testcases: - identity = normalize_identity( - lane, testcase.get("classname"), testcase.get("name") - ) - identities[identity] += 1 - statuses[_testcase_status(testcase)] += 1 - duration += _testcase_duration(testcase, relative_source) - - test_count = sum(identities.values()) - if test_count == 0: - raise ManifestError(f"No entries found under {results_directory}") - if statuses["failures"] or statuses["errors"]: - raise ManifestError( - f"Lane {lane} contains {statuses['failures']} failure(s) and " - f"{statuses['errors']} error(s)" - ) - - return { - "schema_version": SCHEMA_VERSION, - "lane": lane, - "successful": True, - "test_count": test_count, - "unique_identity_count": len(identities), - "duration_seconds": float(duration), - "status_counts": {key: statuses[key] for key in STATUS_KEYS}, - "source_files": source_files, - "identity_digest": _identity_digest(identities), - "identities": _identities_as_json(identities), - } - - -def write_json(document, output_file): - """Write one deterministic JSON document.""" - output_file = Path(output_file) - output_file.parent.mkdir(parents=True, exist_ok=True) - with output_file.open("w", encoding="utf-8") as output: - json.dump(document, output, indent=2, sort_keys=True) - output.write("\n") - - -def _load_manifest(manifest_file): - """Load and validate the common fields of one manifest.""" - manifest_file = Path(manifest_file) - try: - with manifest_file.open(encoding="utf-8") as source: - manifest = json.load(source) - except (OSError, json.JSONDecodeError) as error: - raise ManifestError(f"Could not read manifest {manifest_file}: {error}") from error - - if not isinstance(manifest, dict): - raise ManifestError(f"Manifest {manifest_file} must contain a JSON object") - if manifest.get("schema_version") != SCHEMA_VERSION: - raise ManifestError(f"Manifest {manifest_file} has an unsupported schema version") - lane = manifest.get("lane") - if lane not in MANIFEST_LANES: - raise ManifestError(f"Manifest {manifest_file} has invalid lane {lane!r}") - if manifest.get("successful") is not True: - raise ManifestError(f"Manifest {manifest_file} is not successful") - - identities = _identities_from_json(manifest, manifest_file) - test_count = manifest.get("test_count") - if not isinstance(test_count, int) or isinstance(test_count, bool) or test_count <= 0: - raise ManifestError(f"Manifest {manifest_file} has invalid test_count") - if sum(identities.values()) != test_count: - raise ManifestError(f"Manifest {manifest_file} identity counts do not match test_count") - - unique_identity_count = manifest.get("unique_identity_count") - if ( - not isinstance(unique_identity_count, int) - or isinstance(unique_identity_count, bool) - or unique_identity_count <= 0 - or unique_identity_count != len(identities) - ): - raise ManifestError( - f"Manifest {manifest_file} has invalid unique_identity_count" - ) - - statuses = manifest.get("status_counts") - if not isinstance(statuses, dict): - raise ManifestError(f"Manifest {manifest_file} has invalid status_counts") - for key in STATUS_KEYS: - value = statuses.get(key) - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - raise ManifestError(f"Manifest {manifest_file} has invalid status {key}") - if sum(statuses[key] for key in STATUS_KEYS) != test_count: - raise ManifestError(f"Manifest {manifest_file} statuses do not match test_count") - if statuses["failures"] or statuses["errors"]: - raise ManifestError(f"Manifest {manifest_file} contains failed tests") - - duration = manifest.get("duration_seconds") - if ( - not isinstance(duration, (int, float)) - or isinstance(duration, bool) - or not math.isfinite(duration) - or duration < 0 - ): - raise ManifestError(f"Manifest {manifest_file} has invalid duration_seconds") - - source_files = manifest.get("source_files") - if ( - not isinstance(source_files, list) - or not source_files - or any(not isinstance(source, str) or not source for source in source_files) - or len(set(source_files)) != len(source_files) - ): - raise ManifestError(f"Manifest {manifest_file} has invalid source_files") - - identity_digest = manifest.get("identity_digest") - if identity_digest != _identity_digest(identities): - raise ManifestError(f"Manifest {manifest_file} has invalid identity_digest") - - return lane, manifest, identities - - -def _format_identity_difference(reference, actual): - """Format a bounded explanation of a Counter mismatch.""" - differences = [] - for label, values in (("missing", reference - actual), ("extra", actual - reference)): - for (classname, name), count in sorted(values.items())[:5]: - differences.append(f"{label} {count} x {classname}::{name}") - return "; ".join(differences) - - -def _format_identities(identities): - """Format a bounded identity Counter for an error message.""" - return "; ".join( - f"{count} x {classname}::{name}" - for (classname, name), count in sorted(identities.items())[:5] - ) - - -def _load_split_manifests(manifest_files): - """Load exactly one trustworthy manifest for each split lane.""" - manifest_files = [Path(path) for path in manifest_files] - if len(manifest_files) != len(LANES): - raise ManifestError(f"Expected exactly {len(LANES)} manifests, got {len(manifest_files)}") - - by_lane = {} - counters = {} - for manifest_file in manifest_files: - lane, manifest, identities = _load_manifest(manifest_file) - if lane not in LANES: - raise ManifestError( - f"Expected a split-lane manifest, got lane {lane} from {manifest_file}" - ) - if lane in by_lane: - raise ManifestError(f"Received more than one manifest for lane {lane}") - by_lane[lane] = manifest - counters[lane] = identities - - missing_lanes = set(LANES) - set(by_lane) - if missing_lanes: - raise ManifestError(f"Missing manifest lane(s): {', '.join(sorted(missing_lanes))}") - return by_lane, counters - - -def _lane_summary(manifest, identities): - """Return the evidence retained for one successfully loaded lane.""" - return { - "test_count": manifest["test_count"], - "unique_identity_count": manifest["unique_identity_count"], - "duration_seconds": manifest["duration_seconds"], - "status_counts": manifest["status_counts"], - "source_file_count": len(manifest["source_files"]), - "source_files": manifest["source_files"], - "identity_digest": _identity_digest(identities), - } - - -def reconcile_manifests(manifest_files): - """Require four lanes and reconcile the three database identity multisets.""" - by_lane, counters = _load_split_manifests(manifest_files) - - reference = counters["h2"] - for lane in DATABASE_LANES[1:]: - if counters[lane] != reference: - difference = _format_identity_difference(reference, counters[lane]) - raise ManifestError( - f"Database identity mismatch between h2 and {lane}: {difference}" - ) - - unit_database_overlap = counters["unit"] & reference - if unit_database_overlap: - raise ManifestError( - "Unit/database identity overlap: " - f"{_format_identities(unit_database_overlap)}" - ) - - combined_statuses = { - key: sum(by_lane[lane]["status_counts"][key] for lane in LANES) - for key in STATUS_KEYS - } - lane_summaries = { - lane: _lane_summary(by_lane[lane], counters[lane]) for lane in LANES - } - - return { - "schema_version": SCHEMA_VERSION, - "successful": True, - "database_identities_equal": True, - "unit_database_disjoint": True, - "database_test_count_per_lane": sum(reference.values()), - "database_unique_identity_count": len(reference), - "database_identity_digest": _identity_digest(reference), - "combined_test_count": sum(by_lane[lane]["test_count"] for lane in LANES), - "combined_duration_seconds": float( - sum( - ( - Decimal(str(by_lane[lane]["duration_seconds"])) - for lane in LANES - ), - Decimal("0"), - ) - ), - "combined_status_counts": combined_statuses, - "lanes": lane_summaries, - } - - -def _create_argument_parser(): - """Create the command-line parser.""" - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - manifest_parser = subparsers.add_parser( - "manifest", help="Create one normalized manifest from Gradle JUnit XML" - ) - manifest_parser.add_argument("--lane", required=True, choices=MANIFEST_LANES) - manifest_parser.add_argument( - "--results", required=True, type=Path, help="Directory containing TEST-*.xml" - ) - manifest_parser.add_argument( - "--output", required=True, type=Path, help="JSON manifest to write" - ) - - reconcile_parser = subparsers.add_parser( - "reconcile", help="Reconcile unit and database lane manifests" - ) - reconcile_parser.add_argument( - "--manifests", required=True, nargs="+", type=Path, help="The four lane manifests" - ) - reconcile_parser.add_argument( - "--output", required=True, type=Path, help="Combined JSON summary to write" - ) - return parser - - -def main(argv=None): - """Run the command-line interface.""" - parser = _create_argument_parser() - args = parser.parse_args(argv) - try: - if args.command == "manifest": - document = build_manifest(args.lane, args.results) - else: - document = reconcile_manifests(args.manifests) - write_json(document, args.output) - except ManifestError as error: - print(f"error: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml deleted file mode 100644 index 7b6bfdd4bbc..00000000000 --- a/dev/ci/tests/fixtures/core_test_identity/h2/TEST-backend.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml deleted file mode 100644 index 47a84c52d1e..00000000000 --- a/dev/ci/tests/fixtures/core_test_identity/mysql/TEST-backend.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml b/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml deleted file mode 100644 index dd1aad76ca3..00000000000 --- a/dev/ci/tests/fixtures/core_test_identity/postgresql/TEST-backend.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - diff --git a/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml b/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml deleted file mode 100644 index 7c169da3478..00000000000 --- a/dev/ci/tests/fixtures/core_test_identity/unit/TEST-unit.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - diff --git a/dev/ci/tests/test_core_test_identity.py b/dev/ci/tests/test_core_test_identity.py deleted file mode 100644 index 23959b4bda5..00000000000 --- a/dev/ci/tests/test_core_test_identity.py +++ /dev/null @@ -1,331 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -import copy -from collections import Counter -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest - - -SCRIPT_PATH = Path(__file__).parents[1] / "core_test_identity.py" -SPEC = importlib.util.spec_from_file_location("core_test_identity", SCRIPT_PATH) -core_test_identity = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(core_test_identity) - -FIXTURES = Path(__file__).parent / "fixtures" / "core_test_identity" - - -def identity_counter(manifest): - """Return the manifest identities in their natural Counter form.""" - return Counter( - { - (record["classname"], record["name"]): record["count"] - for record in manifest["identities"] - } - ) - - -def write_report(directory, testcases): - """Write a minimal Gradle-compatible JUnit XML report.""" - directory.mkdir(parents=True, exist_ok=True) - (directory / "TEST-fixture.xml").write_text( - "\n" - f"{testcases}\n", - encoding="utf-8", - ) - - -class TestCoreTestIdentity(unittest.TestCase): - def test_manifest_normalizes_database_identities_as_multisets(self): - manifests = { - lane: core_test_identity.build_manifest(lane, FIXTURES / lane) - for lane in core_test_identity.LANES - } - - database_counters = [ - identity_counter(manifests[lane]) - for lane in core_test_identity.DATABASE_LANES - ] - self.assertEqual(database_counters[0], database_counters[1]) - self.assertEqual(database_counters[0], database_counters[2]) - self.assertEqual( - database_counters[0][ - ( - "org.apache.gravitino.stats.storage." - "TestJdbcPartitionStatisticStorageIT$BackendTest", - "writesPartitionStats()[INDEX]", - ) - ], - 2, - ) - self.assertIn( - ( - "org.apache.gravitino.TestCatalogMetaService", - "testCreateCatalog()[BACKEND Backend]", - ), - database_counters[0], - ) - self.assertIn( - ( - "org.apache.gravitino.TestCatalogMetaService", - "testDropCatalog()[BACKEND Backend]", - ), - database_counters[0], - ) - self.assertIn( - ("org.apache.gravitino.BackendTokenTest", "roundTrip[BACKEND]"), - database_counters[0], - ) - self.assertIn( - ( - "org.apache.gravitino.UnmarkedBackendTest", - "unmarkedSharedCase()", - ), - database_counters[0], - ) - - unit = manifests["unit"] - self.assertEqual(unit["test_count"], 3) - self.assertEqual(unit["duration_seconds"], 0.7) - self.assertEqual(unit["status_counts"]["passed"], 2) - self.assertEqual(unit["status_counts"]["skipped"], 1) - self.assertEqual(unit["source_files"], ["TEST-unit.xml"]) - self.assertIn( - ( - "org.apache.gravitino.TestH2ExceptionConverter", - "testH2Converter()", - ), - identity_counter(unit), - ) - self.assertIn( - ( - "org.apache.gravitino.storage.relational.mapper.provider.postgresql." - "TestCatalogMetaPostgreSQLProvider", - "testInsertSql()", - ), - identity_counter(unit), - ) - - def test_lane_validation_rejects_explicit_wrong_backend_markers(self): - cases = ( - ( - "unit", - '', - "Unit test result contains an explicit backend marker", - ), - ( - "h2", - '', - "foreign backend marker", - ), - ( - "postgresql", - '', - "foreign backend marker", - ), - ) - for lane, testcase, message in cases: - with self.subTest(lane=lane), tempfile.TemporaryDirectory() as temp_dir: - results = Path(temp_dir) - write_report(results, testcase) - with self.assertRaisesRegex(core_test_identity.ManifestError, message): - core_test_identity.build_manifest(lane, results) - - def test_manifest_fails_closed_on_missing_or_untrustworthy_results(self): - with tempfile.TemporaryDirectory() as temp_dir: - with self.assertRaisesRegex(core_test_identity.ManifestError, "No TEST-"): - core_test_identity.build_manifest("unit", temp_dir) - - invalid_cases = ( - ("", "No "), - ("", "Could not parse"), - ( - '', - "contains 1 failure", - ), - ( - '', - "and 1 error", - ), - ) - for xml, message in invalid_cases: - with self.subTest(message=message), tempfile.TemporaryDirectory() as temp_dir: - results = Path(temp_dir) - (results / "TEST-invalid.xml").write_text(xml, encoding="utf-8") - with self.assertRaisesRegex(core_test_identity.ManifestError, message): - core_test_identity.build_manifest("unit", results) - - def test_reconcile_emits_combined_timing_and_identity_summary(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - manifest_files = [] - for lane in core_test_identity.LANES: - manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - manifest_files.append(manifest_file) - - summary = core_test_identity.reconcile_manifests(manifest_files) - - self.assertTrue(summary["successful"]) - self.assertTrue(summary["database_identities_equal"]) - self.assertTrue(summary["unit_database_disjoint"]) - self.assertEqual(summary["database_test_count_per_lane"], 6) - self.assertEqual(summary["database_unique_identity_count"], 5) - self.assertEqual(summary["combined_test_count"], 21) - self.assertEqual(summary["combined_duration_seconds"], 7.3) - self.assertEqual(summary["combined_status_counts"]["skipped"], 2) - self.assertEqual(set(summary["lanes"]), set(core_test_identity.LANES)) - self.assertEqual( - summary["lanes"]["h2"]["source_files"], ["TEST-backend.xml"] - ) - - def test_reconcile_requires_exactly_four_matching_lanes(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - manifests = {} - for lane in core_test_identity.LANES: - manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - manifests[lane] = manifest_file - - with self.assertRaisesRegex(core_test_identity.ManifestError, "exactly 4"): - core_test_identity.reconcile_manifests(list(manifests.values())[:3]) - - mismatched = copy.deepcopy( - core_test_identity.build_manifest("mysql", FIXTURES / "mysql") - ) - mismatched["identities"][0]["name"] += "-different" - mismatched["identity_digest"] = core_test_identity._identity_digest( - identity_counter(mismatched) - ) - mismatched_file = output_directory / "mysql-mismatched.json" - core_test_identity.write_json(mismatched, mismatched_file) - with self.assertRaisesRegex( - core_test_identity.ManifestError, "Database identity mismatch" - ): - core_test_identity.reconcile_manifests( - [ - manifests["unit"], - manifests["h2"], - mismatched_file, - manifests["postgresql"], - ] - ) - - with self.assertRaisesRegex( - core_test_identity.ManifestError, "more than one manifest for lane h2" - ): - core_test_identity.reconcile_manifests( - [ - manifests["unit"], - manifests["h2"], - manifests["h2"], - manifests["postgresql"], - ] - ) - - def test_reconcile_rejects_unit_database_overlap(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - overlapping_results = output_directory / "overlapping-unit-results" - write_report( - overlapping_results, - '', - ) - - manifest_files = [] - for lane in core_test_identity.LANES: - results = ( - overlapping_results if lane == "unit" else FIXTURES / lane - ) - manifest = core_test_identity.build_manifest(lane, results) - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - manifest_files.append(manifest_file) - - with self.assertRaisesRegex( - core_test_identity.ManifestError, "Unit/database identity overlap" - ): - core_test_identity.reconcile_manifests(manifest_files) - - def test_reconcile_rejects_tampered_identity_evidence(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - manifest_files = [] - for lane in core_test_identity.LANES: - manifest = core_test_identity.build_manifest(lane, FIXTURES / lane) - if lane == "h2": - manifest["identity_digest"] = "0" * 64 - manifest_file = output_directory / f"{lane}.json" - core_test_identity.write_json(manifest, manifest_file) - manifest_files.append(manifest_file) - - with self.assertRaisesRegex( - core_test_identity.ManifestError, "invalid identity_digest" - ): - core_test_identity.reconcile_manifests(manifest_files) - - def test_cli_writes_manifest_and_reconciliation_output(self): - with tempfile.TemporaryDirectory() as temp_dir: - output_directory = Path(temp_dir) - manifest_files = [] - for lane in core_test_identity.LANES: - manifest_file = output_directory / f"{lane}.json" - return_code = core_test_identity.main( - [ - "manifest", - "--lane", - lane, - "--results", - str(FIXTURES / lane), - "--output", - str(manifest_file), - ] - ) - self.assertEqual(return_code, 0) - self.assertTrue(manifest_file.is_file()) - manifest_files.append(manifest_file) - - summary_file = output_directory / "summary.json" - return_code = core_test_identity.main( - [ - "reconcile", - "--manifests", - *(str(path) for path in manifest_files), - "--output", - str(summary_file), - ] - ) - self.assertEqual(return_code, 0) - with summary_file.open(encoding="utf-8") as source: - summary = json.load(source) - self.assertTrue(summary["database_identities_equal"]) - - -if __name__ == "__main__": - unittest.main() From 27ec2cb3fca5ae84d3d8020c921f0e47a5d6386c Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 11:50:18 -0700 Subject: [PATCH 20/30] docs(test): note that the core lane, not the class, decides Docker CoreBackend tags choose the lane; MySQL and PostgreSQL need Docker and H2 does not. gravitino-docker-test on a backend class only keeps it out of coreUnitTest and of legacy :core:test, so an H2-tagged class must not need a container. --- .../apache/gravitino/storage/relational/CoreBackend.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java index c1f64aef34c..80c10d93dea 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -50,6 +50,12 @@ * core/build.gradle.kts} only needs the tag strings themselves and no execution-time condition is * involved. {@link #H2_TAG}, {@link #MYSQL_TAG}, and {@link #POSTGRESQL_TAG} must stay in sync with * {@code coreBackendTestTags} in {@code core/build.gradle.kts}. + * + *

    These tags choose the lane; whether a container is needed follows from the lane, not the + * class. The MySQL and PostgreSQL lanes require Docker and fail fast without it; the H2 lane runs + * without Docker. {@code gravitino-docker-test} on a backend class only keeps it out of {@code + * coreUnitTest}, and out of legacy {@code :core:test} when Docker tests are skipped, so a class + * tagged for H2 must not need a container itself. */ public final class CoreBackend { From 95ecb6c0dcc847e5018e9d5d79b3986370a5099c Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 11:54:43 -0700 Subject: [PATCH 21/30] docs(test): tighten core lane comments Say that an untagged Docker class runs in no lane task (legacy :core:test still runs it with Docker on), say plainly why lane annotations must not carry an execution-time hook, and name what the includeDockerTaggedTests flag actually controls. --- core/build.gradle.kts | 7 ++++--- .../apache/gravitino/storage/relational/CoreBackend.java | 4 ++-- .../relational/TestCoreDatabaseLaneAnnotations.java | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 08ab76342f5..e6fea0cd9a5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -119,8 +119,8 @@ artifacts { // @CoreBackend.H2 @CoreBackend.MySQL -> runs in coreH2Test and coreMySQLTest // @CoreBackend.All -> runs in all three backend lanes // (no CoreBackend annotation at all) -> a plain unit test, runs in coreUnitTest -// A class needing Docker but carrying no backend tag runs in no lane at all, and -// TestCoreLaneMembership fails coreUnitTest for it. +// A class needing Docker but carrying no backend tag runs in no lane task (only in legacy +// :core:test with Docker on), and TestCoreLaneMembership fails coreUnitTest for it. // // Backend name -> JUnit tag that admits a test class to that backend's lane. Adding a backend // here is enough to teach the lane filtering below about it; also add it to CoreBackend.java. @@ -151,7 +151,8 @@ fun registerCoreTestTask( inputs.property("coreTestBackend", backend ?: "none") // Distinct from the extensions.extraProperties["includeDockerTaggedTests"] flag set below, // which is a different mechanism (read by root build.gradle.kts's shared test-environment - // setup to decide JUnit tag filtering) - this is only a Gradle up-to-date-check input. + // setup to decide whether to exclude gravitino-docker-test) - this is only a Gradle + // up-to-date-check input. inputs.property("coreTestIncludesDockerTaggedTests", backend != null) reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) reports.html.outputLocation.set( diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java index 80c10d93dea..765739b0d20 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -39,8 +39,8 @@ * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. *

  • {@link All} runs a class under all three lanes. *
  • A class with none of these is a unit test and runs in {@code coreUnitTest} - unless it also - * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane at all and - * {@code TestCoreLaneMembership} fails. + * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane task (only + * in legacy {@code :core:test} with Docker on) and {@code TestCoreLaneMembership} fails. * * *

    Each is a plain JUnit composed annotation: meta-annotated with {@link Tag} and nothing else, diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java index 6c5ca0de024..52b464ed7f8 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java @@ -42,7 +42,8 @@ * CoreBackend.PostgreSQL}, and {@link CoreBackend.All}: each must expand to exactly the tag(s) * {@code core/build.gradle.kts} filters on, for the annotated class and for its subclasses, using * the same annotation lookup the Jupiter engine runs at discovery time - and none may carry an - * execution-time hook, since that would repeat the mistake a prior, reverted design made. + * execution-time hook, since lane membership must be decided at discovery so each lane's JUnit XML + * lists only the tests it runs. */ class TestCoreDatabaseLaneAnnotations { From 15b30e0845a25b9d87f7d1cd0e577dfbe169525f Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 12:15:15 -0700 Subject: [PATCH 22/30] improvement(test): trim core lane test scaffolding Review asked to keep only the core changes, so remove what doesn't earn its keep: - TestCoreDatabaseLaneAnnotations only re-tested JUnit's own @Tag meta-annotation and @Inherited handling. The lanes themselves and TestCoreLaneMembership already prove the tags select the right tests. - The lane rules were spelled out three times (core/build.gradle.kts, CoreBackend, the :core:test block). CoreBackend's Javadoc is now the one place; the Gradle file points to it. - The coreTestIncludesDockerTaggedTests input property only repeated what the coreTestBackend input already tells Gradle's up-to-date check. --- core/build.gradle.kts | 36 +---- .../storage/relational/CoreBackend.java | 36 +---- .../TestCoreDatabaseLaneAnnotations.java | 138 ------------------ 3 files changed, 13 insertions(+), 197 deletions(-) delete mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java diff --git a/core/build.gradle.kts b/core/build.gradle.kts index e6fea0cd9a5..6343da32763 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -108,22 +108,9 @@ artifacts { add("testArtifacts", testJar) } -// Core's tests run in one of four Gradle lanes: coreUnitTest (default, no Docker) and -// coreH2Test/coreMySQLTest/corePostgreSQLTest (one per backend, coreMySQLTest and -// corePostgreSQLTest need Docker). Lane membership is decided purely by which of the three -// backend tags below a test class carries - see CoreBackend in -// core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java for the typed -// annotations (@CoreBackend.H2/.MySQL/.PostgreSQL/.All) that set them, instead of writing raw -// @Tag("...") strings by hand: -// @CoreBackend.H2 -> runs only in coreH2Test -// @CoreBackend.H2 @CoreBackend.MySQL -> runs in coreH2Test and coreMySQLTest -// @CoreBackend.All -> runs in all three backend lanes -// (no CoreBackend annotation at all) -> a plain unit test, runs in coreUnitTest -// A class needing Docker but carrying no backend tag runs in no lane task (only in legacy -// :core:test with Docker on), and TestCoreLaneMembership fails coreUnitTest for it. -// -// Backend name -> JUnit tag that admits a test class to that backend's lane. Adding a backend -// here is enough to teach the lane filtering below about it; also add it to CoreBackend.java. +// 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", @@ -149,11 +136,6 @@ fun registerCoreTestTask( inputs.property("coreTestSuite", backend ?: "unit") inputs.property("coreTestBackend", backend ?: "none") - // Distinct from the extensions.extraProperties["includeDockerTaggedTests"] flag set below, - // which is a different mechanism (read by root build.gradle.kts's shared test-environment - // setup to decide whether to exclude gravitino-docker-test) - this is only a Gradle - // up-to-date-check input. - inputs.property("coreTestIncludesDockerTaggedTests", backend != null) reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) reports.html.outputLocation.set( rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") @@ -234,16 +216,8 @@ tasks.named("jacocoTestReport") { } } -// :core:test is the java plugin's built-in `test` task, kept registered (and working) only for -// backward compatibility - IDEs and other tooling may still target it by convention. It is -// deprecated in place, not removed: -// - Dev CUJ: a contributor running tests locally should target one of the four lanes registered -// above (coreUnitTest / coreH2Test / coreMySQLTest / corePostgreSQLTest), never :core:test - -// it predates the lane split and does not correspond to any CI lane. -// - CI CUJ: no change needed here. build.yml's `build` job passes `-x :core:test` and runs each -// lane in its own `core-test` job. Backend IT still realizes it via the root `test` task under -// -PskipTests, where the `**/integration/test/**` filter matches nothing in core, so the -// warning below can also appear in those logs. +// :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. tasks.test { val policiesDocument = rootProject.file("docs/policies.md") inputs.file(policiesDocument) diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java index 765739b0d20..893bea308f8 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java @@ -27,35 +27,15 @@ import org.junit.jupiter.api.Tag; /** - * Namespace for the annotations that select which core database test lane(s) a class runs in. + * 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 core test suite is split into one Gradle task per backend ({@code coreH2Test}, {@code - * coreMySQLTest}, {@code corePostgreSQLTest}), and each lane simply includes the classes carrying - * its backend tag. A class runs under exactly the lanes it is tagged for - there is no separate "is - * this a database test" gatekeeper tag to keep in sync: - * - *

      - *
    • {@link H2}, {@link MySQL}, {@link PostgreSQL} pin a class to one lane; stack more than one - * to run under several lanes, e.g. {@code @CoreBackend.H2 @CoreBackend.MySQL}. - *
    • {@link All} runs a class under all three lanes. - *
    • A class with none of these is a unit test and runs in {@code coreUnitTest} - unless it also - * carries {@code @Tag("gravitino-docker-test")}, in which case it runs in no lane task (only - * in legacy {@code :core:test} with Docker on) and {@code TestCoreLaneMembership} fails. - *
    - * - *

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

    These tags choose the lane; whether a container is needed follows from the lane, not the - * class. The MySQL and PostgreSQL lanes require Docker and fail fast without it; the H2 lane runs - * without Docker. {@code gravitino-docker-test} on a backend class only keeps it out of {@code - * coreUnitTest}, and out of legacy {@code :core:test} when Docker tests are skipped, so a class - * tagged for H2 must not need a container itself. + *

    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 { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java deleted file mode 100644 index 52b464ed7f8..00000000000 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreDatabaseLaneAnnotations.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.gravitino.storage.relational; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.lang.annotation.Annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Tags; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.platform.commons.support.AnnotationSupport; - -/** - * Pins the contract of {@link CoreBackend.H2}, {@link CoreBackend.MySQL}, {@link - * CoreBackend.PostgreSQL}, and {@link CoreBackend.All}: each must expand to exactly the tag(s) - * {@code core/build.gradle.kts} filters on, for the annotated class and for its subclasses, using - * the same annotation lookup the Jupiter engine runs at discovery time - and none may carry an - * execution-time hook, since lane membership must be decided at discovery so each lane's JUnit XML - * lists only the tests it runs. - */ -class TestCoreDatabaseLaneAnnotations { - - private static Stream annotationsAndExpectedTags() { - return Stream.of( - new Object[] {H2Annotated.class, List.of(CoreBackend.H2_TAG)}, - new Object[] {MySQLAnnotated.class, List.of(CoreBackend.MYSQL_TAG)}, - new Object[] {PostgreSQLAnnotated.class, List.of(CoreBackend.POSTGRESQL_TAG)}, - new Object[] { - AllAnnotated.class, - List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, CoreBackend.POSTGRESQL_TAG) - }); - } - - // Named on {0} (the class under test) so the report reads e.g. "1: H2Annotated" rather than - // printing the expected tag list. - @ParameterizedTest(name = "{index}: {0}") - @MethodSource("annotationsAndExpectedTags") - void testExpandsToExpectedTags(Class annotatedClass, List expectedTags) { - assertEquals(expectedTags, tagsOf(annotatedClass)); - } - - @Test - void testStackingTwoAnnotationsRunsUnderBothLanes() { - assertEquals(List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG), tagsOf(H2AndMySQL.class)); - } - - @Test - void testSubclassInheritsTags() { - assertEquals(List.of(CoreBackend.H2_TAG), tagsOf(H2Subclass.class)); - assertEquals( - List.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, CoreBackend.POSTGRESQL_TAG), - tagsOf(AllSubclass.class)); - } - - @Test - void testNoneOfThemCarryAnExecutionTimeHook() { - // No @ExtendWith or similar on any of them: lane filtering stays a discovery-time tag - // match, so classes left out of a lane never appear in that lane's JUnit XML. - Set> tagOnlyMetaAnnotations = - Set.of( - Documented.class, - Inherited.class, - Retention.class, - Target.class, - Tag.class, - Tags.class); - for (Class annotationType : - List.of( - CoreBackend.H2.class, - CoreBackend.MySQL.class, - CoreBackend.PostgreSQL.class, - CoreBackend.All.class)) { - Set> metaAnnotations = - Arrays.stream(annotationType.getAnnotations()) - .map(Annotation::annotationType) - .collect(Collectors.toSet()); - assertEquals( - Set.of(), - metaAnnotations.stream() - .filter(type -> !tagOnlyMetaAnnotations.contains(type)) - .collect(Collectors.toSet()), - annotationType.getSimpleName() + " carries an unexpected, non-tag meta-annotation"); - } - } - - private static List tagsOf(Class testClass) { - return AnnotationSupport.findRepeatableAnnotations(testClass, Tag.class).stream() - .map(Tag::value) - .collect(Collectors.toList()); - } - - @CoreBackend.H2 - private static class H2Annotated {} - - @CoreBackend.MySQL - private static class MySQLAnnotated {} - - @CoreBackend.PostgreSQL - private static class PostgreSQLAnnotated {} - - @CoreBackend.All - private static class AllAnnotated {} - - @CoreBackend.H2 - @CoreBackend.MySQL - private static class H2AndMySQL {} - - private static class H2Subclass extends H2Annotated {} - - private static class AllSubclass extends AllAnnotated {} -} From 85a0c7f025df35f42d811dbf044f3deb6a3e54a2 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 12:35:06 -0700 Subject: [PATCH 23/30] improvement(test): drop core lane code that restates defaults or pins trivia Review asked to keep only the core changes: - Gradle already writes each Test task's JUnit XML to build/test-results/ and its JaCoCo data to build/jacoco/.exec, so the lanes no longer set those paths. The coreTestSuite and coreTestBackend inputs only repeated what includeTags and the backend system property already give the up-to-date check, and the three imports are Kotlin DSL defaults. - validateCoreSuiteCoverage checked that the four .exec files exist, which the coverage job already checks before it copies them. - TestBackendTestSelector no longer pins the six-row legacy matrix or the display-name string through a mocked ExtensionContext; the remaining tests cover selection, validation and the selected-backend matrix. --- core/build.gradle.kts | 28 +--------- .../storage/TestBackendTestSelector.java | 54 ------------------- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 6343da32763..c5dc99469fa 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,7 +1,4 @@ import net.ltgt.gradle.errorprone.errorprone -import org.gradle.api.tasks.testing.Test -import org.gradle.testing.jacoco.plugins.JacocoTaskExtension -import org.gradle.testing.jacoco.tasks.JacocoReport /* * Licensed to the Apache Software Foundation (ASF) under one @@ -134,17 +131,10 @@ fun registerCoreTestTask( testClassesDirs = sourceSets["test"].output.classesDirs classpath = sourceSets["test"].runtimeClasspath - inputs.property("coreTestSuite", backend ?: "unit") - inputs.property("coreTestBackend", backend ?: "none") - reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/$taskName")) reports.html.outputLocation.set( rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName") ) - extensions.configure { - destinationFile = layout.buildDirectory.file("jacoco/$taskName.exec").get().asFile - } - useJUnitPlatform { if (backend == null) { // Whatever carries no backend tag (and no Docker tag) is the unit suite. @@ -193,25 +183,9 @@ val coreSuiteTaskNames = listOf("coreUnitTest", "coreH2Test", "coreMySQLTest", "corePostgreSQLTest") val coreSuiteExecutionData = coreSuiteTaskNames.map { layout.buildDirectory.file("jacoco/$it.exec") } -val validateCoreSuiteCoverage by tasks.registering { - inputs.files(coreSuiteExecutionData) - - doLast { - val missingExecutionData = - coreSuiteExecutionData - .map { it.get().asFile } - .filterNot { it.isFile && it.length() > 0L } - if (missingExecutionData.isNotEmpty()) { - throw GradleException( - "Missing core JaCoCo execution data: ${missingExecutionData.joinToString()}" - ) - } - } -} - tasks.named("jacocoTestReport") { if (coreSuiteCoverage.get()) { - dependsOn(tasks.named("classes"), validateCoreSuiteCoverage) + dependsOn(tasks.named("classes")) executionData.setFrom(coreSuiteExecutionData) } } diff --git a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java index 642d70a1110..7eb4760f571 100644 --- a/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java +++ b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java @@ -21,23 +21,17 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.List; import java.util.Optional; -import org.apache.gravitino.storage.relational.BackendTestExtension; import org.apache.gravitino.storage.relational.BackendTestSelector; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.parallel.ResourceAccessMode; import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.api.parallel.Resources; -import org.mockito.Mockito; /** Tests backend selection for the core database test suites. */ @ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE) @@ -79,35 +73,6 @@ void testSelectionIsNormalizedAndValidated() { assertThrows(IllegalArgumentException.class, BackendTestSelector::selectedBackend); } - @Test - void testTemplateProviderUsesSelectedBackendAndMethodName() throws NoSuchMethodException { - System.setProperty(BACKEND_PROPERTY, "mysql"); - BackendTestExtension extension = new BackendTestExtension(); - - String firstDisplayName = - selectedInvocation(extension, "firstTemplateMethod").getDisplayName(1); - String secondDisplayName = - selectedInvocation(extension, "secondTemplateMethod").getDisplayName(1); - - assertEquals("firstTemplateMethod[MYSQL Backend]", firstDisplayName); - assertEquals("secondTemplateMethod[MYSQL Backend]", secondDisplayName); - assertNotEquals(firstDisplayName, secondDisplayName); - } - - @Test - void testStorageProviderPreservesLegacyMatrix() { - assertArrayEquals( - new Object[][] { - {"h2", true}, - {"h2", false}, - {"mysql", true}, - {"mysql", false}, - {"postgresql", true}, - {"postgresql", false} - }, - AbstractEntityStorageTest.storageProvider()); - } - @Test void testStorageProviderUsesSelectedBackend() { System.setProperty(BACKEND_PROPERTY, "postgresql"); @@ -116,23 +81,4 @@ void testStorageProviderUsesSelectedBackend() { new Object[][] {{"postgresql", true}, {"postgresql", false}}, AbstractEntityStorageTest.storageProvider()); } - - private static TestTemplateInvocationContext selectedInvocation( - BackendTestExtension extension, String methodName) throws NoSuchMethodException { - ExtensionContext context = Mockito.mock(ExtensionContext.class); - Mockito.when(context.getRequiredTestMethod()) - .thenReturn(TemplateMethods.class.getDeclaredMethod(methodName)); - - List invocations = - extension.provideTestTemplateInvocationContexts(context).toList(); - - assertEquals(1, invocations.size()); - return invocations.get(0); - } - - private static class TemplateMethods { - void firstTemplateMethod() {} - - void secondTemplateMethod() {} - } } From 7df1dae6801f65518c78bdaacdac9cfb3d956717 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 14:38:43 -0700 Subject: [PATCH 24/30] docs(test): describe the core test lanes in how-to-test Contributors had no guide to the new lanes: name the four tasks and when Docker is needed, how a class joins a lane through @CoreBackend, and how to check a class runs in the lane you expect. --- docs/how-to-test.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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. From 54815791a9d256b4ca3b6d11aa6b72c1bb416c30 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Tue, 13 Oct 2026 16:43:01 -0700 Subject: [PATCH 25/30] improvement(test): give coreUnitTest main's policies.md setup #13478 added docs/policies.md as an input and gravitino.repositoryRoot as a system property to :core:test. TestRestrictedRegoGoldenFile is a unit test, so CI runs it in coreUnitTest: give that task the same two settings. The database lanes never run it and get neither. --- core/build.gradle.kts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c5dc99469fa..2c846b6e481 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -192,11 +192,16 @@ tasks.named("jacocoTestReport") { // :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. -tasks.test { - val policiesDocument = rootProject.file("docs/policies.md") - inputs.file(policiesDocument) - systemProperty("gravitino.repositoryRoot", rootProject.projectDir.path) +// 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 " + From 4ef3fd5794aa1b67d5931c61cc130f055cca4584 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sun, 27 Sep 2026 01:43:41 -0700 Subject: [PATCH 26/30] test(core): reuse database fixtures by class --- .../job/TestJobManagerMultiNode.java | 3 + .../storage/AbstractEntityStorageTest.java | 12 +- .../gravitino/storage/TestSQLScripts.java | 3 + .../relational/BackendTestExtension.java | 369 ++++++++++++++--- .../storage/relational/DatabaseFixture.java | 42 ++ .../storage/relational/DatabaseIsolation.java | 32 ++ .../relational/TestBackendTestExtension.java | 390 ++++++++++++++++++ .../test/container/ContainerSuite.java | 17 +- .../test/container/TestContainerSuite.java | 48 +++ .../test/util/CloseContainerExtension.java | 23 +- 10 files changed, 846 insertions(+), 93 deletions(-) create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java create mode 100644 integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestContainerSuite.java diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java index 487645a10d9..8336560691f 100644 --- a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java +++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java @@ -51,6 +51,8 @@ import org.apache.gravitino.meta.JobEntity; import org.apache.gravitino.meta.JobTemplateEntity; import org.apache.gravitino.storage.RandomIdGenerator; +import org.apache.gravitino.storage.relational.DatabaseFixture; +import org.apache.gravitino.storage.relational.DatabaseIsolation; import org.apache.gravitino.storage.relational.RelationalEntityStore; import org.apache.gravitino.storage.relational.TestJDBCBackend; import org.apache.gravitino.utils.NameIdentifierUtil; @@ -66,6 +68,7 @@ * JobManager} and {@link LocalJobExecutor}, share the same relational metadata store. The status * pull and the cleanup are triggered manually, so that the test controls which node runs them when. */ +@DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) public class TestJobManagerMultiNode extends TestJDBCBackend { private static final String METALAKE = "metalake_job_multi_node"; diff --git a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java index f49c550f4e3..26498a1a498 100644 --- a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java +++ b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java @@ -79,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; @@ -107,13 +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); @@ -137,13 +138,6 @@ static Object[][] storageProvider() { .toArray(Object[][]::new); } - @AfterEach - void closeSuit() throws IOException { - // todo: refactor TestEntityStorage to extend TestJDBCBackend, otherwise, each test will start - // and stop the container suite. - ContainerSuite.getInstance().close(); - } - protected void init(String type, Config config) throws IllegalAccessException { Preconditions.checkArgument(StringUtils.isNotBlank(type)); File dir = new File(DB_DIR); diff --git a/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java b/core/src/test/java/org/apache/gravitino/storage/TestSQLScripts.java index 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 d442fb3113e..6ab1ec26efb 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 @@ -51,12 +51,15 @@ import org.apache.gravitino.storage.RandomIdGenerator; import org.apache.gravitino.storage.relational.service.EntityIdService; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; +import org.junit.jupiter.api.extension.TestWatcher; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,34 +74,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; + } } } @@ -114,8 +127,8 @@ public Stream provideTestTemplateInvocationContex Optional selectedBackend = BackendTestSelector.selectedBackend(); if (selectedBackend.isPresent()) { LOG.info("Running tests with the selected {} backend.", selectedBackend.get()); - return Stream.of(selectedBackend.get()) - .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + return createInvocationContexts( + testMethodName, Collections.singletonList(selectedBackend.get()), true); } List backendsToTest = new ArrayList<>(); @@ -131,17 +144,33 @@ public Stream provideTestTemplateInvocationContex "Running tests with H2 backend only. Set env var 'dockerTest=true' to include all backends."); } - return backendsToTest.stream() - .map(backendType -> new BackendInvocationContext(testMethodName, backendType)); + return createInvocationContexts(testMethodName, backendsToTest, false); + } + + Stream createInvocationContexts( + String testMethodName, List backends, boolean reuseBackend) { + return backends.stream() + .map( + backendType -> + new BackendInvocationContext( + testMethodName, backendType, reuseBackend, backendFactory)); } private static class BackendInvocationContext implements TestTemplateInvocationContext { private final String testMethodName; private final String backendType; - - public BackendInvocationContext(String testMethodName, String backendType) { + private final boolean reuseBackend; + private final BackendFactory backendFactory; + + private BackendInvocationContext( + String testMethodName, + String backendType, + boolean reuseBackend, + BackendFactory backendFactory) { this.testMethodName = testMethodName; this.backendType = backendType; + this.reuseBackend = reuseBackend; + this.backendFactory = backendFactory; } @Override @@ -154,35 +183,26 @@ public String getDisplayName(int invocationIndex) { @Override public List getAdditionalExtensions() { - return Collections.singletonList(new BackendSetupCallback(backendType)); + return Collections.singletonList( + new BackendSetupCallback(backendType, reuseBackend, backendFactory)); } } - private static class BackendSetupCallback implements BeforeEachCallback { - private final String backendType; - private final BaseIT baseIT = new BaseIT(); + @FunctionalInterface + interface BackendFactory { + BackendResource create(String backendType) throws Exception; + } - public BackendSetupCallback(String backendType) { - this.backendType = backendType; - } + @FunctionalInterface + interface BackendActivator { + void activate() throws Exception; + } + private static class DefaultBackendFactory implements BackendFactory { @Override - public void beforeEach(ExtensionContext context) throws Exception { - - // Lazy loading: If the backend of this type has not started, start it - RelationalBackend backend = startBackend(backendType); - - // Inject into the test instance - Object testInstance = context.getRequiredTestInstance(); - if (testInstance instanceof TestJDBCBackend) { - LOG.info("Injecting {} backend into test instance", backendType); - ((TestJDBCBackend) testInstance).setBackend(backend); - ((TestJDBCBackend) testInstance).setBackendType(backendType); - } - } - - private RelationalBackend startBackend(String type) throws Exception { - LOG.info("Initializing backend resource: {}", type); + public BackendResource create(String backendType) throws Exception { + BaseIT baseIT = new BaseIT(); + LOG.info("Initializing backend resource: {}", backendType); Config config = Mockito.mock(Config.class); Mockito.when(config.get(Configs.ENTITY_STORE)).thenReturn(Configs.RELATIONAL_ENTITY_STORE); Mockito.when(config.get(Configs.ENTITY_RELATIONAL_STORE)) @@ -194,20 +214,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"); @@ -228,15 +243,240 @@ private RelationalBackend startBackend(String type) throws Exception { Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER)).thenReturn("org.h2.Driver"); // Wrap it with a Wrapper so that files can be deleted during the clean process - backend = new H2BackendWrapper(config, jdbcPath); + backend = new H2BackendWrapper(jdbcPath); + } + + BackendResource resource = + new BackendResource( + backendType, + backend, + () -> { + FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true); + FieldUtils.writeField( + GravitinoEnv.getInstance(), "idGenerator", RandomIdGenerator.INSTANCE, true); + EntityIdService.initialize(new RelationalEntityStoreIdResolver()); + }); + try { + resource.activate(); + // Close the backend before initializing to make sure the singleton sqlSession is cleared. + backend.close(); + backend.initialize(config); + return resource; + } catch (Exception failure) { + try { + resource.close(); + } catch (Exception closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + } + + private static class BackendSetupCallback + implements BeforeEachCallback, + AfterEachCallback, + LifecycleMethodExecutionExceptionHandler, + TestWatcher { + private final String backendType; + private final boolean reuseBackend; + private final BackendFactory backendFactory; + private BackendResource backendResource; + private boolean closeAfterEach; + + private BackendSetupCallback( + String backendType, boolean reuseBackend, BackendFactory backendFactory) { + this.backendType = backendType; + this.reuseBackend = reuseBackend; + this.backendFactory = backendFactory; + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + DatabaseIsolation isolation = isolation(context); + if (isolation == DatabaseIsolation.DEDICATED_SERVER) { + throw new UnsupportedOperationException( + "DEDICATED_SERVER isolation is not implemented for core database tests"); } - // close the backend before initializing to make sure the singleton sqlSession - // has been cleared. - backend.close(); - backend.initialize(config); - EntityIdService.initialize(new RelationalEntityStoreIdResolver()); - return backend; + closeAfterEach = !reuseBackend || isolation == DatabaseIsolation.FRESH_NAMESPACE; + try { + if (reuseBackend && isolation == DatabaseIsolation.FRESH_NAMESPACE) { + closeClassBackend(context); + } + backendResource = closeAfterEach ? newBackendResource() : getOrCreateClassBackend(context); + backendResource.activate(); + injectBackend(context, backendResource.backend); + } catch (Exception e) { + cleanupAfterSetupFailure(e); + throw e; + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + if (closeAfterEach && backendResource != null) { + backendResource.close(); + backendResource = null; + } + } + + @Override + public void testAborted(ExtensionContext context, Throwable cause) { + poisonSharedFixture(); + } + + @Override + public void testFailed(ExtensionContext context, Throwable cause) { + poisonSharedFixture(); + } + + @Override + public void handleBeforeEachMethodExecutionException( + ExtensionContext context, Throwable throwable) throws Throwable { + poisonSharedFixture(); + throw throwable; + } + + @Override + public void handleAfterEachMethodExecutionException( + ExtensionContext context, Throwable throwable) throws Throwable { + poisonSharedFixture(); + throw throwable; + } + + private void injectBackend(ExtensionContext context, RelationalBackend backend) { + Object testInstance = context.getRequiredTestInstance(); + if (testInstance instanceof TestJDBCBackend) { + LOG.info("Injecting {} backend into test instance", backendType); + ((TestJDBCBackend) testInstance).setBackend(backend); + ((TestJDBCBackend) testInstance).setBackendType(backendType); + } + } + + private BackendResource getOrCreateClassBackend(ExtensionContext context) throws Exception { + ConcurrentHashMap backendMap = backendMap(context); + synchronized (backendMap) { + BackendResource resource = backendMap.get(backendType); + if (resource != null && resource.poisoned) { + backendMap.remove(backendType); + resource.close(); + resource = null; + } + if (resource == null) { + resource = newBackendResource(); + backendMap.put(backendType, resource); + } + return resource; + } + } + + private void closeClassBackend(ExtensionContext context) throws Exception { + ConcurrentHashMap backendMap = backendMap(context); + synchronized (backendMap) { + BackendResource resource = backendMap.remove(backendType); + if (resource != null) { + resource.close(); + } + } + } + + @SuppressWarnings("unchecked") + private ConcurrentHashMap backendMap(ExtensionContext context) { + ExtensionContext classContext = context; + while (classContext.getTestMethod().isPresent()) { + classContext = + classContext + .getParent() + .orElseThrow(() -> new IllegalStateException("Test class context is unavailable")); + } + + ConcurrentHashMap backendMap = + (ConcurrentHashMap) + classContext.getStore(NAMESPACE).get(STORE_KEY); + if (backendMap == null) { + throw new IllegalStateException("Backend fixture store is unavailable"); + } + return backendMap; + } + + private BackendResource newBackendResource() throws Exception { + return backendFactory.create(backendType); + } + + private void cleanupAfterSetupFailure(Exception failure) { + if (backendResource == null) { + return; + } + if (!closeAfterEach) { + backendResource.poisoned = true; + return; + } + try { + backendResource.close(); + } catch (Exception closeFailure) { + failure.addSuppressed(closeFailure); + } finally { + backendResource = null; + } + } + + private void poisonSharedFixture() { + if (!closeAfterEach && backendResource != null) { + backendResource.poisoned = true; + } + } + + private DatabaseIsolation isolation(ExtensionContext context) { + DatabaseFixture methodFixture = + context.getRequiredTestMethod().getAnnotation(DatabaseFixture.class); + if (methodFixture != null) { + return methodFixture.value(); + } + DatabaseFixture classFixture = + context.getRequiredTestClass().getAnnotation(DatabaseFixture.class); + return classFixture == null ? DatabaseIsolation.RESETTABLE_NAMESPACE : classFixture.value(); + } + } + + static class BackendResource { + private final String backendType; + private final RelationalBackend backend; + private final BackendActivator activator; + private boolean poisoned; + + BackendResource(String backendType, RelationalBackend backend, BackendActivator activator) { + this.backendType = backendType; + this.backend = backend; + this.activator = activator; + } + + private void activate() throws Exception { + activator.activate(); + } + + private void close() throws Exception { + LOG.info("Tearing down backend: {}", backendType); + Exception failure = null; + try { + backend.close(); + } catch (Exception e) { + failure = e; + } + try { + if (backend instanceof H2BackendWrapper) { + ((H2BackendWrapper) backend).cleanFile(); + } + } catch (Exception e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } } } @@ -244,9 +484,8 @@ private RelationalBackend startBackend(String type) throws Exception { public static class H2BackendWrapper extends JDBCBackend { private final String path; - public H2BackendWrapper(Config config, String path) { + public H2BackendWrapper(String path) { this.path = path; - super.initialize(config); } public void cleanFile() throws IOException { diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java new file mode 100644 index 00000000000..6c966af4b0c --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseFixture.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Declares the database-fixture isolation required by a test class or method. */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface DatabaseFixture { + + /** + * Returns the required database isolation. + * + * @return the isolation policy + */ + DatabaseIsolation value() default DatabaseIsolation.RESETTABLE_NAMESPACE; +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java new file mode 100644 index 00000000000..c87d27ca6ca --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseIsolation.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +/** Isolation policies for database-backed tests. */ +public enum DatabaseIsolation { + /** Reuse a class-scoped fixture and reset its tables before each test. */ + RESETTABLE_NAMESPACE, + + /** Create and close a fresh database backend for each test. */ + FRESH_NAMESPACE, + + /** Create a dedicated database server for each test. */ + DEDICATED_SERVER +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java new file mode 100644 index 00000000000..d9c3c30c142 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java @@ -0,0 +1,390 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.TestTemplateInvocationContext; +import org.junit.jupiter.api.extension.TestWatcher; + +class TestBackendTestExtension { + + @Test + void testSelectedBackendReusesClassFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(1, factory.backends.size()); + assertEquals(2, factory.activationCount); + verify(factory.backends.get(0), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(0), times(1)).close(); + } + + @Test + void testLegacyH2DoesNotReuseFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, false, contexts.methodContext("first")); + runInvocation(extension, false, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + + extension.afterAll(contexts.classContext); + } + + @Test + void testFreshClassCreatesAndClosesEachInvocation() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(FreshIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + + extension.afterAll(contexts.classContext); + } + + @Test + void testFreshMethodEvictsSharedFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(MixedIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("resettable")); + runInvocation(extension, true, contexts.methodContext("fresh")); + runInvocation(extension, true, contexts.methodContext("resettable")); + + assertEquals(3, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + verify(factory.backends.get(2), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(2), times(1)).close(); + } + + @Test + void testFailedSharedFixtureIsClosedAndRebuilt() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + ((AfterEachCallback) firstCallback).afterEach(firstContext); + ((TestWatcher) firstCallback).testFailed(firstContext, new AssertionError("expected")); + + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), never()).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testLifecycleFailurePoisonsSharedFixture() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + assertThrows( + IllegalStateException.class, + () -> + ((LifecycleMethodExecutionExceptionHandler) firstCallback) + .handleBeforeEachMethodExecutionException( + firstContext, new IllegalStateException("expected"))); + + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, factory.backends.size()); + verify(factory.backends.get(0), times(1)).close(); + + extension.afterAll(contexts.classContext); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testActivationFailurePoisonsSharedFixture() throws Exception { + List backends = new ArrayList<>(); + AtomicInteger activations = new AtomicInteger(); + BackendTestExtension.BackendFactory factory = + backendType -> { + RelationalBackend backend = mock(RelationalBackend.class); + backends.add(backend); + return new BackendTestExtension.BackendResource( + backendType, + backend, + () -> { + if (activations.incrementAndGet() == 2) { + throw new IllegalStateException("expected"); + } + }); + }; + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, contexts.methodContext("first")); + assertThrows( + IllegalStateException.class, + () -> startInvocation(extension, true, contexts.methodContext("second"))); + runInvocation(extension, true, contexts.methodContext("second")); + + assertEquals(2, backends.size()); + verify(backends.get(0), times(1)).close(); + + extension.afterAll(contexts.classContext); + verify(backends.get(1), times(1)).close(); + } + + @Test + void testDedicatedServerIsolationFailsFast() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DedicatedServerTests.class); + extension.beforeAll(contexts.classContext); + + assertThrows( + UnsupportedOperationException.class, + () -> startInvocation(extension, true, contexts.methodContext("first"))); + assertEquals(0, factory.backends.size()); + + extension.afterAll(contexts.classContext); + } + + @Test + void testAfterAllClosesEveryFixtureAndAggregatesFailures() throws Exception { + CountingBackendFactory factory = new CountingBackendFactory(); + BackendTestExtension extension = new BackendTestExtension(factory); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + + runInvocation(extension, true, "h2", contexts.methodContext("first")); + runInvocation(extension, true, "mysql", contexts.methodContext("second")); + doThrow(new IOException("h2 close")).when(factory.backends.get(0)).close(); + doThrow(new IOException("mysql close")).when(factory.backends.get(1)).close(); + + Exception failure = + assertThrows(Exception.class, () -> extension.afterAll(contexts.classContext)); + assertEquals(1, failure.getSuppressed().length); + verify(factory.backends.get(0), times(1)).close(); + verify(factory.backends.get(1), times(1)).close(); + } + + @Test + void testH2CleanupRunsAfterUncheckedBackendCloseFailure() throws Exception { + BackendTestExtension.H2BackendWrapper backend = + mock(BackendTestExtension.H2BackendWrapper.class); + BackendTestExtension extension = + new BackendTestExtension( + backendType -> + new BackendTestExtension.BackendResource(backendType, backend, () -> {})); + TestContexts contexts = new TestContexts(DefaultIsolationTests.class); + extension.beforeAll(contexts.classContext); + runInvocation(extension, true, contexts.methodContext("first")); + + doThrow(new IllegalStateException("close")).when(backend).close(); + doThrow(new IOException("clean")).when(backend).cleanFile(); + + Exception failure = + assertThrows(Exception.class, () -> extension.afterAll(contexts.classContext)); + assertEquals("close", failure.getMessage()); + assertEquals(1, failure.getSuppressed().length); + assertEquals("clean", failure.getSuppressed()[0].getMessage()); + verify(backend, times(1)).cleanFile(); + } + + @Test + void testDisplayNamePreservesMethodAndBackend() { + BackendTestExtension extension = + new BackendTestExtension( + backendType -> + new BackendTestExtension.BackendResource( + backendType, mock(RelationalBackend.class), () -> {})); + + TestTemplateInvocationContext context = + extension + .createInvocationContexts("testMethod", List.of("h2"), true) + .findFirst() + .orElseThrow(); + + assertEquals("testMethod[H2 Backend]", context.getDisplayName(1)); + } + + private static void runInvocation( + BackendTestExtension extension, boolean reuseBackend, ExtensionContext context) + throws Exception { + runInvocation(extension, reuseBackend, "h2", context); + } + + private static void runInvocation( + BackendTestExtension extension, + boolean reuseBackend, + String backendType, + ExtensionContext context) + throws Exception { + Extension callback = startInvocation(extension, reuseBackend, backendType, context); + ((AfterEachCallback) callback).afterEach(context); + } + + private static Extension startInvocation( + BackendTestExtension extension, boolean reuseBackend, ExtensionContext context) + throws Exception { + return startInvocation(extension, reuseBackend, "h2", context); + } + + private static Extension startInvocation( + BackendTestExtension extension, + boolean reuseBackend, + String backendType, + ExtensionContext context) + throws Exception { + TestTemplateInvocationContext invocation = + extension + .createInvocationContexts("testMethod", List.of(backendType), reuseBackend) + .findFirst() + .orElseThrow(); + Extension callback = invocation.getAdditionalExtensions().get(0); + ((BeforeEachCallback) callback).beforeEach(context); + return callback; + } + + private static class CountingBackendFactory implements BackendTestExtension.BackendFactory { + private final List backends = new ArrayList<>(); + private int activationCount; + + @Override + public BackendTestExtension.BackendResource create(String backendType) { + RelationalBackend backend = mock(RelationalBackend.class); + backends.add(backend); + return new BackendTestExtension.BackendResource( + backendType, backend, () -> activationCount++); + } + } + + private static class TestContexts { + private final Class testClass; + private final ExtensionContext classContext; + + private TestContexts(Class testClass) { + this.testClass = testClass; + classContext = mock(ExtensionContext.class); + ExtensionContext.Store store = mock(ExtensionContext.Store.class); + Map entries = new HashMap<>(); + + doAnswer( + invocation -> { + entries.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }) + .when(store) + .put(any(), any()); + when(store.get(any())).thenAnswer(invocation -> entries.get(invocation.getArgument(0))); + when(store.remove(any())).thenAnswer(invocation -> entries.remove(invocation.getArgument(0))); + when(classContext.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(classContext.getTestMethod()).thenReturn(Optional.empty()); + when(classContext.getRequiredTestClass()).thenAnswer(invocation -> testClass); + } + + private ExtensionContext methodContext(String methodName) throws NoSuchMethodException { + Method method = testClass.getDeclaredMethod(methodName); + ExtensionContext methodContext = mock(ExtensionContext.class); + when(methodContext.getTestMethod()).thenReturn(Optional.of(method)); + when(methodContext.getRequiredTestMethod()).thenReturn(method); + when(methodContext.getRequiredTestClass()).thenAnswer(invocation -> testClass); + when(methodContext.getRequiredTestInstance()).thenReturn(this); + when(methodContext.getParent()).thenReturn(Optional.of(classContext)); + return methodContext; + } + } + + private static class DefaultIsolationTests { + void first() {} + + void second() {} + } + + @DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) + private static class FreshIsolationTests { + void first() {} + + void second() {} + } + + private static class MixedIsolationTests { + void resettable() {} + + @DatabaseFixture(DatabaseIsolation.FRESH_NAMESPACE) + void fresh() {} + } + + @DatabaseFixture(DatabaseIsolation.DEDICATED_SERVER) + private static class DedicatedServerTests { + void first() {} + } +} diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/ContainerSuite.java index 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..5e26bf52488 100644 --- a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/CloseContainerExtension.java @@ -32,14 +32,11 @@ public class CloseContainerExtension implements BeforeAllCallback { @Override public void beforeAll(ExtensionContext extensionContext) { - // Ensure that the container suite is initialized before closing it - if (ContainerSuite.initialized()) { - synchronized (CloseContainerExtension.class) { - extensionContext - .getRoot() - .getStore(ExtensionContext.Namespace.GLOBAL) - .getOrComputeIfAbsent(CloseableContainer.class); - } + synchronized (CloseContainerExtension.class) { + extensionContext + .getRoot() + .getStore(ExtensionContext.Namespace.GLOBAL) + .getOrComputeIfAbsent(CloseableContainer.class); } } @@ -48,13 +45,9 @@ private static class CloseableContainer implements ExtensionContext.Store.Closea private static final ContainerSuite CONTAINER_SUITE = ContainerSuite.getInstance(); @Override - public void close() { - try { - CONTAINER_SUITE.close(); - LOGGER.info("Containers were closed successfully"); - } catch (Exception e) { - LOGGER.warn("Containers were not closed as expected", e); - } + public void close() throws Exception { + CONTAINER_SUITE.close(); + LOGGER.info("Containers were closed successfully"); } } } From 56290e6be17e4ec6771759606c17deb455838444 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 14 Oct 2026 12:08:44 -0700 Subject: [PATCH 27/30] fix(test): log container close failures at the end of the test plan again CloseContainerExtension runs for every integration-test module. Making it rethrow turned a slow Docker teardown at the end of a green run into a failed task. Keep ContainerSuite.close() throwing for direct callers, but log in the extension as before. --- .../test/util/CloseContainerExtension.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 5e26bf52488..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 @@ -44,10 +44,16 @@ 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() throws Exception { - CONTAINER_SUITE.close(); - LOGGER.info("Containers were closed successfully"); + public void close() { + try { + CONTAINER_SUITE.close(); + LOGGER.info("Containers were closed successfully"); + } catch (Exception e) { + LOGGER.warn("Containers were not closed as expected", e); + } } } } From ca32141c96a7a6c23b8c925c5f850969d0409f2c Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Sun, 27 Sep 2026 04:41:38 -0700 Subject: [PATCH 28/30] test(core): add bounded adaptive database forks --- .github/workflows/build.yml | 23 + build.gradle.kts | 22 +- buildSrc/build.gradle.kts | 37 ++ .../testing/CoreDatabaseConcurrency.java | 502 ++++++++++++++++++ .../testing/TestCoreDatabaseConcurrency.java | 300 +++++++++++ core/build.gradle.kts | 130 ++++- .../TestJdbcPartitionStatisticStorageIT.java | 3 + .../relational/BackendTestExtension.java | 44 +- .../relational/DatabaseTestContext.java | 54 ++ .../relational/TestBackendTestExtension.java | 39 +- .../storage/relational/TestJDBCBackend.java | 16 +- .../service/TestModelVersionMetaService.java | 2 +- .../CoreDatabaseShardTelemetryExtension.java | 176 ++++++ ...stCoreDatabaseShardTelemetryExtension.java | 104 ++++ .../org.junit.jupiter.api.extension.Extension | 19 + 15 files changed, 1439 insertions(+), 32 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/java/org/apache/gravitino/testing/CoreDatabaseConcurrency.java create mode 100644 buildSrc/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseConcurrency.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java create mode 100644 core/src/test/java/org/apache/gravitino/testing/CoreDatabaseShardTelemetryExtension.java create mode 100644 core/src/test/java/org/apache/gravitino/testing/TestCoreDatabaseShardTelemetryExtension.java create mode 100644 core/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98dd113bb7a..c8c48030e41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -288,6 +288,22 @@ jobs: 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 }} \ @@ -303,6 +319,13 @@ jobs: 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 diff --git a/build.gradle.kts b/build.gradle.kts index ea7cd68e2b5..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") } @@ -1022,7 +1022,7 @@ subprojects { val skipTests = project.hasProperty("skipTests") if (!skipTests) { val extraArgs = project.property("extraJvmArgs") as List - jvmArgs = listOf("-Xmx4G") + extraArgs + jvmArgs = listOf(CoreDatabaseConcurrency.TEST_WORKER_MAX_HEAP_ARGUMENT) + extraArgs useJUnitPlatform() val isCoreSuiteTask = project.path == ":core" && @@ -1645,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() { @@ -1652,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 2c846b6e481..f715586a46b 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 @@ -115,6 +119,53 @@ val coreBackendTestTags = "postgresql" to "gravitino-core-postgresql-test" ) val coreTestBackendProperty = "gravitino.core.test.backend" +val coreDatabaseTaskNames = + setOf("coreH2Test", "coreMySQLTest", "corePostgreSQLTest") +val coreDatabaseActiveLaneCount = + gradle.startParameter.taskNames + .map { it.substringAfterLast(':') } + .distinct() + .count { it in coreDatabaseTaskNames } + .coerceAtLeast(1) +val coreDatabaseTotalMemoryBytes = + runCatching { + (ManagementFactory.getOperatingSystemMXBean() as? OperatingSystemMXBean)?.totalMemorySize ?: 0L + }.getOrDefault(0L) +val coreDatabaseMemoryBasis = + if (coreDatabaseTotalMemoryBytes > 0) { + "jdk-total-memory-minus-configured-build-reserve" + } else { + "unavailable" + } +val coreDatabaseMemoryConfiguration = + CoreDatabaseConcurrency.resolveMemoryConfiguration( + providers.gradleProperty(CoreDatabaseConcurrency.BUILD_MEMORY_RESERVE_PROPERTY).orNull, + providers + .environmentVariable(CoreDatabaseConcurrency.BUILD_MEMORY_RESERVE_ENVIRONMENT_VARIABLE) + .orNull, + providers.gradleProperty(CoreDatabaseConcurrency.FORK_MEMORY_OVERHEAD_PROPERTY).orNull, + providers + .environmentVariable(CoreDatabaseConcurrency.FORK_MEMORY_OVERHEAD_ENVIRONMENT_VARIABLE) + .orNull + ) +val coreDatabaseAvailableMemoryBytes = + CoreDatabaseConcurrency.availableMemoryForTestWorkers( + coreDatabaseTotalMemoryBytes, + coreDatabaseMemoryConfiguration.buildReserveBytes() + ) +val coreDatabaseDetectedCapacity = + DetectedCapacity( + Runtime.getRuntime().availableProcessors(), + coreDatabaseTotalMemoryBytes, + gradle.startParameter.maxWorkerCount, + coreDatabaseActiveLaneCount + ) +val coreDatabaseConcurrencyBudget = + CoreDatabaseConcurrency.rolloutBudget(coreDatabaseMemoryConfiguration) +val macDockerConnectorFixedNetwork = + rootProject.extra[ + CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA + ] as? Boolean ?: false fun registerCoreTestTask( taskName: String, @@ -151,14 +202,89 @@ fun registerCoreTestTask( } if (backend != null) { + val shardTelemetryDirectory = + layout.buildDirectory.dir("test-results/$taskName/shard-telemetry") + val laneMaximum = + CoreDatabaseConcurrency.laneMaximum( + backend != "h2", + macDockerConnectorFixedNetwork + ) + val concurrencyResolution = + CoreDatabaseConcurrency.resolve( + providers.gradleProperty(CoreDatabaseConcurrency.FORKS_PROPERTY).orNull, + providers.environmentVariable(CoreDatabaseConcurrency.FORKS_ENVIRONMENT_VARIABLE).orNull, + coreDatabaseDetectedCapacity, + coreDatabaseConcurrencyBudget, + laneMaximum + ) + systemProperty(coreTestBackendProperty, backend) // 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 - // Database tests mutate process-wide state and must remain sequential within each lane. - maxParallelForks = 1 + // Each Gradle worker owns its database server. JUnit execution remains serial inside it. + maxParallelForks = concurrencyResolution.forks() + systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") systemProperty("junit.jupiter.execution.parallel.enabled", "false") + systemProperty( + "gravitino.core.database.shard.telemetry.directory", + shardTelemetryDirectory.get().asFile.absolutePath + ) + inputs.property("coreDatabaseForks", concurrencyResolution.forks()) + inputs.property("coreDatabaseActiveLanes", coreDatabaseActiveLaneCount) + outputs.dir(shardTelemetryDirectory) + + doFirst { + project.delete(shardTelemetryDirectory) + logger.lifecycle( + "[CORE-DB-CONCURRENCY] task={} forks={} source={} limitingFactor={} " + + "safeMaximum={} allowedMaximum={} processors={} totalMemoryBytes={} " + + "availableTestMemoryBytes={} memoryBasis={} buildReserveBytes={} " + + "buildReserveSource={} memoryBytesPerFork={} testWorkerMaxHeapBytes={} " + + "forkOverheadBytes={} forkOverheadSource={} gradleMaxWorkers={} " + + "activeDatabaseLanes={} rolloutCap={} laneMaximum={} " + + "macDockerConnectorFixedNetwork={} junitParallel=false", + path, + concurrencyResolution.forks(), + concurrencyResolution.source(), + concurrencyResolution.limitingFactor(), + concurrencyResolution.safeMaximum(), + concurrencyResolution.allowedMaximum(), + coreDatabaseDetectedCapacity.processors(), + coreDatabaseTotalMemoryBytes, + coreDatabaseAvailableMemoryBytes, + coreDatabaseMemoryBasis, + coreDatabaseMemoryConfiguration.buildReserveBytes(), + coreDatabaseMemoryConfiguration.buildReserveSource(), + coreDatabaseConcurrencyBudget.memoryBytesPerFork(), + CoreDatabaseConcurrency.TEST_WORKER_MAX_HEAP_BYTES, + coreDatabaseMemoryConfiguration.forkOverheadBytes(), + coreDatabaseMemoryConfiguration.forkOverheadSource(), + coreDatabaseDetectedCapacity.gradleMaxWorkers(), + coreDatabaseDetectedCapacity.activeDatabaseLanes(), + coreDatabaseConcurrencyBudget.rolloutCap(), + laneMaximum, + macDockerConnectorFixedNetwork + ) + } + + doLast { + val shardRecords = + shardTelemetryDirectory + .get() + .asFile + .walkTopDown() + .filter { it.isFile && it.extension == "log" } + .flatMap { it.readLines().asSequence() } + .filter { it.startsWith("[CORE-DB-SHARD]") } + .sorted() + .toList() + if (shardRecords.isEmpty()) { + throw GradleException("The $backend core database lane emitted no shard telemetry.") + } + shardRecords.forEach { logger.lifecycle(it) } + } if (backend != "h2") { doFirst { diff --git a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java index 38d421bd547..ee08e8ce72d 100644 --- a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java +++ b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java @@ -50,6 +50,7 @@ import org.apache.gravitino.integration.test.container.MySQLContainer; import org.apache.gravitino.integration.test.container.PGImageName; import org.apache.gravitino.integration.test.container.PostgreSQLContainer; +import org.apache.gravitino.integration.test.util.CloseContainerExtension; import org.apache.gravitino.integration.test.util.TestDatabaseName; import org.apache.gravitino.meta.TableEntity; import org.apache.gravitino.stats.PartitionRange; @@ -65,6 +66,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,6 +100,7 @@ public class TestJdbcPartitionStatisticStorageIT { * runs in. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @ExtendWith(CloseContainerExtension.class) abstract static class BaseJdbcPartitionStatisticStorageTest { protected JdbcPartitionStatisticStorage storage; diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java index 6ab1ec26efb..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 @@ -57,6 +57,9 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; import org.junit.jupiter.api.extension.TestWatcher; @@ -277,11 +280,13 @@ private static class BackendSetupCallback implements BeforeEachCallback, AfterEachCallback, LifecycleMethodExecutionExceptionHandler, + ParameterResolver, TestWatcher { private final String backendType; private final boolean reuseBackend; private final BackendFactory backendFactory; private BackendResource backendResource; + private DatabaseTestContext databaseTestContext; private boolean closeAfterEach; private BackendSetupCallback( @@ -306,18 +311,39 @@ public void beforeEach(ExtensionContext context) throws Exception { } backendResource = closeAfterEach ? newBackendResource() : getOrCreateClassBackend(context); backendResource.activate(); - injectBackend(context, backendResource.backend); + databaseTestContext = + new DatabaseTestContext(backendType, backendResource.backend, isolation); } catch (Exception e) { cleanupAfterSetupFailure(e); throw e; } } + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType() == DatabaseTestContext.class; + } + + @Override + public DatabaseTestContext resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + if (databaseTestContext == null) { + throw new ParameterResolutionException( + "DatabaseTestContext is unavailable before the database fixture starts"); + } + return databaseTestContext; + } + @Override public void afterEach(ExtensionContext context) throws Exception { - if (closeAfterEach && backendResource != null) { - backendResource.close(); - backendResource = null; + try { + if (closeAfterEach && backendResource != null) { + backendResource.close(); + backendResource = null; + } + } finally { + databaseTestContext = null; } } @@ -345,15 +371,6 @@ public void handleAfterEachMethodExecutionException( throw throwable; } - private void injectBackend(ExtensionContext context, RelationalBackend backend) { - Object testInstance = context.getRequiredTestInstance(); - if (testInstance instanceof TestJDBCBackend) { - LOG.info("Injecting {} backend into test instance", backendType); - ((TestJDBCBackend) testInstance).setBackend(backend); - ((TestJDBCBackend) testInstance).setBackendType(backendType); - } - } - private BackendResource getOrCreateClassBackend(ExtensionContext context) throws Exception { ConcurrentHashMap backendMap = backendMap(context); synchronized (backendMap) { @@ -405,6 +422,7 @@ private BackendResource newBackendResource() throws Exception { } private void cleanupAfterSetupFailure(Exception failure) { + databaseTestContext = null; if (backendResource == null) { return; } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java new file mode 100644 index 00000000000..6b8435dcf33 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/DatabaseTestContext.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.storage.relational; + +import java.util.Objects; + +/** + * Provides the database fixture owned by the current test invocation. + * + *

    The extension owns the fixture lifecycle. Tests must not close the supplied backend. + */ +public final class DatabaseTestContext { + private final String backendType; + private final RelationalBackend backend; + private final DatabaseIsolation isolation; + + DatabaseTestContext(String backendType, RelationalBackend backend, DatabaseIsolation isolation) { + this.backendType = Objects.requireNonNull(backendType, "backendType"); + this.backend = Objects.requireNonNull(backend, "backend"); + this.isolation = Objects.requireNonNull(isolation, "isolation"); + } + + /** Returns the database implementation selected for this invocation. */ + public String backendType() { + return backendType; + } + + /** Returns the initialized relational backend owned by the test fixture. */ + public RelationalBackend backend() { + return backend; + } + + /** Returns the isolation policy requested by the test. */ + public DatabaseIsolation isolation() { + return isolation; + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java index d9c3c30c142..47b878e7e35 100644 --- a/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestBackendTestExtension.java @@ -20,7 +20,10 @@ package org.apache.gravitino.storage.relational; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; @@ -32,6 +35,7 @@ import java.io.IOException; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -44,6 +48,9 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestWatcher; @@ -56,7 +63,15 @@ void testSelectedBackendReusesClassFixture() throws Exception { TestContexts contexts = new TestContexts(DefaultIsolationTests.class); extension.beforeAll(contexts.classContext); - runInvocation(extension, true, contexts.methodContext("first")); + ExtensionContext firstContext = contexts.methodContext("first"); + Extension firstCallback = startInvocation(extension, true, firstContext); + DatabaseTestContext databaseContext = + (DatabaseTestContext) + ((ParameterResolver) firstCallback).resolveParameter(parameterContext(0), firstContext); + assertEquals("h2", databaseContext.backendType()); + assertSame(factory.backends.get(0), databaseContext.backend()); + assertEquals(DatabaseIsolation.RESETTABLE_NAMESPACE, databaseContext.isolation()); + ((AfterEachCallback) firstCallback).afterEach(firstContext); runInvocation(extension, true, contexts.methodContext("second")); assertEquals(1, factory.backends.size()); @@ -272,8 +287,14 @@ void testDisplayNamePreservesMethodAndBackend() { .createInvocationContexts("testMethod", List.of("h2"), true) .findFirst() .orElseThrow(); + ParameterResolver resolver = (ParameterResolver) context.getAdditionalExtensions().get(0); assertEquals("testMethod[H2 Backend]", context.getDisplayName(1)); + assertTrue(resolver.supportsParameter(parameterContext(0), mock(ExtensionContext.class))); + assertFalse(resolver.supportsParameter(parameterContext(1), mock(ExtensionContext.class))); + assertThrows( + ParameterResolutionException.class, + () -> resolver.resolveParameter(parameterContext(0), mock(ExtensionContext.class))); } private static void runInvocation( @@ -314,6 +335,22 @@ private static Extension startInvocation( return callback; } + private static ParameterContext parameterContext(int index) { + Parameter parameter; + try { + parameter = + index == 0 + ? TestJDBCBackend.class.getDeclaredMethod("init", DatabaseTestContext.class) + .getParameters()[0] + : Object.class.getDeclaredMethod("equals", Object.class).getParameters()[0]; + } catch (NoSuchMethodException e) { + throw new AssertionError(e); + } + ParameterContext context = mock(ParameterContext.class); + when(context.getParameter()).thenReturn(parameter); + return context; + } + private static class CountingBackendFactory implements BackendTestExtension.BackendFactory { private final List backends = new ArrayList<>(); private int activationCount; diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java index 41d08dff85b..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 @@ -98,16 +98,16 @@ public abstract class TestJDBCBackend { protected String backendType; protected RelationalBackend backend; - public void setBackend(RelationalBackend backend) { - this.backend = backend; - } - - public void setBackendType(String backendType) { - this.backendType = backendType; + /** Activates and resets the database fixture selected for the current test invocation. */ + @BeforeEach + public void init(DatabaseTestContext context) throws SQLException { + backend = context.backend(); + backendType = context.backendType(); + resetDatabase(); } - @BeforeEach - public void init() throws SQLException { + /** Clears data owned by the current database fixture. */ + protected void resetDatabase() throws SQLException { truncateAllTables(); } diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestModelVersionMetaService.java index 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 From 1eae08e9f064ee62f20f25b3a44b21c7ee2bc351 Mon Sep 17 00:00:00 2001 From: Nevin Zheng Date: Wed, 14 Oct 2026 12:08:52 -0700 Subject: [PATCH 29/30] fix(test): keep the fork count out of the core lanes' cache key The fork count and the number of lanes on the command line don't change test results, but as task inputs they made every machine and every task combination miss the build cache. --- core/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index f715586a46b..c59110e080c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -231,8 +231,6 @@ fun registerCoreTestTask( "gravitino.core.database.shard.telemetry.directory", shardTelemetryDirectory.get().asFile.absolutePath ) - inputs.property("coreDatabaseForks", concurrencyResolution.forks()) - inputs.property("coreDatabaseActiveLanes", coreDatabaseActiveLaneCount) outputs.dir(shardTelemetryDirectory) doFirst { From 9912611e1d4040fbce2d09845bbb97d02475e963 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:14:17 +0000 Subject: [PATCH 30/30] improvement(test): share one container per backend across core DB tests Each core MySQL or PostgreSQL test task used to start its own container per test JVM. SharedDbContainerService, a Gradle BuildService, now starts one MySQL and one PostgreSQL container for the whole build through the docker CLI, and each backend gets a fresh, uniquely named database on that server instead of a fresh container. - SharedCoreDatabaseProvisioner reads the published admin URL, user and password from system properties and creates the per-backend database. - A core database task that gets no shared server fails fast instead of silently starting its own containers. - Stale containers are reaped only when the Gradle daemon that owns them is gone; PIDs 0 and 1 are never treated as live owners. - The containers use capped tmpfs mounts and durability-off settings that are safe for disposable test data; the tuning args are logged, and container logs are captured when a container never becomes ready. - buildSrc declares gradleApi() so `./gradlew -p buildSrc test` builds on its own. - A README in the tuning resources describes the benchmark machine and how to size the settings. --- build.gradle.kts | 13 + buildSrc/build.gradle.kts | 3 + .../gravitino/testing/DbConnectionInfo.java | 59 ++ .../testing/SharedDbContainerService.java | 633 ++++++++++++++++++ .../testing/shared-db-tuning/README.md | 158 +++++ .../testing/shared-db-tuning/mysql.args | 52 ++ .../testing/shared-db-tuning/postgresql.args | 44 ++ .../testing/TestSharedDbContainerService.java | 104 +++ core/build.gradle.kts | 38 ++ .../relational/BackendTestExtension.java | 39 +- .../SharedCoreDatabaseProvisioner.java | 209 ++++++ .../TestSharedCoreDatabaseProvisioner.java | 182 +++++ .../test/container/DatabaseProvisioning.java | 193 ++++++ .../test/container/MySQLContainer.java | 33 +- .../test/container/PostgreSQLContainer.java | 17 +- .../container/TestDatabaseProvisioning.java | 182 +++++ 16 files changed, 1902 insertions(+), 57 deletions(-) create mode 100644 buildSrc/src/main/java/org/apache/gravitino/testing/DbConnectionInfo.java create mode 100644 buildSrc/src/main/java/org/apache/gravitino/testing/SharedDbContainerService.java create mode 100644 buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/README.md create mode 100644 buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/mysql.args create mode 100644 buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/postgresql.args create mode 100644 buildSrc/src/test/java/org/apache/gravitino/testing/TestSharedDbContainerService.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/SharedCoreDatabaseProvisioner.java create mode 100644 core/src/test/java/org/apache/gravitino/storage/relational/TestSharedCoreDatabaseProvisioner.java create mode 100644 integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/DatabaseProvisioning.java create mode 100644 integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestDatabaseProvisioning.java diff --git a/build.gradle.kts b/build.gradle.kts index 427df0ba37c..92123445bce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,7 @@ 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.gravitino.testing.SharedDbContainerService import org.apache.tools.zip.ZipEntry import org.apache.tools.zip.ZipOutputStream import org.gradle.api.attributes.java.TargetJvmVersion @@ -82,6 +83,18 @@ val sharedTestEnvironmentLock = gradle.sharedServices.registerIfAbsent( maxParallelUsages.set(1) } +// Owns one shared MySQL container and one shared PostgreSQL container for the whole build (see +// org.apache.gravitino.testing.SharedDbContainerService in buildSrc), so the Core database test +// tasks no longer have to start a fresh container per test-worker fork. Registered here, at the +// root, so a single instance is shared by every project that opts in via `usesService`. Stored on +// rootProject.extra so subproject build scripts (for example core/build.gradle.kts) can look up +// the same registration by name. +val sharedDbContainerService = gradle.sharedServices.registerIfAbsent( + "sharedDbContainerService", + SharedDbContainerService::class +) {} +rootProject.extra["sharedDbContainerService"] = sharedDbContainerService + /** Packages the legal documents for one Maven artifact, retaining dependency provenance. */ @CacheableTask abstract class GenerateJarLegalFiles : DefaultTask() { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b7a000f20a9..27dfb53bc81 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -26,6 +26,9 @@ repositories { } dependencies { + // SharedDbContainerService is a Gradle BuildService. Inside the main build Gradle adds its API + // to buildSrc implicitly; `./gradlew -p buildSrc test` builds buildSrc on its own and needs it. + implementation(gradleApi()) 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") diff --git a/buildSrc/src/main/java/org/apache/gravitino/testing/DbConnectionInfo.java b/buildSrc/src/main/java/org/apache/gravitino/testing/DbConnectionInfo.java new file mode 100644 index 00000000000..2d2fc3b859c --- /dev/null +++ b/buildSrc/src/main/java/org/apache/gravitino/testing/DbConnectionInfo.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.gravitino.testing; + +/** + * Administrative connection info for a {@link SharedDbContainerService}-managed shared database + * container, not naming any particular database on that server. + */ +public final class DbConnectionInfo { + + private final String adminJdbcUrl; + private final String user; + private final String password; + + /** + * Creates a new connection info holder. + * + * @param adminJdbcUrl a JDBC URL that can be connected to without naming any database + * @param user the administrative user + * @param password the password for {@code user} + */ + public DbConnectionInfo(String adminJdbcUrl, String user, String password) { + this.adminJdbcUrl = adminJdbcUrl; + this.user = user; + this.password = password; + } + + /** Returns the administrative JDBC URL, not naming any database. */ + public String getAdminJdbcUrl() { + return adminJdbcUrl; + } + + /** Returns the administrative user. */ + public String getUser() { + return user; + } + + /** Returns the password for {@link #getUser()}. */ + public String getPassword() { + return password; + } +} diff --git a/buildSrc/src/main/java/org/apache/gravitino/testing/SharedDbContainerService.java b/buildSrc/src/main/java/org/apache/gravitino/testing/SharedDbContainerService.java new file mode 100644 index 00000000000..9f4ba68a513 --- /dev/null +++ b/buildSrc/src/main/java/org/apache/gravitino/testing/SharedDbContainerService.java @@ -0,0 +1,633 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 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.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.gradle.api.services.BuildService; +import org.gradle.api.services.BuildServiceParameters; + +/** + * A Gradle {@link BuildService} that owns a single, shared MySQL container and a single, shared + * PostgreSQL container for the whole lifetime of the build, so that the Core database test tasks + * no longer have to start one container per Gradle test-worker fork. + * + *

    Containers are started lazily, on the first call to {@link #connectionInfo(String)} for a + * given backend, and are reused for every subsequent call for that backend within the same build. + * Each forked test JVM is expected to create its own database on the shared container rather than + * requesting its own container. + * + *

    The main Gradle process does not have Testcontainers on its classpath, so containers are + * managed directly through the {@code docker} CLI rather than through Testcontainers. Every + * container this service starts is tagged with the {@value #CONTAINER_LABEL_KEY} label plus a + * {@value #CONTAINER_PID_LABEL_KEY} label recording the Gradle daemon process that started it, so + * that a crashed or forcibly killed build does not leak a container forever: the next build that + * creates this service removes any labelled container that has either exited, or is still + * running but whose recording daemon process is no longer alive - a still-running container whose + * daemon *is* alive belongs to a build genuinely still in progress (a second Gradle invocation, + * another CI job sharing this runner, an IDE test run started mid-build) and is left alone. + * Databases created on a container are never individually dropped; they are discarded together + * with the container when it is removed. + */ +public abstract class SharedDbContainerService + implements BuildService, AutoCloseable { + + private static final Logger LOG = Logging.getLogger(SharedDbContainerService.class); + + /** Docker label key applied to every container this service starts. */ + private static final String CONTAINER_LABEL_KEY = "gravitino-core-test"; + + /** + * Docker label key recording the PID of the Gradle daemon process that started a container, so + * a later build can tell "still running because its build is still in progress" apart from + * "still running because its build (and daemon) died without cleaning up". + */ + private static final String CONTAINER_PID_LABEL_KEY = "gravitino-core-test-pid"; + + private static final String MYSQL_BACKEND = "mysql"; + private static final String POSTGRESQL_BACKEND = "postgresql"; + + private static final String MYSQL_IMAGE = "mysql:8.0"; + private static final String POSTGRESQL_IMAGE = "postgres:13"; + + private static final String MYSQL_CONTAINER_PORT = "3306"; + private static final String POSTGRESQL_CONTAINER_PORT = "5432"; + + private static final String DB_USER = "root"; + private static final String DB_PASSWORD = "root"; + + private static final int READINESS_MAX_ATTEMPTS = 120; + private static final long READINESS_POLL_INTERVAL_MILLIS = 1000L; + + // How long a single docker CLI invocation (including a cold image pull) may run before it is + // treated as hung and killed. + /** Number of trailing container-log lines echoed into the readiness-timeout exception. */ + private static final int CONTAINER_LOG_TAIL_LINES = 20; + + private static final long EXEC_TIMEOUT_SECONDS = 300L; + + // Unique per build-service instance, embedded in every container this instance starts (see + // startMySql/startPostgreSql). Purely informational/diagnostic - see CONTAINER_PID_LABEL_KEY + // for the label removeStaleContainers() actually uses to decide what is safe to remove. + private final String labelValue = "run-" + UUID.randomUUID(); + + // The current Gradle daemon's PID, recorded on every container this instance starts (see + // CONTAINER_PID_LABEL_KEY / removeStaleContainers()). + private final long ownerPid = ProcessHandle.current().pid(); + + private final Map connectionInfoByBackend = new ConcurrentHashMap<>(); + private final Map containerIdByBackend = new ConcurrentHashMap<>(); + + // Drains a docker CLI child process's stdout/stderr concurrently (see exec()). Daemon threads + // so a stuck drain never keeps the Gradle daemon process alive. + private final ExecutorService streamReader = + Executors.newCachedThreadPool( + runnable -> { + Thread thread = new Thread(runnable, "shared-db-container-stream-reader"); + thread.setDaemon(true); + return thread; + }); + + /** Creates the service and removes any stale containers left by a previously killed build. */ + public SharedDbContainerService() { + removeStaleContainers(); + } + + /** + * Returns connection info for the shared container for the given backend, starting that + * backend's container on first use. + * + * @param backend either {@code "mysql"} or {@code "postgresql"} + * @return administrative connection info for the shared container, not naming any database + */ + public synchronized DbConnectionInfo connectionInfo(String backend) { + return connectionInfoByBackend.computeIfAbsent(backend, this::startContainer); + } + + /** Stops every container this service instance started. */ + @Override + public void close() { + containerIdByBackend.values().forEach(this::removeContainer); + containerIdByBackend.clear(); + streamReader.shutdownNow(); + } + + private void removeContainer(String containerId) { + try { + runDocker("rm", "-f", containerId); + } catch (RuntimeException e) { + LOG.warn("Failed to stop shared test database container {}: {}", containerId, e.getMessage()); + } + } + + /** + * Removes containers left behind by a previous, no-longer-running build. + * + *

    A labelled container with {@code status=exited} is always reaped (its owning build is, by + * definition, done with it). A labelled container that is still {@code running} is reaped only + * when its recorded {@value #CONTAINER_PID_LABEL_KEY} process is no longer alive - that is the + * one realistic leak this class exists to close: a Gradle daemon that was SIGKILLed, OOM-killed, + * or lost with its host, which never got to call {@link #close()}. A still-running container + * whose daemon process *is* alive is never touched, since it belongs to a build genuinely still + * in progress on this host (a second Gradle invocation, another CI job sharing this runner, an + * IDE test run started mid-build). + */ + private void removeStaleContainers() { + String labelledIds = + runDockerQuiet("ps", "-aq", "--filter", "label=" + CONTAINER_LABEL_KEY); + List ids = splitNonBlankLines(labelledIds); + List toRemove = new ArrayList<>(); + for (String id : ids) { + if (isReapable(id)) { + toRemove.add(id); + } + } + if (toRemove.isEmpty()) { + return; + } + + LOG.lifecycle( + "Removing {} stale shared test database container(s) from a previous build", toRemove.size()); + List removeCommand = new ArrayList<>(); + removeCommand.add("rm"); + removeCommand.add("-f"); + removeCommand.addAll(toRemove); + runDockerQuiet(removeCommand.toArray(new String[0])); + } + + /** + * Returns whether the given labelled container may be safely removed: it has already exited, + * or its recorded owning daemon process is no longer alive. + */ + private boolean isReapable(String containerId) { + String state = + runDockerQuiet("inspect", "-f", "{{.State.Status}}", containerId).trim(); + if ("exited".equals(state)) { + return true; + } + + String pidLabel = + runDockerQuiet( + "inspect", + "-f", + "{{index .Config.Labels \"" + CONTAINER_PID_LABEL_KEY + "\"}}", + containerId) + .trim(); + return !isOwningProcessAlive(pidLabel); + } + + /** Returns whether {@code pidLabel} parses to a PID that is currently alive on this host. */ + static boolean isOwningProcessAlive(String pidLabel) { + try { + long pid = Long.parseLong(pidLabel.trim()); + // PIDs 0 and 1 are the kernel/init on macOS and Linux, never a Gradle daemon. + return pid > 1 && ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } catch (NumberFormatException e) { + // No usable PID label (e.g. a container from before this label existed, or empty output) - + // treat it as not verifiably alive, so removeStaleContainers() falls through to reaping it + // rather than leaking it forever on a false-negative liveness check. + return false; + } + } + + + /** + * Why this tuning exists at all: one shared container serves roughly twice the concurrent + * DDL/DML load that per-fork containers each saw, because every test fork now writes to the + * same server. A database's production durability defaults -- an fsync on every commit, frequent + * checkpoints, doublewrite/full-page writes, binary logging -- are per-commit costs, so that + * doubled write load multiplies them rather than absorbing them. Relaxing those guarantees for + * a container that is disposable and torn down at the end of the run was the difference between + * the shared container being a ~5.6% regression and being a 3x+ speedup. + * + *

    Loads extra container-start args for {@code backend} from a plain-text resource at + * {@code shared-db-tuning/.args} (one token per line; blank lines and lines + * starting with {@code #} are ignored). Missing resources or read failures are logged and + * treated as "no extra args" so a bad/missing tuning file can never fail the build -- + * it only ever falls back to untuned defaults. + */ + private List loadExtraDbArgs(String backend) { + // Resolved relative to this class's own package via Class#getResourceAsStream, NOT + // ClassLoader#getResourceAsStream: the loader variant treats the name as absolute from the + // classpath root, so "shared-db-tuning/mysql.args" never matched the file's actual location + // (org/apache/gravitino/testing/shared-db-tuning/mysql.args) and every lookup silently + // returned null, leaving both containers on stock server defaults. + String resourcePath = "shared-db-tuning/" + backend + ".args"; + try (java.io.InputStream in = + SharedDbContainerService.class.getResourceAsStream(resourcePath)) { + if (in == null) { + LOG.warn( + "No DB tuning args found on the classpath at {} for backend {}; the shared container" + + " will run with untuned server defaults", + resourcePath, + backend); + return java.util.Collections.emptyList(); + } + List args = new ArrayList<>(); + try (java.io.BufferedReader reader = + new java.io.BufferedReader(new java.io.InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + args.add(trimmed); + } + } + } + return args; + } catch (IOException e) { + LOG.warn("Failed to load DB tuning args for {} from {}, continuing untuned", + backend, resourcePath, e); + return java.util.Collections.emptyList(); + } + } + + /** + * Logs the tuning args actually applied to a shared container, so a run that is unexpectedly + * slow can be told apart from a run whose tuning file silently failed to load. + */ + private void logTuningArgs(String backendLabel, List tuningArgs) { + if (tuningArgs.isEmpty()) { + LOG.warn( + "Starting shared {} test database container with NO tuning args (untuned server" + + " defaults); expect a substantially slower run", + backendLabel); + } else { + LOG.lifecycle( + "Applying {} tuning args to shared {} test database container: {}", + tuningArgs.size(), + backendLabel, + String.join(" ", tuningArgs)); + } + } + + private DbConnectionInfo startContainer(String backend) { + switch (backend) { + case MYSQL_BACKEND: + return startMySql(); + case POSTGRESQL_BACKEND: + return startPostgreSql(); + default: + throw new IllegalArgumentException("Unsupported shared test database backend: " + backend); + } + } + + private DbConnectionInfo startMySql() { + List mysqlRunArgs = + new ArrayList<>( + Arrays.asList( + "run", + "-d", + "--rm", + "--label", + CONTAINER_LABEL_KEY + "=" + labelValue, + "--label", + CONTAINER_PID_LABEL_KEY + "=" + ownerPid, + "-P", + // Disposable container -- backing the data dir with tmpfs removes real disk + // I/O for every write, on top of the durability-relaxing mysqld flags below. + // + // The size cap is mandatory, not cosmetic: an uncapped `--tmpfs` mount defaults + // to 50% of host RAM *per mount*, so MySQL's and PostgreSQL's data dirs could + // between them claim 100% of RAM and get the Gradle daemon or a test fork + // OOM-killed. Headroom math for this repo's real lane -- the backend + // integration-test job in .github/workflows/backend-integration-test.yml runs + // on `ubuntu-22.04`, a GitHub-hosted runner with 4 vCPU / 16 GiB RAM: + // 16 GiB total + // - 4 GiB build-process reserve (CoreDatabaseConcurrency + // DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES) + // - 12 GiB 2 concurrent test forks x (4 GiB max heap, + // TEST_WORKER_MAX_HEAP_MIB + 2 GiB native overhead, + // DEFAULT_TEST_WORKER_OVERHEAD_BYTES) + // i.e. the JVM side alone nominally commits the whole box, so these mounts must + // stay a bounded worst case rather than an open-ended 8 GiB each. MySQL's share + // is 2g: the core test schema is small, and --skip-log-bin plus the default + // ~100 MiB redo capacity mean little but table data lands here, while + // --innodb-buffer-pool-size=2G is separately resident in the container's RSS. + "--tmpfs", + "/var/lib/mysql:size=2g", + "-e", + "MYSQL_ROOT_PASSWORD=" + DB_PASSWORD, + MYSQL_IMAGE)); + List mysqlTuningArgs = loadExtraDbArgs("mysql"); + logTuningArgs("MySQL", mysqlTuningArgs); + mysqlRunArgs.addAll(mysqlTuningArgs); + String containerId = runDocker(mysqlRunArgs.toArray(new String[0])); + try { + awaitReady( + containerId, + new String[] { + "mysqladmin", "ping", "-h", "127.0.0.1", "-u" + DB_USER, "-p" + DB_PASSWORD + }, + "MySQL"); + int hostPort = resolveHostPort(containerId, MYSQL_CONTAINER_PORT); + containerIdByBackend.put(MYSQL_BACKEND, containerId); + + String adminJdbcUrl = String.format("jdbc:mysql://127.0.0.1:%d", hostPort); + LOG.lifecycle( + "Started shared MySQL test database container {} on port {}", containerId, hostPort); + return new DbConnectionInfo(adminJdbcUrl, DB_USER, DB_PASSWORD); + } catch (RuntimeException e) { + // Not yet recorded in containerIdByBackend (only done on success above), so close() would + // never reap this container - remove it now instead of leaking it until the next build's + // stale-container cleanup. + removeContainer(containerId); + throw e; + } + } + + private DbConnectionInfo startPostgreSql() { + List postgresRunArgs = + new ArrayList<>( + Arrays.asList( + "run", + "-d", + "--rm", + "--label", + CONTAINER_LABEL_KEY + "=" + labelValue, + "--label", + CONTAINER_PID_LABEL_KEY + "=" + ownerPid, + "-P", + // Disposable container -- see the equivalent tmpfs mount in startMySql() for + // the runner spec (ubuntu-22.04, 4 vCPU / 16 GiB) and the headroom math. + // + // PostgreSQL gets a larger cap than MySQL (5g vs 2g) because pg_wal lives + // *inside* the data dir and postgresql.args sets max_wal_size=4GB with + // checkpoint_timeout=1h, so up to ~4 GiB of WAL can legitimately accumulate + // here before a checkpoint recycles it. Capping this at MySQL's 2g would turn a + // merely-late checkpoint into an ENOSPC and a PostgreSQL PANIC partway through + // the run. 5g = ~4 GiB WAL headroom + ~1 GiB for the schema and its data. + // Worst case both mounts together top out at 7 GiB, leaving ~9 GiB for the OS, + // the two database processes' RSS (2 GiB buffer pool + 1 GiB shared_buffers, + // plus overhead) and the fork JVMs' actual (as opposed to maximum) heap usage. + "--tmpfs", + "/var/lib/postgresql/data:size=5g", + "-e", + "POSTGRES_USER=" + DB_USER, + "-e", + "POSTGRES_PASSWORD=" + DB_PASSWORD, + POSTGRESQL_IMAGE)); + List postgresTuningArgs = loadExtraDbArgs("postgresql"); + logTuningArgs("PostgreSQL", postgresTuningArgs); + postgresRunArgs.addAll(postgresTuningArgs); + String containerId = runDocker(postgresRunArgs.toArray(new String[0])); + try { + // -h forces a TCP health check. The postgres image's entrypoint first runs a *temporary*, + // socket-only server (listen_addresses='') to execute init scripts before starting the + // real, TCP-listening server; a socket-based pg_isready (no -h) reports ready during that + // window, before this container can actually accept the JDBC connection made right after. + awaitReady(containerId, new String[] {"pg_isready", "-h", "127.0.0.1", "-U", DB_USER}, "PostgreSQL"); + int hostPort = resolveHostPort(containerId, POSTGRESQL_CONTAINER_PORT); + containerIdByBackend.put(POSTGRESQL_BACKEND, containerId); + + // Mirrors PostgreSQLContainer#getJdbcUrl(): no database name, trailing slash. + String adminJdbcUrl = String.format("jdbc:postgresql://127.0.0.1:%d/", hostPort); + LOG.lifecycle( + "Started shared PostgreSQL test database container {} on port {}", + containerId, + hostPort); + return new DbConnectionInfo(adminJdbcUrl, DB_USER, DB_PASSWORD); + } catch (RuntimeException e) { + removeContainer(containerId); + throw e; + } + } + + private int resolveHostPort(String containerId, String containerPort) { + String output = runDocker("port", containerId, containerPort + "/tcp"); + return parseHostPort(output, containerId); + } + + /** + * Parses the host port `docker port <container> <port>/tcp` publishes from its raw + * output, e.g. {@code "0.0.0.0:49153"} or, when the daemon also publishes an IPv6 address, + * {@code "0.0.0.0:49153\n[::]:49153"} (the first line is used either way). + * + * @throws IllegalStateException if {@code output} has no lines (no published port) + * @throws NumberFormatException if the port segment after the last {@code ':'} is not numeric + */ + static int parseHostPort(String output, String containerId) { + List lines = splitNonBlankLines(output); + if (lines.isEmpty()) { + throw new IllegalStateException( + "docker port did not report a published host port for container " + containerId); + } + String firstLine = lines.get(0); + String portPart = firstLine.substring(firstLine.lastIndexOf(':') + 1).trim(); + return Integer.parseInt(portPart); + } + + private void awaitReady(String containerId, String[] healthCheckCommand, String backendLabel) { + List execCommand = new ArrayList<>(); + execCommand.add("exec"); + execCommand.add(containerId); + execCommand.addAll(Arrays.asList(healthCheckCommand)); + + for (int attempt = 1; attempt <= READINESS_MAX_ATTEMPTS; attempt++) { + if (runDockerExitCode(execCommand) == 0) { + return; + } + sleep(READINESS_POLL_INTERVAL_MILLIS); + } + + // Capture the container's own logs before returning: the caller removes a container that + // failed to become ready, which destroys the only evidence of *why* it failed (bad server + // flag, ENOSPC on the tmpfs data dir, image init script error). Done here rather than in the + // caller so it runs before any removal path. + String containerLogs = captureContainerLogs(containerId); + LOG.error( + "Shared {} test database container {} never became ready. Full container logs follow:\n{}", + backendLabel, + containerId, + containerLogs); + + throw new IllegalStateException( + String.format( + "Timed out after %d attempts waiting for shared %s test database container %s to" + + " become ready. Last %d lines of container logs:%n%s", + READINESS_MAX_ATTEMPTS, + backendLabel, + containerId, + CONTAINER_LOG_TAIL_LINES, + tailLines(containerLogs, CONTAINER_LOG_TAIL_LINES))); + } + + /** + * Returns the combined stdout and stderr of {@code docker logs }, or a short + * placeholder if the logs cannot be read. Never throws: this only ever runs on a path that is + * already failing, and losing the diagnostic must not mask the original error. Both streams are + * collected because the MySQL and PostgreSQL images write their server logs to stderr. + */ + private String captureContainerLogs(String containerId) { + try { + ProcessResult result = exec("logs", containerId); + StringBuilder combined = new StringBuilder(); + if (!result.stdout.isEmpty()) { + combined.append(result.stdout); + } + if (!result.stderr.isEmpty()) { + if (combined.length() > 0) { + combined.append(System.lineSeparator()); + } + combined.append(result.stderr); + } + return combined.length() == 0 ? "" : combined.toString(); + } catch (RuntimeException e) { + LOG.warn("Failed to capture logs for container {}", containerId, e); + return ""; + } + } + + /** Returns at most the last {@code maxLines} non-blank lines of {@code text}. */ + static String tailLines(String text, int maxLines) { + List lines = splitNonBlankLines(text); + List tail = lines.subList(Math.max(0, lines.size() - maxLines), lines.size()); + return String.join(System.lineSeparator(), tail); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for shared test database container", e); + } + } + + /** Runs {@code docker }, throwing if it exits non-zero, and returns its stdout. */ + private String runDocker(String... args) { + ProcessResult result = exec(args); + if (result.exitCode != 0) { + throw new IllegalStateException( + "docker " + String.join(" ", args) + " failed with exit code " + result.exitCode + + ": " + result.stderr); + } + return result.stdout; + } + + /** Runs {@code docker }, logging (but not throwing on) a non-zero exit, and returns stdout. */ + private String runDockerQuiet(String... args) { + ProcessResult result = exec(args); + if (result.exitCode != 0) { + LOG.warn("docker {} exited with {}: {}", String.join(" ", args), result.exitCode, result.stderr); + } + return result.stdout; + } + + /** Runs {@code docker } and returns only its exit code. */ + private int runDockerExitCode(List args) { + return exec(args.toArray(new String[0])).exitCode; + } + + private ProcessResult exec(String... args) { + List command = new ArrayList<>(); + command.add("docker"); + command.addAll(Arrays.asList(args)); + + Process process = null; + try { + // Deliberately does NOT merge stderr into stdout: `docker run` writes image-pull progress + // to stderr while it is still starting the container, and prints only the container ID to + // stdout on success. Merging the two streams (as redirectErrorStream(true) would) corrupts + // the container ID with pull-progress text on a cold cache, breaking every later `docker + // exec`/`docker port` call keyed on that ID. + // + // stdout and stderr are drained on separate threads, concurrently with the process running: + // a slow image pull can write more to stderr than one pipe buffer holds, and letting stdout + // fill up while stderr is not yet being drained would deadlock against the child process + // blocking on a full stderr pipe. + process = new ProcessBuilder(command).start(); + process.getOutputStream().close(); // no docker subcommand used here reads stdin + Process startedProcess = process; + Future stdoutFuture = + streamReader.submit(() -> startedProcess.getInputStream().readAllBytes()); + Future stderrFuture = + streamReader.submit(() -> startedProcess.getErrorStream().readAllBytes()); + + // waitFor(timeout) is checked BEFORE reading the futures: readAllBytes() only returns at + // EOF (i.e. once the process has actually exited), so blocking on the futures first would + // make this timeout unreachable - a hung `docker` call would block the build forever. + boolean finished = process.waitFor(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + stdoutFuture.cancel(true); + stderrFuture.cancel(true); + throw new IllegalStateException("Command timed out: " + String.join(" ", command)); + } + + String stdout = + new String(stdoutFuture.get(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS), StandardCharsets.UTF_8); + String stderr = + new String(stderrFuture.get(EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS), StandardCharsets.UTF_8); + return new ProcessResult(process.exitValue(), stdout.trim(), stderr.trim()); + } catch (IOException e) { + throw new IllegalStateException("Failed to run command: " + String.join(" ", command), e); + } catch (ExecutionException e) { + throw new IllegalStateException( + "Failed to read output of command: " + String.join(" ", command), e.getCause()); + } catch (TimeoutException e) { + throw new IllegalStateException( + "Timed out reading output of command: " + String.join(" ", command), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while running command: " + String.join(" ", command), e); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + static List splitNonBlankLines(String text) { + List lines = new ArrayList<>(); + for (String line : text.split("\\R")) { + if (!line.trim().isEmpty()) { + lines.add(line.trim()); + } + } + return lines; + } + + private static final class ProcessResult { + private final int exitCode; + private final String stdout; + private final String stderr; + + private ProcessResult(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + } +} diff --git a/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/README.md b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/README.md new file mode 100644 index 00000000000..91b363cd3a4 --- /dev/null +++ b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/README.md @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +# Shared test database tuning + +`mysql.args` and `postgresql.args` hold extra server flags that +`SharedDbContainerService` passes to the single shared MySQL / PostgreSQL +container used by the `core` database tests. This file explains what the tuning +does, what it bought, and -- most importantly -- how to re-derive the memory +caps if the CI runner class ever changes. + +## What the tuning does and why + +The `core` DB tests used to get one container per test fork. They now share one +container per backend, which removes a lot of container startup cost but means +a single server absorbs roughly **twice the concurrent DDL/DML** it saw before. + +That matters because a database's default durability guarantees are *per-commit* +costs: an fsync on every commit, doublewrite / full-page writes, binary logging, +and frequent checkpoints. Doubling the write load multiplies those costs rather +than absorbing them, so the shared container started out *slower* than the +per-fork design it replaced. + +The `.args` files relax exactly those guarantees (`fsync=off`, +`innodb_flush_log_at_trx_commit=0`, `synchronous_commit=off`, +`full_page_writes=off`, `--skip-log-bin`, `autovacuum=off`, a long +`checkpoint_timeout`, and a larger buffer pool / `shared_buffers`), and the +service mounts each data dir on `tmpfs`. This is safe **here and only here**: +the container is disposable and is destroyed at the end of the run, so crash +recovery, replication and long-term maintenance have no one to serve. None of +these settings would be acceptable in production. + +One flag is deliberately *not* set: `--performance-schema=OFF`. +`TestRoleMembershipWrites` and `TestOwnerAssignmentWrites` verify real row-lock +contention by polling `performance_schema.data_lock_waits`. With the +performance schema disabled that table is silently always empty, so those +assertions would stop verifying anything without ever failing. + +## Measured effect + +Measured on `apache/gravitino#13553` with `-PcoreDatabaseForks=2`: + +| Task | Before (per-fork) | After (shared + tuned) | Speedup | +| ------------------------- | ----------------- | ---------------------- | ------- | +| `core:coreMySQLTest` | 26m25s | 7m04s | 3.74x | +| `core:corePostgreSQLTest` | 7m10s | 2m11s | 3.28x | +| `core:coreH2Test` | 1m27s | 1m21s | ~1.0x (control) | + +`coreH2Test` uses no container and is flat across every run, which is what +confirms the speedup is database I/O rather than a faster host. + +### Attribution: the tmpfs data dir did most of the work + +The numbers above were originally collected while a classpath-path bug meant +the `.args` files were never actually loaded (`ClassLoader#getResourceAsStream` +resolving an absolute name that did not match where the resources are +packaged), so both containers silently ran on stock server defaults. They +therefore measure **the tmpfs data dir alone**, not the server flags. + +Re-measured after that bug was fixed, with the flags verified as applied in the +running container (`innodb_buffer_pool_size=2147483648`, +`innodb_flush_log_at_trx_commit=0`, `innodb_doublewrite=OFF`, `log_bin=OFF`): + +| Task | tmpfs only | tmpfs + flags | Flags' marginal gain | +| ------------------------- | ---------- | ------------- | -------------------- | +| `core:coreMySQLTest` | 7m05s | 6m40s | ~6% | +| `core:corePostgreSQLTest` | 2m11s | 2m10s | ~0% (within noise) | + +So the headline 3-4x speedup comes almost entirely from moving the data dir to +`tmpfs`; the durability flags are a modest additional gain on top. Keep that in +mind before trading test fidelity for another flag -- the remaining headroom is +small. It is also why `--performance-schema=OFF` is not worth having: it would +buy a little overhead back while silently disabling the lock-contention +assertions described above. + +### Caveat: those numbers came from bigger hardware than CI + +The table above was measured on an **8 vCPU / 31 GB** development box. This +repo's actual lane -- the backend integration-test job in +`.github/workflows/backend-integration-test.yml` -- is `runs-on: ubuntu-22.04`, +a GitHub-hosted runner with **4 vCPU / 16 GiB RAM**. Do not expect to reproduce +3.73x there. + +Expect a real but smaller speedup. The win is **architectural**, not tied to a +specific machine: relaxed fsync/checkpoint semantics and a `tmpfs` data dir +remove I/O work that no amount of CPU would have made free. A smaller box has +less parallelism to exploit and less page cache, so the multiplier shrinks -- +the direction does not. + +## Headroom math: sizing the tmpfs caps + +Both `tmpfs` mounts are explicitly capped. This is required, not tidiness: an +uncapped `--tmpfs` defaults to **50% of host RAM per mount**, so two uncapped +mounts can claim 100% of RAM and get the Gradle daemon or a test fork +OOM-killed instead of failing cleanly. + +Use this formula to re-derive the caps for a different runner class: + +``` +safe tmpfs ceiling = total RAM + - build-process reserve (CoreDatabaseConcurrency + DEFAULT_BUILD_PROCESS_MEMORY_RESERVE_BYTES) + - forks x (max heap + native overhead) + (TEST_WORKER_MAX_HEAP_MIB + + DEFAULT_TEST_WORKER_OVERHEAD_BYTES) + - database process RSS (innodb_buffer_pool_size / + shared_buffers, plus overhead) + - OS and Docker daemon +``` + +Worked example for the current `ubuntu-22.04` lane at `-PcoreDatabaseForks=2`: + +| Term | Size | +| ----------------------------------------------- | ------- | +| Total RAM | 16 GiB | +| Build-process reserve | -4 GiB | +| 2 forks x (4 GiB heap + 2 GiB native overhead) | -12 GiB | + +The JVM side alone nominally commits the entire box, so the mounts must be a +bounded worst case rather than an open-ended 8 GiB each. Real fork heap usage +sits well below the 4 GiB maximum, which is where the actual headroom comes +from -- but the *cap* has to assume it might not. + +Current caps: + +- **MySQL, `size=2g`.** The core test schema is small, and `--skip-log-bin` + plus the default (~100 MiB) redo capacity mean little besides table data + lands on the mount. The 2G buffer pool is separately resident as container + RSS, not on the tmpfs. +- **PostgreSQL, `size=5g`.** Deliberately larger, because `pg_wal` lives + *inside* the data dir and `postgresql.args` sets `max_wal_size=4GB` with + `checkpoint_timeout=1h`. Up to ~4 GiB of WAL can legitimately accumulate + before a checkpoint recycles it, so a 2g cap would turn a merely-late + checkpoint into an `ENOSPC` and a PostgreSQL `PANIC` mid-run. 5g = ~4 GiB WAL + headroom + ~1 GiB schema and data. + +Worst case both mounts total **7 GiB**, leaving ~9 GiB for the OS, both +database processes' RSS, and the fork JVMs' real heap usage. + +If you raise `max_wal_size` or `checkpoint_timeout`, raise the PostgreSQL cap +to match. If you move to a smaller runner, re-run the formula rather than +assuming these two numbers still fit. diff --git a/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/mysql.args b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/mysql.args new file mode 100644 index 00000000000..b3cc2d61e2c --- /dev/null +++ b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/mysql.args @@ -0,0 +1,52 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Extra mysqld command-line flags for the shared MySQL test container +# (SharedDbContainerService). One token per line; blank lines and lines +# starting with # are ignored. +# +# Why these are safe HERE (and would NOT be safe in production): this +# container is disposable and is torn down at the end of every test run, +# so there is no need for crash durability, replication, or monitoring. +# +# Measured effect (apache/gravitino#13553, an 8 vCPU / 31 GB Linux machine, +# -PcoreDatabaseForks=2), core:coreMySQLTest wall clock: +# 26m25s per-fork containers (old design) +# 27m53s shared container, untuned +# 7m05s shared container + tmpfs data dir (flags NOT yet loading) +# 6m40s shared container + tmpfs data dir + the flags below, verified +# as actually applied inside the running container +# The staged numbers above predate the fix for a classpath bug that stopped +# this file from ever being read, so the large win is attributable to the tmpfs +# data dir; these flags add roughly a further 6%. See README.md next to this +# file for the full attribution table. +# All runs: 520 tests, 0 failures, 0 deadlocks / lock-wait timeouts. +# core:coreH2Test is unchanged across all runs (1m27s -> 1m21s), the control +# confirming the speedup is database I/O and not the host. +# +# NOTE: --performance-schema is deliberately NOT disabled here, even though +# turning it off would shave a little more overhead. TestRoleMembershipWrites +# and TestOwnerAssignmentWrites assert on real row-lock contention by polling +# performance_schema.data_lock_waits; with the performance schema off that +# table is silently always empty, so those assertions would stop verifying +# anything without ever failing. +--innodb-flush-log-at-trx-commit=0 +--sync-binlog=0 +--innodb-buffer-pool-size=2G +--innodb-log-buffer-size=64M +--skip-log-bin +--innodb-doublewrite=0 diff --git a/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/postgresql.args b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/postgresql.args new file mode 100644 index 00000000000..a7a0548e262 --- /dev/null +++ b/buildSrc/src/main/resources/org/apache/gravitino/testing/shared-db-tuning/postgresql.args @@ -0,0 +1,44 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Extra postgres server args for the shared PostgreSQL test container +# (SharedDbContainerService). One token per line; each pair of +# '-c' / 'key=value' lines becomes one '-c key=value' server arg. Blank +# lines and lines starting with # are ignored. +# +# Same rationale as mysql.args: this container is disposable and torn down +# at the end of every test run, so relaxing crash-durability guarantees and +# skipping long-term maintenance work (autovacuum) is safe here and would +# NOT be safe in production. +-c +fsync=off +-c +synchronous_commit=off +-c +full_page_writes=off +-c +shared_buffers=1GB +-c +max_wal_size=4GB +-c +checkpoint_timeout=1h +-c +autovacuum=off +-c +wal_level=minimal +-c +max_wal_senders=0 diff --git a/buildSrc/src/test/java/org/apache/gravitino/testing/TestSharedDbContainerService.java b/buildSrc/src/test/java/org/apache/gravitino/testing/TestSharedDbContainerService.java new file mode 100644 index 00000000000..9fa7ca5627c --- /dev/null +++ b/buildSrc/src/test/java/org/apache/gravitino/testing/TestSharedDbContainerService.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.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SharedDbContainerService}'s pure parsing/liveness logic. Container + * lifecycle itself (starting a container, readiness polling, stale-container removal) needs a + * real Docker daemon and is exercised by the Core database Gradle lanes instead. + */ +public class TestSharedDbContainerService { + + @Test + public void testParseHostPortSingleLine() { + assertEquals(49153, SharedDbContainerService.parseHostPort("0.0.0.0:49153", "abc123")); + } + + @Test + public void testParseHostPortUsesFirstLineWhenMultipleAddressFamiliesPublished() { + // `docker port` prints one line per address family when the daemon also publishes IPv6. + assertEquals( + 49153, + SharedDbContainerService.parseHostPort("0.0.0.0:49153\n[::]:49153", "abc123")); + } + + @Test + public void testParseHostPortTrimsWhitespace() { + assertEquals(49153, SharedDbContainerService.parseHostPort(" 0.0.0.0:49153 \n", "abc123")); + } + + @Test + public void testParseHostPortThrowsOnEmptyOutput() { + IllegalStateException e = + assertThrows( + IllegalStateException.class, + () -> SharedDbContainerService.parseHostPort("", "abc123")); + assertTrue(e.getMessage().contains("abc123")); + } + + @Test + public void testParseHostPortThrowsOnNonNumericPort() { + assertThrows( + NumberFormatException.class, + () -> SharedDbContainerService.parseHostPort("0.0.0.0:notaport", "abc123")); + } + + @Test + public void testSplitNonBlankLinesFiltersBlanksAndTrims() { + List lines = SharedDbContainerService.splitNonBlankLines(" a \n\n b\n \nc"); + assertEquals(List.of("a", "b", "c"), lines); + } + + @Test + public void testSplitNonBlankLinesEmptyInputReturnsEmptyList() { + assertTrue(SharedDbContainerService.splitNonBlankLines("").isEmpty()); + assertTrue(SharedDbContainerService.splitNonBlankLines(" \n \n").isEmpty()); + } + + @Test + public void testIsOwningProcessAliveTrueForCurrentProcess() { + assertTrue( + SharedDbContainerService.isOwningProcessAlive( + String.valueOf(ProcessHandle.current().pid()))); + } + + @Test + public void testIsOwningProcessAliveFalseForUnusedPid() { + // PID 0 is reserved/invalid on essentially every OS Gradle runs on, and is never a real, + // live user process - a safe stand-in for "definitely not alive" without depending on any + // specific dead PID happening to be free at test time. + assertFalse(SharedDbContainerService.isOwningProcessAlive("0")); + } + + @Test + public void testIsOwningProcessAliveFalseForUnparsableLabel() { + // A missing/malformed PID label (e.g. a container from before this label existed) must bias + // toward "not alive" so removeStaleContainers() reaps it rather than leaking it forever. + assertFalse(SharedDbContainerService.isOwningProcessAlive("")); + assertFalse(SharedDbContainerService.isOwningProcessAlive("not-a-pid")); + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c59110e080c..1588bb18784 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -2,6 +2,9 @@ import com.sun.management.OperatingSystemMXBean import net.ltgt.gradle.errorprone.errorprone import org.apache.gravitino.testing.CoreDatabaseConcurrency import org.apache.gravitino.testing.CoreDatabaseConcurrency.DetectedCapacity +import org.apache.gravitino.testing.DbConnectionInfo +import org.apache.gravitino.testing.SharedDbContainerService +import org.gradle.api.provider.Provider import java.lang.management.ManagementFactory /* @@ -167,6 +170,12 @@ val macDockerConnectorFixedNetwork = CoreDatabaseConcurrency.MAC_DOCKER_CONNECTOR_FIXED_NETWORK_EXTRA ] as? Boolean ?: false +// Connection pool budget for the shared-container path: several test-JVM forks may briefly hold +// connections against the same physical server at once, so this stays well under MySQL's default +// max_connections=151 / PostgreSQL's default max_connections=100 even with a handful of forks and +// classes active concurrently, while remaining far larger than any single test actually needs. +val sharedDbTestConnectionPoolSize = 20 + fun registerCoreTestTask( taskName: String, backend: String? = null @@ -292,6 +301,35 @@ fun registerCoreTestTask( ) } } + + // Contract with SharedCoreDatabaseProvisioner (core/src/test/java/.../storage/relational) + // and its ADMIN_PROPERTY_PREFIX/POOL_MAX_CONNECTIONS_PROPERTY constants - kept as literal + // strings on both sides rather than importing that class here, since a main build script + // cannot see a project's own test-source classes at configuration time (they are compiled + // by a task, not on the script's own classpath). + val sharedDbAdminPropertyPrefix = "gravitino.test.db." + systemProperty(sharedDbAdminPropertyPrefix + "pool.maxConnections", sharedDbTestConnectionPoolSize) + + // Must match root build.gradle.kts's own dockerTest wiring exactly (rootProject.extra + // only, not e.g. System.getenv("dockerTest")): it unconditionally sets this task's + // dockerTest environment variable from rootProject.extra["dockerTest"], overriding + // anything a developer exported in their shell, which is what BackendTestExtension + // actually reads at test-JVM runtime. Gating shared-container startup on anything looser + // here could start it even when the forked test JVM will never see dockerTest=true and so + // never use it. + if (rootProject.extra["dockerTest"] == true) { + @Suppress("UNCHECKED_CAST") + val sharedDbContainerService = + rootProject.extra["sharedDbContainerService"] as Provider + usesService(sharedDbContainerService) + + doFirst { + val connectionInfo: DbConnectionInfo = sharedDbContainerService.get().connectionInfo(backend) + systemProperty(sharedDbAdminPropertyPrefix + backend + ".adminUrl", connectionInfo.adminJdbcUrl) + systemProperty(sharedDbAdminPropertyPrefix + backend + ".user", connectionInfo.user) + systemProperty(sharedDbAdminPropertyPrefix + backend + ".password", connectionInfo.password) + } + } } } } 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 e9a4b275fce..ca44d031b08 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 @@ -210,28 +210,45 @@ public BackendResource create(String backendType) throws Exception { Mockito.when(config.get(Configs.ENTITY_STORE)).thenReturn(Configs.RELATIONAL_ENTITY_STORE); Mockito.when(config.get(Configs.ENTITY_RELATIONAL_STORE)) .thenReturn(DEFAULT_ENTITY_RELATIONAL_STORE); + // On the shared-container path, several test-JVM forks may hold a connection pool against + // the same physical server at once, so the Gradle 'core' test task publishes a smaller, + // shared-safe pool size (see SharedCoreDatabaseProvisioner.POOL_MAX_CONNECTIONS_PROPERTY) + // instead of the much larger per-fork-container default. Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS)) - .thenReturn(DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS); + .thenReturn( + Integer.getInteger( + SharedCoreDatabaseProvisioner.POOL_MAX_CONNECTIONS_PROPERTY, + DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS)); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_MAX_IDLE_CONNECTIONS)).thenReturn(10); Mockito.when(config.get(ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS)) .thenReturn(DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_WAIT_MILLISECONDS); Mockito.when(config.get(CACHE_ENABLED)).thenReturn(true); RelationalBackend backend = new JDBCBackend(); - 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(backendType)) { - String url = baseIT.startAndInitPGBackend(); + if ("mysql".equals(backendType) || "postgresql".equals(backendType)) { + if (BackendTestSelector.selectedBackend().isPresent() + && !SharedCoreDatabaseProvisioner.isEnabled(backendType)) { + // The Gradle database tasks always publish the shared server. If it is missing, the + // build wiring broke; fail rather than quietly fall back to slow per-JVM containers. + throw new IllegalStateException( + "No shared " + + backendType + + " server was published to this test JVM; run it through the core " + + backendType + + " Gradle task."); + } + String url = + SharedCoreDatabaseProvisioner.isEnabled(backendType) + ? SharedCoreDatabaseProvisioner.provision(backendType) + : "mysql".equals(backendType) + ? baseIT.startAndInitMySQLBackend() + : baseIT.startAndInitPGBackend(); 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("org.postgresql.Driver"); + .thenReturn( + "mysql".equals(backendType) ? "com.mysql.cj.jdbc.Driver" : "org.postgresql.Driver"); } else { // H2 Logic String uuid = UUID.randomUUID().toString().replace("-", ""); diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/SharedCoreDatabaseProvisioner.java b/core/src/test/java/org/apache/gravitino/storage/relational/SharedCoreDatabaseProvisioner.java new file mode 100644 index 00000000000..eba652c45e6 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/SharedCoreDatabaseProvisioner.java @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.storage.relational; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nullable; +import org.apache.gravitino.config.ConfigConstants; +import org.apache.gravitino.integration.test.container.DatabaseProvisioning; + +/** + * Provisions an isolated database for one Core database test-JVM fork on a shared, build-wide + * MySQL/PostgreSQL container, instead of that fork starting its own container. + * + *

    Active only when the Gradle {@code core} test task's shared-container {@code + * SharedDbContainerService} (buildSrc) has published admin connection info as system properties + * under {@link #ADMIN_PROPERTY_PREFIX}; {@link #isEnabled(String)} returns {@code false} otherwise, + * e.g. when this test class is run directly from an IDE outside the Gradle task, and the caller is + * expected to fall back to starting a fresh, per-fork container as before. + * + *

    Each call to {@link #provision(String)} allocates and migrates a brand-new database - this + * relies on {@link BackendTestExtension} only calling it when a genuinely new backend is actually + * needed (a fresh {@link DatabaseTestContext} fixture, or the first fixture for a test class); + * reuse across test methods within a class is already handled one layer up, by {@link + * BackendTestExtension}'s own class-scoped backend cache. Databases provisioned here are never + * individually dropped; they are discarded together with the shared container when the build ends + * (see {@code SharedDbContainerService#close()} in buildSrc). + */ +final class SharedCoreDatabaseProvisioner { + + /** System property prefix under which the shared-container BuildService publishes info. */ + static final String ADMIN_PROPERTY_PREFIX = "gravitino.test.db."; + + /** System property carrying the shared-safe connection-pool budget, if published. */ + static final String POOL_MAX_CONNECTIONS_PROPERTY = ADMIN_PROPERTY_PREFIX + "pool.maxConnections"; + + private static final AtomicLong DATABASE_COUNTER = new AtomicLong(); + + private SharedCoreDatabaseProvisioner() {} + + /** + * Returns whether the shared-container BuildService has published admin connection info for + * {@code backendType} ({@code "mysql"} or {@code "postgresql"}). + */ + static boolean isEnabled(String backendType) { + return adminUrl(backendType) != null; + } + + /** + * Creates and migrates a brand-new, uniquely-named database on the shared container for {@code + * backendType}, returning its JDBC URL. + * + * @throws IllegalStateException if {@link #isEnabled(String)} is {@code false} for {@code + * backendType} + */ + static String provision(String backendType) throws IOException { + String adminUrl = adminUrl(backendType); + if (adminUrl == null) { + throw new IllegalStateException( + "Shared-container mode is not enabled for backend " + backendType); + } + String adminUser = System.getProperty(ADMIN_PROPERTY_PREFIX + backendType + ".user"); + String adminPassword = System.getProperty(ADMIN_PROPERTY_PREFIX + backendType + ".password"); + + if ("mysql".equals(backendType)) { + loadMySQLDriver(); + } + + String dbName = nextDatabaseName(); + DatabaseProvisioning.createDatabaseIfAbsent(adminUrl, adminUser, adminPassword, dbName); + String dbUrl = buildJdbcUrlForDatabase(adminUrl, dbName); + runSchemaMigration(backendType, dbUrl, adminUser, adminPassword); + return dbUrl; + } + + /** + * Forces the legacy MySQL JDBC driver class to load before the first {@link + * DriverManager#getConnection}, mirroring the same workaround {@code + * MySQLContainer#createDatabase} already applies for #6392 (the driver may not register + * itself via {@link java.util.ServiceLoader} in time on some JVMs/classloaders). + */ + private static void loadMySQLDriver() { + try { + Class.forName("com.mysql.jdbc.Driver"); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Failed to load MySQL JDBC driver", e); + } + } + + @Nullable + private static String adminUrl(String backendType) { + String value = System.getProperty(ADMIN_PROPERTY_PREFIX + backendType + ".adminUrl"); + return (value == null || value.isEmpty()) ? null : value; + } + + /** + * Computes a fresh database name: {@code "gravitino_core_" + sanitize(worker) + "_" + counter}, + * where {@code worker} is {@code System.getProperty("org.gradle.test.worker", "local")} and + * {@code counter} is a per-JVM monotonically increasing value - unlike a name derived only from a + * test class name, this can never collide within one fork no matter how many databases that fork + * ends up provisioning, and the worker prefix keeps distinct forks apart from each other. + */ + static String nextDatabaseName() { + String worker = + System.getProperty("org.gradle.test.worker", "local").replaceAll("[^a-zA-Z0-9_$]", "_"); + String name = "gravitino_core_" + worker + "_" + DATABASE_COUNTER.incrementAndGet(); + if (!DatabaseProvisioning.isValidDatabaseName(name)) { + // Only reachable for an absurdly long worker id - fail loudly instead of silently + // colliding or truncating into an unrelated name. + throw new IllegalStateException("Computed an invalid shared database name: " + name); + } + return name; + } + + /** + * Derives the JDBC URL to connect directly to database {@code dbName} on the same server as + * {@code adminUrl}, by replacing whatever database name (if any) {@code adminUrl} points at. + */ + static String buildJdbcUrlForDatabase(String adminUrl, String dbName) { + int queryIndex = adminUrl.indexOf('?'); + String base = queryIndex >= 0 ? adminUrl.substring(0, queryIndex) : adminUrl; + String query = queryIndex >= 0 ? adminUrl.substring(queryIndex) : ""; + + int schemeEnd = base.indexOf("://"); + int hostStart = schemeEnd >= 0 ? schemeEnd + 3 : 0; + int pathStart = base.indexOf('/', hostStart); + String hostPart = pathStart >= 0 ? base.substring(0, pathStart) : base; + return hostPart + "/" + dbName + query; + } + + /** + * Runs the same OSS schema SQL that {@link + * org.apache.gravitino.integration.test.util.BaseIT#startAndInitMySQLBackend()} / {@link + * org.apache.gravitino.integration.test.util.BaseIT#startAndInitPGBackend()} apply, against the + * given already-created, empty database. + */ + private static void runSchemaMigration(String type, String dbUrl, String user, String password) + throws IOException { + String gravitinoHome = + Objects.requireNonNull( + System.getenv("GRAVITINO_ROOT_DIR"), + "GRAVITINO_ROOT_DIR must be set to locate the schema SQL to migrate a shared " + + type + + " database"); + String schemaContent = loadSchemaContent(gravitinoHome, type); + + String[] statements = + Arrays.stream(schemaContent.split(";")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + + String currentStatement = ""; + try (Connection connection = DriverManager.getConnection(dbUrl, user, password); + Statement statement = connection.createStatement()) { + for (String sql : statements) { + currentStatement = sql; + statement.execute(sql); + } + } catch (SQLException e) { + throw new RuntimeException( + "Failed to migrate shared " + + type + + " database " + + dbUrl + + ", statement: " + + currentStatement, + e); + } + } + + private static String loadSchemaContent(String gravitinoHome, String databaseType) + throws IOException { + String version = ConfigConstants.CURRENT_SCRIPT_VERSION; + Path schema = + Path.of( + gravitinoHome, + "scripts", + databaseType, + String.format("schema-%s-%s.sql", version, databaseType)); + return Files.readString(schema, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/org/apache/gravitino/storage/relational/TestSharedCoreDatabaseProvisioner.java b/core/src/test/java/org/apache/gravitino/storage/relational/TestSharedCoreDatabaseProvisioner.java new file mode 100644 index 00000000000..f456c6862e4 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/storage/relational/TestSharedCoreDatabaseProvisioner.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.annotation.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SharedCoreDatabaseProvisioner}'s pure logic: {@link + * SharedCoreDatabaseProvisioner#isEnabled(String)}, {@link + * SharedCoreDatabaseProvisioner#nextDatabaseName()}, and {@link + * SharedCoreDatabaseProvisioner#buildJdbcUrlForDatabase(String, String)}. Provisioning against a + * real server is exercised indirectly by the docker-tagged integration tests instead. + */ +public class TestSharedCoreDatabaseProvisioner { + + private static final String ADMIN_URL_PROPERTY = + SharedCoreDatabaseProvisioner.ADMIN_PROPERTY_PREFIX + "mysql.adminUrl"; + + // org.gradle.test.worker is set by Gradle itself for the lifetime of a forked test-worker JVM + // and is also read elsewhere in the same JVM (CoreDatabaseShardTelemetryExtension), so tests + // must save and restore it exactly as they found it rather than clearing it outright - Gradle + // reuses worker JVMs across test classes, so clearing it here would corrupt telemetry (and, + // for any future database-tagged class sharing this fork, database naming) for every class + // that happens to run afterward in the same fork. + private @Nullable String originalAdminUrlProperty; + private @Nullable String originalWorkerProperty; + + @BeforeEach + public void saveProperties() { + originalAdminUrlProperty = System.getProperty(ADMIN_URL_PROPERTY); + originalWorkerProperty = System.getProperty("org.gradle.test.worker"); + } + + @AfterEach + public void restoreProperties() { + restoreProperty(ADMIN_URL_PROPERTY, originalAdminUrlProperty); + restoreProperty("org.gradle.test.worker", originalWorkerProperty); + } + + private static void restoreProperty(String name, @Nullable String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + @Test + public void testIsEnabledFalseWhenPropertyAbsent() { + System.clearProperty(ADMIN_URL_PROPERTY); + assertFalse(SharedCoreDatabaseProvisioner.isEnabled("mysql")); + } + + @Test + public void testIsEnabledFalseWhenPropertyEmpty() { + System.setProperty(ADMIN_URL_PROPERTY, ""); + assertFalse(SharedCoreDatabaseProvisioner.isEnabled("mysql")); + } + + @Test + public void testIsEnabledTrueWhenPropertyPresent() { + System.setProperty(ADMIN_URL_PROPERTY, "jdbc:mysql://127.0.0.1:3306"); + assertTrue(SharedCoreDatabaseProvisioner.isEnabled("mysql")); + } + + @Test + public void testNextDatabaseNameIsValidAndUnique() { + System.setProperty("org.gradle.test.worker", "1"); + String first = SharedCoreDatabaseProvisioner.nextDatabaseName(); + String second = SharedCoreDatabaseProvisioner.nextDatabaseName(); + + assertTrue(first.matches("^[a-zA-Z0-9_$]+$"), "name must match the allowed character set"); + assertTrue(first.length() <= 63, "name must not exceed the 63-character cap"); + assertTrue(first.startsWith("gravitino_core_1_")); + assertNotEquals(first, second, "consecutive calls must never collide"); + } + + @Test + public void testNextDatabaseNameSanitizesWorkerId() { + // org.gradle.test.worker values are numeric in practice, but the sanitizer must still handle + // a non-alphanumeric id defensively (e.g. a manually-set property) without producing an + // invalid database name. + System.setProperty("org.gradle.test.worker", "gradle-worker-1"); + String name = SharedCoreDatabaseProvisioner.nextDatabaseName(); + assertTrue(name.matches("^[a-zA-Z0-9_$]+$")); + } + + @Test + public void testBuildJdbcUrlForDatabaseMySqlNoPath() { + String url = + SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase( + "jdbc:mysql://127.0.0.1:3306", "gravitino_core_1_1"); + assertEquals("jdbc:mysql://127.0.0.1:3306/gravitino_core_1_1", url); + } + + @Test + public void testBuildJdbcUrlForDatabasePostgresTrailingSlash() { + String url = + SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase( + "jdbc:postgresql://127.0.0.1:5432/", "gravitino_core_1_1"); + assertEquals("jdbc:postgresql://127.0.0.1:5432/gravitino_core_1_1", url); + } + + @Test + public void testBuildJdbcUrlForDatabasePreservesQueryString() { + String url = + SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase( + "jdbc:postgresql://127.0.0.1:5432/?sslmode=disable", "gravitino_core_1_1"); + assertEquals("jdbc:postgresql://127.0.0.1:5432/gravitino_core_1_1?sslmode=disable", url); + } + + @Test + public void testBuildJdbcUrlForDatabaseIpv6Host() { + String url = + SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase( + "jdbc:mysql://[::1]:3306", "gravitino_core_1_1"); + assertEquals("jdbc:mysql://[::1]:3306/gravitino_core_1_1", url); + } + + @Test + public void testBuildJdbcUrlForDatabaseReplacesExistingDatabaseName() { + String url = + SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase( + "jdbc:mysql://127.0.0.1:3306/some_other_db", "gravitino_core_1_1"); + assertEquals("jdbc:mysql://127.0.0.1:3306/gravitino_core_1_1", url); + } + + @Test + public void testBuildJdbcUrlForDatabaseHandlesSchemelessUrl() { + // No "://" at all - schemeEnd < 0, so the whole string is treated as the host part. + String url = SharedCoreDatabaseProvisioner.buildJdbcUrlForDatabase("127.0.0.1:3306", "db1"); + assertEquals("127.0.0.1:3306/db1", url); + } + + @Test + public void testNextDatabaseNameThrowsWhenWorkerIdMakesTheNameTooLong() { + // MAX_SHARED_DB_NAME_LENGTH is 63 and there is no class-name segment to truncate here (unlike + // BackendTestExtension's per-class variant), so an absurdly long worker id is the only way to + // exceed the cap - confirm it fails loudly instead of silently truncating into a name another + // worker could collide with. + System.setProperty("org.gradle.test.worker", "w".repeat(60)); + assertThrows(IllegalStateException.class, SharedCoreDatabaseProvisioner::nextDatabaseName); + } + + @Test + public void testAdminPropertyPrefixMatchesTheContractCoreBuildGradleKtsPublishesTo() { + // core/build.gradle.kts cannot import this Java test-source class at Gradle configuration + // time, so it re-declares this same prefix as a Kotlin string literal instead. Pin the + // literal here so a rename on either side that breaks the contract fails loudly (as a test + // failure) instead of silently disabling shared-container mode (isEnabled() just returns + // false when the properties never arrive, with no other signal). + assertEquals("gravitino.test.db.", SharedCoreDatabaseProvisioner.ADMIN_PROPERTY_PREFIX); + assertEquals( + "gravitino.test.db.pool.maxConnections", + SharedCoreDatabaseProvisioner.POOL_MAX_CONNECTIONS_PROPERTY); + } +} diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/DatabaseProvisioning.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/DatabaseProvisioning.java new file mode 100644 index 00000000000..6d819e8d4ac --- /dev/null +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/DatabaseProvisioning.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 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 java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared, database-agnostic helper for provisioning a test database on an already-running MySQL or + * PostgreSQL container. + * + *

    This class holds the validated "create database if it does not already exist" logic that used + * to live separately inside {@link MySQLContainer#createDatabase( + * org.apache.gravitino.integration.test.util.TestDatabaseName)} and {@link + * PostgreSQLContainer#createDatabase(org.apache.gravitino.integration.test.util.TestDatabaseName)}. + * Both container classes now delegate to {@link #createDatabaseIfAbsent(String, String, String, + * String)}, and any other caller (for example a shared-container test extension) that only has a + * JDBC admin URL, credentials, and a database name can use it directly without going through a + * {@code TestDatabaseName} enum value or a container instance. + */ +public class DatabaseProvisioning { + + private static final Logger LOG = LoggerFactory.getLogger(DatabaseProvisioning.class); + + private static final int MAX_DATABASE_NAME_LENGTH = 63; + + private static final String VALID_DATABASE_NAME_REGEX = "^[a-zA-Z0-9_$]+$"; + + private static final String MYSQL_JDBC_URL_PREFIX = "jdbc:mysql:"; + + private static final String POSTGRESQL_JDBC_URL_PREFIX = "jdbc:postgresql:"; + + /** PostgreSQL's SQLSTATE for "a database with this name already exists". */ + private static final String POSTGRESQL_DUPLICATE_DATABASE_SQLSTATE = "42P04"; + + // Private constructor to prevent instantiation of this static helper class. + private DatabaseProvisioning() {} + + /** + * Creates a database on the target MySQL or PostgreSQL server if it does not already exist. + * + *

    The dialect (MySQL vs. PostgreSQL) is inferred from the scheme of {@code adminJdbcUrl} + * ({@code jdbc:mysql:...} or {@code jdbc:postgresql:...}). {@code adminJdbcUrl} must be a JDBC + * URL that can be connected to without naming {@code dbName} itself, e.g. the server root URL or + * another already-existing database on the same server. + * + *

    For MySQL this issues {@code CREATE DATABASE IF NOT EXISTS }. PostgreSQL has no + * {@code IF NOT EXISTS} clause for {@code CREATE DATABASE}, so this first checks {@code + * pg_database} for an existing row and only issues {@code CREATE DATABASE ""} when none + * is found. + * + * @param adminJdbcUrl a JDBC URL that can be connected to without naming {@code dbName} + * @param user the database user used to connect and create the database + * @param password the password for {@code user} + * @param dbName the name of the database to create; validated against {@link + * #isValidDatabaseName(String)} + * @throws IllegalArgumentException if {@code dbName} is invalid, or {@code adminJdbcUrl} is + * neither a MySQL nor a PostgreSQL JDBC URL + * @throws RuntimeException if the database cannot be created + *

    Note for existing {@link MySQLContainer#createDatabase}/{@link + * PostgreSQLContainer#createDatabase} callers migrated onto this helper: the accepted name + * length cap is 63 characters (PostgreSQL's identifier limit), tighter than the 64 {@code + * MySQLContainer} previously allowed on its own; every {@code TestDatabaseName} value in this + * repo is well under that either way. An invalid name now surfaces as a bare {@link + * IllegalArgumentException} rather than one wrapped in a {@code RuntimeException}. + */ + public static void createDatabaseIfAbsent( + String adminJdbcUrl, String user, String password, String dbName) { + if (!isValidDatabaseName(dbName)) { + throw new IllegalArgumentException("Invalid database name: " + dbName); + } + + if (adminJdbcUrl.startsWith(MYSQL_JDBC_URL_PREFIX)) { + createMySQLDatabaseIfAbsent(adminJdbcUrl, user, password, dbName); + } else if (adminJdbcUrl.startsWith(POSTGRESQL_JDBC_URL_PREFIX)) { + createPostgreSQLDatabaseIfAbsent(adminJdbcUrl, user, password, dbName); + } else { + throw new IllegalArgumentException( + "Unsupported JDBC URL for database provisioning: " + adminJdbcUrl); + } + } + + /** + * Validates that {@code databaseName} only contains safe characters and does not exceed the + * shared length cap, so it can be interpolated into a {@code CREATE DATABASE} statement. + * + * @param databaseName the candidate database name + * @return {@code true} if the name is non-null, non-empty, at most {@value + * #MAX_DATABASE_NAME_LENGTH} characters, and matches {@code ^[a-zA-Z0-9_$]+$} + */ + public static boolean isValidDatabaseName(String databaseName) { + if (databaseName == null || databaseName.isEmpty()) { + return false; + } + + if (databaseName.length() > MAX_DATABASE_NAME_LENGTH) { + return false; + } + + return databaseName.matches(VALID_DATABASE_NAME_REGEX); + } + + private static void createMySQLDatabaseIfAbsent( + String adminJdbcUrl, String user, String password, String dbName) { + try (Connection connection = DriverManager.getConnection(adminJdbcUrl, user, password); + Statement statement = connection.createStatement()) { + executeCreateMySQLDatabase(statement, dbName); + } catch (SQLException e) { + throw new RuntimeException("Failed to create MySQL database " + dbName, e); + } + } + + /** + * Issues the MySQL {@code CREATE DATABASE IF NOT EXISTS} statement against an already-open {@link + * Statement}. Package-private (rather than folded into {@link #createMySQLDatabaseIfAbsent}) so + * it can be unit-tested against a mocked {@link Statement} without a real database connection. + */ + static void executeCreateMySQLDatabase(Statement statement, String dbName) throws SQLException { + // FIXME: dbName is validated by isValidDatabaseName(), but interpolating it into SQL is + // still not ideal. + statement.execute(String.format("CREATE DATABASE IF NOT EXISTS `%s`", dbName)); + LOG.info("MySQL database {} has been created (or already existed)", dbName); + } + + private static void createPostgreSQLDatabaseIfAbsent( + String adminJdbcUrl, String user, String password, String dbName) { + try (Connection connection = DriverManager.getConnection(adminJdbcUrl, user, password); + Statement statement = connection.createStatement()) { + executeCreatePostgreSQLDatabase(statement, dbName); + } catch (SQLException e) { + throw new RuntimeException("Failed to create PostgreSQL database " + dbName, e); + } + } + + /** + * Issues the PostgreSQL exists-check-then-{@code CREATE DATABASE} sequence against an + * already-open {@link Statement}, tolerating SQLSTATE {@value + * #POSTGRESQL_DUPLICATE_DATABASE_SQLSTATE} (duplicate database) as success. Package-private + * (rather than folded into {@link #createPostgreSQLDatabaseIfAbsent}) so it can be unit-tested + * against a mocked {@link Statement}/{@link ResultSet} without a real database connection. + */ + static void executeCreatePostgreSQLDatabase(Statement statement, String dbName) + throws SQLException { + if (postgreSQLDatabaseExists(statement, dbName)) { + LOG.info("PostgreSQL database {} already exists, skipping creation", dbName); + return; + } + + // PostgreSQL's CREATE DATABASE has no IF NOT EXISTS clause, so existence is checked above. + // That check-then-create is not race-safe on its own (another caller could create dbName + // between the check and this statement), so a duplicate-database error here is still + // treated as success rather than propagated. + try { + statement.execute(String.format("CREATE DATABASE \"%s\"", dbName)); + LOG.info("PostgreSQL database {} has been created", dbName); + } catch (SQLException e) { + if (POSTGRESQL_DUPLICATE_DATABASE_SQLSTATE.equals(e.getSQLState())) { + LOG.info( + "PostgreSQL database {} was created concurrently by another caller, skipping", dbName); + return; + } + throw e; + } + } + + static boolean postgreSQLDatabaseExists(Statement statement, String dbName) throws SQLException { + String query = String.format("SELECT 1 FROM pg_database WHERE datname = '%s'", dbName); + try (ResultSet resultSet = statement.executeQuery(query)) { + return resultSet.next(); + } + } +} diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MySQLContainer.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MySQLContainer.java index 3cfb6ecc56b..c17a5b689ce 100644 --- a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MySQLContainer.java +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/MySQLContainer.java @@ -22,10 +22,8 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await; import com.google.common.collect.ImmutableSet; -import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; -import java.sql.Statement; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -128,35 +126,8 @@ public void createDatabase(TestDatabaseName testDatabaseName) { } // change password for root user, Gravitino API must set password in catalog properties - try (Connection connection = - DriverManager.getConnection(mySQLJdbcUrl, USER_NAME, getPassword()); - Statement statement = connection.createStatement()) { - - // validate database name to ensure it only contains safe characters - String databaseName = testDatabaseName.toString(); - if (!isValidDatabaseName(databaseName)) { - throw new IllegalArgumentException("Invalid database name: " + databaseName); - } - - String query = String.format("CREATE DATABASE IF NOT EXISTS `%s`;", databaseName); - // FIXME: String, which is used in SQL, can be unsafe - statement.execute(query); - LOG.info(String.format("MySQL container database %s has been created", testDatabaseName)); - } catch (Exception e) { - throw new RuntimeException("Failed to create database", e); - } - } - - private boolean isValidDatabaseName(String databaseName) { - if (databaseName == null || databaseName.isEmpty()) { - return false; - } - - if (databaseName.length() > 64) { - return false; - } - - return databaseName.matches("^[a-zA-Z0-9_$]+$"); + DatabaseProvisioning.createDatabaseIfAbsent( + mySQLJdbcUrl, USER_NAME, getPassword(), testDatabaseName.toString()); } public String getUsername() { diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/PostgreSQLContainer.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/PostgreSQLContainer.java index 6b2b147b1c8..008e30fb7c9 100644 --- a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/PostgreSQLContainer.java +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/PostgreSQLContainer.java @@ -22,10 +22,8 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await; import com.google.common.collect.ImmutableSet; -import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; -import java.sql.Statement; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -112,19 +110,8 @@ protected boolean checkContainerStatus(int retryLimit) { } public void createDatabase(TestDatabaseName testDatabaseName) { - try (Connection connection = - DriverManager.getConnection(getJdbcUrl(), getUsername(), getPassword()); - Statement statement = connection.createStatement()) { - - String query = format("CREATE DATABASE \"%s\"", testDatabaseName); - statement.execute(query); - LOG.info(format("PostgreSQL container database %s has been created", testDatabaseName)); - } catch (SQLException e) { - if (e.getMessage() - .equals(String.format("ERROR: database \"%s\" already exists", testDatabaseName))) { - LOG.info("PostgreSQL Database {} already exists, skipping", testDatabaseName); - } else throw new RuntimeException(e); - } + DatabaseProvisioning.createDatabaseIfAbsent( + getJdbcUrl(), getUsername(), getPassword(), testDatabaseName.toString()); } public String getUsername() { diff --git a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestDatabaseProvisioning.java b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestDatabaseProvisioning.java new file mode 100644 index 00000000000..f630cc57a77 --- /dev/null +++ b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/TestDatabaseProvisioning.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.integration.test.container; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DatabaseProvisioning}'s pure validation/dispatch logic, plus the PostgreSQL + * exists-check/duplicate-tolerant create sequence and the MySQL statement it issues, both exercised + * here against a mocked {@link Statement} rather than a live server. The connect-then-delegate + * paths ({@code createMySQLDatabaseIfAbsent}/{@code createPostgreSQLDatabaseIfAbsent}) still need a + * live server and are exercised indirectly by the docker-tagged integration tests instead. + */ +public class TestDatabaseProvisioning { + + @Test + public void testIsValidDatabaseNameAcceptsAllowedCharacters() { + assertTrue(DatabaseProvisioning.isValidDatabaseName("gravitino_core_w1")); + assertTrue(DatabaseProvisioning.isValidDatabaseName("a")); + assertTrue(DatabaseProvisioning.isValidDatabaseName("A1_$")); + } + + @Test + public void testIsValidDatabaseNameRejectsNullOrEmpty() { + assertFalse(DatabaseProvisioning.isValidDatabaseName(null)); + assertFalse(DatabaseProvisioning.isValidDatabaseName("")); + } + + @Test + public void testIsValidDatabaseNameRejectsDisallowedCharacters() { + assertFalse(DatabaseProvisioning.isValidDatabaseName("bad-name")); + assertFalse(DatabaseProvisioning.isValidDatabaseName("bad name")); + assertFalse(DatabaseProvisioning.isValidDatabaseName("bad;name")); + assertFalse(DatabaseProvisioning.isValidDatabaseName("bad'name")); + } + + @Test + public void testIsValidDatabaseNameRejectsNamesOverSixtyThreeCharacters() { + String sixtyThree = "a".repeat(63); + String sixtyFour = "a".repeat(64); + assertTrue(DatabaseProvisioning.isValidDatabaseName(sixtyThree)); + assertFalse(DatabaseProvisioning.isValidDatabaseName(sixtyFour)); + } + + @Test + public void testCreateDatabaseIfAbsentRejectsInvalidName() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + DatabaseProvisioning.createDatabaseIfAbsent( + "jdbc:mysql://127.0.0.1:3306", "root", "root", "bad-name")); + assertTrue(e.getMessage().contains("Invalid database name")); + } + + @Test + public void testCreateDatabaseIfAbsentRejectsUnsupportedJdbcUrl() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + DatabaseProvisioning.createDatabaseIfAbsent( + "jdbc:h2:mem:test", "root", "root", "valid_name")); + assertTrue(e.getMessage().contains("Unsupported JDBC URL")); + } + + @Test + public void testExecuteCreateMySQLDatabaseIssuesCreateIfNotExists() throws SQLException { + Statement statement = mock(Statement.class); + DatabaseProvisioning.executeCreateMySQLDatabase(statement, "gravitino_core_1"); + verify(statement).execute("CREATE DATABASE IF NOT EXISTS `gravitino_core_1`"); + } + + @Test + public void testPostgreSQLDatabaseExistsTrueWhenQueryReturnsARow() throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(contains("gravitino_core_1"))).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + + assertTrue(DatabaseProvisioning.postgreSQLDatabaseExists(statement, "gravitino_core_1")); + } + + @Test + public void testPostgreSQLDatabaseExistsFalseWhenQueryReturnsNoRow() throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + + assertFalse(DatabaseProvisioning.postgreSQLDatabaseExists(statement, "gravitino_core_1")); + } + + @Test + public void testExecuteCreatePostgreSQLDatabaseSkipsCreateWhenAlreadyExists() + throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); // exists-check finds a row + + DatabaseProvisioning.executeCreatePostgreSQLDatabase(statement, "gravitino_core_1"); + + verify(statement, never()).execute(anyString()); + } + + @Test + public void testExecuteCreatePostgreSQLDatabaseIssuesCreateWhenAbsent() throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); // exists-check finds no row + + DatabaseProvisioning.executeCreatePostgreSQLDatabase(statement, "gravitino_core_1"); + + verify(statement).execute("CREATE DATABASE \"gravitino_core_1\""); + } + + @Test + public void testExecuteCreatePostgreSQLDatabaseTreatsDuplicateSqlStateAsSuccess() + throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + // 42P04 = PostgreSQL's duplicate_database SQLSTATE: another caller won the race between this + // method's own exists-check and its CREATE DATABASE statement. + when(statement.execute(anyString())) + .thenThrow(new SQLException("database already exists", "42P04")); + + // Must not throw. + DatabaseProvisioning.executeCreatePostgreSQLDatabase(statement, "gravitino_core_1"); + } + + @Test + public void testExecuteCreatePostgreSQLDatabaseRethrowsNonDuplicateSqlState() + throws SQLException { + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + SQLException permissionDenied = new SQLException("permission denied", "42501"); + when(statement.execute(anyString())).thenThrow(permissionDenied); + + SQLException thrown = + assertThrows( + SQLException.class, + () -> + DatabaseProvisioning.executeCreatePostgreSQLDatabase( + statement, "gravitino_core_1")); + assertTrue(thrown == permissionDenied); + } +}