This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 105fe71472 [#13516] improvement(ci): Split core tests into explicit 
database lanes (#13517)
105fe71472 is described below

commit 105fe714720f3b845f48863ef7688ed21b4296a4
Author: Nevin Zheng <[email protected]>
AuthorDate: Wed Oct 14 18:30:15 2026 -0700

    [#13516] improvement(ci): Split core tests into explicit database lanes 
(#13517)
    
    ### What changes were proposed in this pull request?
    
    Split `core` tests into four Gradle lanes, each run as its own CI job,
    so unit tests and each database backend report, fail and time
    independently.
    
    - `coreUnitTest` runs tests with no database backend. `coreH2Test`,
    `coreMySQLTest` and `corePostgreSQLTest` each run one backend,
    sequentially. A class joins a lane with
    `@CoreBackend.H2/MySQL/PostgreSQL/All`.
    - `TestCoreLaneMembership` fails `coreUnitTest` when a Docker-tagged
    class has no backend tag, so a class can't silently drop out of every
    lane.
    - The `coverage` job merges the four lanes' raw JaCoCo data into one
    core report.
    - Three plain `@Test` methods move from `TestJobMetaService` to
    `TestJobMetaServiceValidation`: they need no database, and in a
    `@CoreBackend.All` class they would run three times.
    - `:core:test` still works locally and prints a deprecation warning.
    - `docs/how-to-test.md` describes the lanes and how to check which lane
    a class runs in.
    
    ### Why are the changes needed?
    
    `:core:test` combines unit tests with all three database backends in one
    task. Failures, timing and coverage can't be told apart per backend, and
    the follow-up concurrency work (#13518, #13530, #13553) has no safe
    boundary to build on. Running the lanes in parallel also shortens the
    critical path (see Timing).
    
    Fix: #13516
    
    Related to: #13515
    
    ### Does this PR introduce _any_ user-facing change?
    
    No production API, storage format or configuration changes. Test-only
    changes:
    
    - New Gradle tasks `coreUnitTest`, `coreH2Test`, `coreMySQLTest`,
    `corePostgreSQLTest`, and the test-only system property
    `gravitino.core.test.backend`.
    - `:core:test` is deprecated: it still runs all core tests locally but
    prints a warning pointing to the lanes.
    - Core `@TestTemplate` cases are now reported as `testMethod[H2
    Backend]` instead of `[H2 Backend]`, in JUnit XML and HTML for the new
    lanes and for legacy `:core:test`. Test history keyed on the old names
    starts fresh, and exact-match `--tests` filters need the new name.
    Trailing-wildcard filters such as `*[H2 Backend]` still match.
    - CI checks: `build (17)` and the BackendIT checks keep their names, but
    `build (17)` now skips `:core:test`, so core results appear only under
    the new `core-test (17, unit|h2|mysql|postgresql)` checks. `coverage` is
    now its own check. `.asf.yaml` requires no status checks by name, so
    branch protection needs no change. Merge order for the follow-up PRs is
    on #13515.
    
    ### How was this patch tested?
    
    Locally (macOS, JDK 17, Docker via OrbStack) on the PR head:
    
    - `./gradlew :core:spotlessCheck :core:compileTestJava -PskipWeb=true
    -PskipDockerTests=true`
    - `./gradlew :core:coreUnitTest -PskipWeb=true -PskipDockerTests=true`:
    2,203 tests, 0 failures.
    - `./gradlew :core:coreH2Test -PskipWeb=true -PskipDockerTests=true`:
    588 tests, 0 failures; no Docker container was started.
    - `./gradlew :core:corePostgreSQLTest -PskipWeb=true
    -PskipDockerTests=false`: 588 tests, 0 failures, 3 skipped.
    - `./gradlew :core:coreMySQLTest -PskipWeb=true
    -PskipDockerTests=false`: 588 tests, 0 failures, 2 skipped.
    - The three database lanes ran the same 588 tests once backend names are
    normalized, and no class ran in both the unit lane and a database lane.
    All 312 core test classes JUnit discovers ran in a lane.
    - `TestCoreLaneMembership` fails, naming the four `TestEntityStorage*`
    classes, when `@CoreBackend.All` is removed from
    `AbstractEntityStorageTest`.
    - `actionlint .github/workflows/build.yml`
    
    <details><summary>Timing (CI)</summary>
    
    Lane jobs from [run
    38280654558](https://github.com/apache/gravitino/actions/runs/38280654558)
    on `656282cc6`. Later commits remove code and comments and leave lane
    selection unchanged; the lanes' JUnit and JaCoCo outputs now use
    Gradle's default paths.
    
    | Job | Wall time |
    | --- | ---: |
    | `build (17)` on `main`, core included
    ([1](https://github.com/apache/gravitino/actions/runs/38357234677),
    [2](https://github.com/apache/gravitino/actions/runs/38245414857),
    [3](https://github.com/apache/gravitino/actions/runs/38139898061)) |
    1h28m – 1h46m |
    | `build (17)` on this PR, core skipped | 49m30s |
    | `core-test (17, unit)` | 6m04s |
    | `core-test (17, h2)` | 6m59s |
    | `core-test (17, postgresql)` | 19m11s |
    | `core-test (17, mysql)` | 27m20s |
    | `coverage` | 1m28s |
    
    The lanes run in parallel with `build`, so the critical path is `build`
    plus `coverage`, about 51m.
    
    </details>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
    Co-authored-by: Jerry Shao <[email protected]>
---
 .github/workflows/build.yml                        | 167 ++++++++++++++++++---
 build.gradle.kts                                   |  19 ++-
 core/build.gradle.kts                              | 106 ++++++++++++-
 .../TestJdbcPartitionStatisticStorageIT.java       |   7 +-
 .../storage/AbstractEntityStorageTest.java         |  24 ++-
 .../gravitino/storage/TestBackendTestSelector.java |  84 +++++++++++
 .../storage/relational/BackendTestExtension.java   |  21 ++-
 .../storage/relational/BackendTestSelector.java    |  71 +++++++++
 .../gravitino/storage/relational/CoreBackend.java  |  86 +++++++++++
 .../storage/relational/TestCoreLaneMembership.java | 107 +++++++++++++
 .../storage/relational/TestJDBCBackend.java        |   1 +
 .../relational/service/TestJobMetaService.java     |  48 ------
 .../service/TestJobMetaServiceValidation.java      |  78 ++++++++++
 docs/how-to-test.md                                |  23 +++
 14 files changed, 759 insertions(+), 83 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 30d5754e27..98dd113bb7 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,156 @@ 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: Validate core test evidence
+        run: |
+          junit_xml="$(find "core/build/test-results/${{ matrix.task }}" \
+            -type f -name 'TEST-*.xml' -print -quit)"
+          test -n "${junit_xml}"
+          test -s "${junit_xml}"
+          test -s "build/reports/tests/core/${{ matrix.task }}/index.html"
+          test -s "core/build/jacoco/${{ matrix.task }}.exec"
+
+      - name: Upload core test evidence
+        uses: actions/upload-artifact@v7
+        with:
+          name: core-${{ matrix.lane }}-test-evidence
+          path: |
+            core/build/test-results/${{ matrix.task }}
+            build/reports/tests/core/${{ matrix.task }}
+            core/build/jacoco/${{ matrix.task }}.exec
+          if-no-files-found: error
+          retention-days: 1
+
+      - name: Upload core test report
+        uses: actions/upload-artifact@v7
+        if: failure()
+        with:
+          name: core test report ${{ matrix.lane }}
+          path: |
+            build/reports/tests/core
+            core/build/test-results
+
+  coverage:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    needs: [ changes, build, core-test ]
+    if: >-
+      always() &&
+      github.event_name == 'pull_request' &&
+      needs.build.result == 'success' &&
+      (needs.changes.outputs.maintenance_module_only_changes == 'true' ||
+      needs['core-test'].result == 'success')
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: ./.github/actions/setup-java-toolchains
+        if: needs.changes.outputs.maintenance_module_only_changes != 'true'
+        with:
+          java-version: 17
+
+      - name: Fetch base branch for coverage diff
         run: git fetch origin ${{ github.base_ref }} --depth=1
 
+      - name: Download coverage data
+        uses: actions/download-artifact@v4
+        with:
+          pattern: jacoco-*
+          merge-multiple: true
+
+      - name: Download core test evidence
+        if: needs.changes.outputs.maintenance_module_only_changes != 'true'
+        uses: actions/download-artifact@v4
+        with:
+          pattern: core-*-test-evidence
+          path: core-test-evidence
+
+      - name: Generate combined core coverage
+        if: needs.changes.outputs.maintenance_module_only_changes != 'true'
+        run: |
+          mkdir -p core/build/jacoco
+          for task in coreUnitTest coreH2Test coreMySQLTest 
corePostgreSQLTest; do
+            exec_file="$(find core-test-evidence -path 
"*/core/build/jacoco/${task}.exec" -print -quit)"
+            test -n "${exec_file}"
+            install -m 0644 "${exec_file}" "core/build/jacoco/${task}.exec"
+          done
+          ./gradlew \
+            :core:jacocoTestReport \
+            -PcoreSuiteCoverage=true \
+            -PskipWeb=true
+          test -s core/build/reports/jacoco/test/jacocoTestReport.xml
+
       - name: Generate Coverage Report
         id: coverage
-        if: github.event_name == 'pull_request'
         run: |
           python3 dev/ci/jacoco_report.py \
             --base-ref "${{ github.base_ref }}" \
@@ -240,11 +385,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 +398,7 @@ jobs:
             pr-number.txt
 
       - name: Output Coverage Info
-        if: github.event_name == 'pull_request' && 
steps.coverage.outputs.has_reports == 'true'
+        if: steps.coverage.outputs.has_reports == 'true'
         run: |
           echo "Total coverage ${{ steps.coverage.outputs.coverage-overall }}"
           echo "Changed Files coverage ${{ 
steps.coverage.outputs.coverage-changed-files }}"
-
-      - name: Upload unit tests report
-        uses: actions/upload-artifact@v7
-        if: failure()
-        with:
-          name: unit test report
-          path: |
-            build/reports
-            catalogs-contrib/**/*.log
-            catalogs-contrib/**/*.tar
-            catalogs/**/*.log
-            catalogs/**/*.tar
diff --git a/build.gradle.kts b/build.gradle.kts
index 0161f01f72..ea7cd68e2b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -514,9 +514,14 @@ allprojects {
 
       val dockerTest = project.rootProject.extra["dockerTest"] as? Boolean ?: 
false
       param.environment("dockerTest", dockerTest.toString())
+      // Must stay inside doFirst: tasks such as :core's database lanes set 
this flag in their own
+      // configuration action, which may run after this configureEach action 
registers.
+      val includeDockerTaggedTests =
+        
param.extensions.extraProperties.properties["includeDockerTaggedTests"] as? 
Boolean
+          ?: dockerTest
       val dorisMultiVersion = project.hasProperty("dorisMultiVersionTest")
       param.useJUnitPlatform {
-        if (!dockerTest) {
+        if (!includeDockerTaggedTests) {
           excludeTags("gravitino-docker-test")
         }
         if (!dorisMultiVersion) {
@@ -1019,7 +1024,17 @@ subprojects {
       val extraArgs = project.property("extraJvmArgs") as List<String>
       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 bdd8cbbfc1..2c846b6e48 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -105,11 +105,109 @@ artifacts {
   add("testArtifacts", testJar)
 }
 
-tasks.test {
-  val policiesDocument = rootProject.file("docs/policies.md")
-  inputs.file(policiesDocument)
-  systemProperty("gravitino.repositoryRoot", rootProject.projectDir.path)
+// Core's tests run in four lanes: coreUnitTest, plus 
coreH2Test/coreMySQLTest/corePostgreSQLTest.
+// A class joins a backend lane through its @CoreBackend.* annotation; see 
CoreBackend.java.
+// Backend name -> the JUnit tag that admits a class to that lane. Keep in 
sync with CoreBackend.
+val coreBackendTestTags =
+  linkedMapOf(
+    "h2" to "gravitino-core-h2-test",
+    "mysql" to "gravitino-core-mysql-test",
+    "postgresql" to "gravitino-core-postgresql-test"
+  )
+val coreTestBackendProperty = "gravitino.core.test.backend"
+
+fun registerCoreTestTask(
+  taskName: String,
+  backend: String? = null
+) = tasks.register<Test>(taskName) {
+  group = "verification"
+  description =
+    if (backend == null) {
+      "Runs core unit tests."
+    } else {
+      "Runs core database tests against $backend."
+    }
+
+  testClassesDirs = sourceSets["test"].output.classesDirs
+  classpath = sourceSets["test"].runtimeClasspath
+
+  reports.html.outputLocation.set(
+    rootProject.layout.buildDirectory.dir("reports/tests/core/$taskName")
+  )
+
+  useJUnitPlatform {
+    if (backend == null) {
+      // Whatever carries no backend tag (and no Docker tag) is the unit suite.
+      excludeTags(*coreBackendTestTags.values.toTypedArray(), 
"gravitino-docker-test")
+    } else {
+      val ownBackendTag =
+        coreBackendTestTags[backend]
+          ?: throw GradleException("Unsupported core test backend: $backend")
+      // Plain tag include, applied by JUnit at discovery time, so classes not 
tagged for this
+      // backend never show up in this lane's JUnit XML. A class tagged for 
several backends
+      // runs under each of them.
+      includeTags(ownBackendTag)
+    }
+  }
 
+  if (backend != null) {
+    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
+    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") }
+tasks.named<JacocoReport>("jacocoTestReport") {
+  if (coreSuiteCoverage.get()) {
+    dependsOn(tasks.named("classes"))
+    executionData.setFrom(coreSuiteExecutionData)
+  }
+}
+
+// :core:test is kept for IDEs and local habit, and is deprecated: run a lane 
instead. CI skips it
+// (`build` passes `-x :core:test`) and runs each lane in its own `core-test` 
job.
+// TestRestrictedRegoGoldenFile reads docs/policies.md. It is a unit test, so 
only coreUnitTest and
+// the legacy :core:test run it; the database lanes don't need the input.
+listOf("coreUnitTest", "test").forEach {
+  tasks.named<Test>(it) {
+    inputs.file(rootProject.file("docs/policies.md"))
+    systemProperty("gravitino.repositoryRoot", rootProject.projectDir.path)
+  }
+}
+
+tasks.test {
+  doFirst {
+    logger.warn(
+      "WARNING: :core:test is deprecated and does not correspond to any CI 
lane. Use " +
+        "coreUnitTest, coreH2Test, coreMySQLTest, or corePostgreSQLTest 
instead."
+    )
+  }
   val testMode = project.properties["testMode"] as? String ?: "embedded"
   if (testMode == "embedded") {
     environment("GRAVITINO_HOME", project.rootDir.path)
diff --git 
a/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java
 
b/core/src/test/java/org/apache/gravitino/stats/storage/TestJdbcPartitionStatisticStorageIT.java
index b1cac18a98..38d421bd54 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.PartitionStatisticsModification;
 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;
@@ -93,7 +94,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. Each subclass carries the tag of the 
one backend lane it
+   * runs in.
    */
   @TestInstance(TestInstance.Lifecycle.PER_CLASS)
   abstract static class BaseJdbcPartitionStatisticStorageTest {
@@ -585,6 +587,7 @@ public class TestJdbcPartitionStatisticStorageIT {
 
   /** MySQL-specific tests using Docker container. */
   @Nested
+  @CoreBackend.MySQL
   @Tag("gravitino-docker-test")
   static class MySQLTest extends BaseJdbcPartitionStatisticStorageTest {
 
@@ -655,6 +658,7 @@ public class TestJdbcPartitionStatisticStorageIT {
 
   /** PostgreSQL-specific tests using Docker container. */
   @Nested
+  @CoreBackend.PostgreSQL
   @Tag("gravitino-docker-test")
   static class PostgreSQLTest extends BaseJdbcPartitionStatisticStorageTest {
 
@@ -728,6 +732,7 @@ public class TestJdbcPartitionStatisticStorageIT {
 
   /** H2-specific tests using embedded in-memory database. */
   @Nested
+  @CoreBackend.H2
   static class H2Test extends BaseJdbcPartitionStatisticStorageTest {
 
     private static final String H2_JDBC_URL =
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
 
b/core/src/test/java/org/apache/gravitino/storage/AbstractEntityStorageTest.java
index 896fe90ca4..f49c550f4e 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.SQLException;
 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,8 @@ import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.meta.TopicEntity;
 import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.storage.relational.BackendTestSelector;
+import org.apache.gravitino.storage.relational.CoreBackend;
 import org.apache.gravitino.storage.relational.RelationalBackend;
 import org.apache.gravitino.storage.relational.RelationalEntityStore;
 import org.apache.gravitino.storage.relational.RelationalGarbageCollector;
@@ -110,6 +113,7 @@ import org.mockito.Mockito;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
[email protected]
 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 0000000000..7eb4760f57
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/TestBackendTestSelector.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.apache.gravitino.storage.relational.BackendTestSelector;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceAccessMode;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+
+/** Tests backend selection for the core database test suites. */
+@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = 
ResourceAccessMode.READ_WRITE)
+public class TestBackendTestSelector {
+
+  private static final String BACKEND_PROPERTY = "gravitino.core.test.backend";
+
+  private Optional<String> originalBackend = Optional.empty();
+
+  @BeforeEach
+  void saveAndClearBackendProperty() {
+    originalBackend = 
Optional.ofNullable(System.getProperty(BACKEND_PROPERTY));
+    System.clearProperty(BACKEND_PROPERTY);
+  }
+
+  @AfterEach
+  void restoreBackendProperty() {
+    System.clearProperty(BACKEND_PROPERTY);
+    originalBackend.ifPresent(value -> System.setProperty(BACKEND_PROPERTY, 
value));
+  }
+
+  @Test
+  void testAbsentSelectionPreservesLegacyBehavior() {
+    assertEquals(Optional.empty(), BackendTestSelector.selectedBackend());
+    assertTrue(BackendTestSelector.isSelected("h2"));
+    assertTrue(BackendTestSelector.isSelected("mysql"));
+    assertTrue(BackendTestSelector.isSelected("postgresql"));
+  }
+
+  @Test
+  void testSelectionIsNormalizedAndValidated() {
+    System.setProperty(BACKEND_PROPERTY, " MySQL ");
+
+    assertEquals(Optional.of("mysql"), BackendTestSelector.selectedBackend());
+    assertTrue(BackendTestSelector.isSelected("MYSQL"));
+    assertFalse(BackendTestSelector.isSelected("h2"));
+
+    System.setProperty(BACKEND_PROPERTY, "unsupported");
+    assertThrows(IllegalArgumentException.class, 
BackendTestSelector::selectedBackend);
+  }
+
+  @Test
+  void testStorageProviderUsesSelectedBackend() {
+    System.setProperty(BACKEND_PROPERTY, "postgresql");
+
+    assertArrayEquals(
+        new Object[][] {{"postgresql", true}, {"postgresql", false}},
+        AbstractEntityStorageTest.storageProvider());
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
index 9961e30bb1..d442fb3113 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.nio.file.Paths;
 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 class BackendTestExtension
   @Override
   public Stream<TestTemplateInvocationContext> 
provideTestTemplateInvocationContexts(
       ExtensionContext context) {
+    String testMethodName = context.getRequiredTestMethod().getName();
+    Optional<String> 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<String> backendsToTest = new ArrayList<>();
     backendsToTest.add("h2"); // Always test with H2
 
@@ -122,19 +131,25 @@ public class BackendTestExtension
           "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());
+      // 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
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 0000000000..3f794dbea8
--- /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<String> 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<String> selectedBackend() {
+    String configuredBackend = System.getProperty(BACKEND_PROPERTY);
+    if (configuredBackend == null) {
+      return Optional.empty();
+    }
+
+    return Optional.of(validate(configuredBackend));
+  }
+
+  /**
+   * Returns whether a backend should run under the current selection.
+   *
+   * @param backend backend to test
+   * @return true when no backend is selected or the backend matches the 
selection
+   * @throws IllegalArgumentException if either backend value is unsupported
+   */
+  public static boolean isSelected(String backend) {
+    String normalizedBackend = validate(backend);
+    return selectedBackend().map(normalizedBackend::equals).orElse(true);
+  }
+
+  private static String validate(String backend) {
+    String normalizedBackend = backend.trim().toLowerCase(Locale.ROOT);
+    if (!SUPPORTED_BACKENDS.contains(normalizedBackend)) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Unsupported core test backend '%s'; expected one of %s",
+              backend, SUPPORTED_BACKENDS));
+    }
+    return normalizedBackend;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java 
b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java
new file mode 100644
index 0000000000..893bea308f
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/CoreBackend.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Annotations that choose which core database test lane(s) a class runs in. 
Each lane task ({@code
+ * coreH2Test}, {@code coreMySQLTest}, {@code corePostgreSQLTest}) includes 
only classes with its
+ * tag; stack annotations to join several lanes, or use {@link All}. A class 
with none of them is a
+ * unit test and runs in {@code coreUnitTest}. {@code TestCoreLaneMembership} 
fails if a class
+ * carries {@code gravitino-docker-test} but no backend tag, since it would 
run in no lane.
+ *
+ * <p>The lane, not the class, decides whether Docker is needed: MySQL and 
PostgreSQL require it and
+ * H2 does not, so a class tagged for H2 must not need a container itself. The 
tag strings must stay
+ * in sync with {@code coreBackendTestTags} in {@code core/build.gradle.kts}.
+ */
+public final class CoreBackend {
+
+  /** The JUnit tag that selects the H2 lane. */
+  public static final String H2_TAG = "gravitino-core-h2-test";
+
+  /** The JUnit tag that selects the MySQL lane. */
+  public static final String MYSQL_TAG = "gravitino-core-mysql-test";
+
+  /** The JUnit tag that selects the PostgreSQL lane. */
+  public static final String POSTGRESQL_TAG = "gravitino-core-postgresql-test";
+
+  private CoreBackend() {}
+
+  /** Pins a core database test class to the H2 lane ({@code coreH2Test}). */
+  @Documented
+  @Inherited
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.TYPE)
+  @Tag(H2_TAG)
+  public @interface H2 {}
+
+  /** Pins a core database test class to the MySQL lane ({@code 
coreMySQLTest}). */
+  @Documented
+  @Inherited
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.TYPE)
+  @Tag(MYSQL_TAG)
+  public @interface MySQL {}
+
+  /** Pins a core database test class to the PostgreSQL lane ({@code 
corePostgreSQLTest}). */
+  @Documented
+  @Inherited
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.TYPE)
+  @Tag(POSTGRESQL_TAG)
+  public @interface PostgreSQL {}
+
+  /** Runs a core database test class under every backend lane. */
+  @Documented
+  @Inherited
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.TYPE)
+  @Tag(H2_TAG)
+  @Tag(MYSQL_TAG)
+  @Tag(POSTGRESQL_TAG)
+  public @interface All {}
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java
new file mode 100644
index 0000000000..a3c02117bf
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestCoreLaneMembership.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.platform.commons.annotation.Testable;
+import org.junit.platform.commons.support.AnnotationSupport;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+import org.junit.platform.commons.support.ReflectionSupport;
+
+/**
+ * Fails when a core test class runs in no lane: it carries {@code 
gravitino-docker-test}, which
+ * keeps it out of {@code coreUnitTest}, but no {@link CoreBackend} tag, which 
keeps it out of every
+ * database lane. Nothing else notices a class that leaves every lane at once.
+ */
+class TestCoreLaneMembership {
+
+  private static final String DOCKER_TAG = "gravitino-docker-test";
+
+  private static final Set<String> BACKEND_TAGS =
+      Set.of(CoreBackend.H2_TAG, CoreBackend.MYSQL_TAG, 
CoreBackend.POSTGRESQL_TAG);
+
+  @Test
+  void everyDockerTaggedTestClassHasABackendLane() {
+    URL coreTestClasses = codeLocation(TestCoreLaneMembership.class);
+    List<String> orphans =
+        ReflectionSupport.findAllClassesInPackage(
+                "org.apache.gravitino",
+                c -> coreTestClasses.equals(codeLocation(c)) && 
declaresTests(c),
+                name -> true)
+            .stream()
+            .filter(
+                c -> {
+                  Set<String> tags = effectiveTags(c);
+                  return tags.contains(DOCKER_TAG)
+                      && tags.stream().noneMatch(BACKEND_TAGS::contains);
+                })
+            .map(Class::getName)
+            .sorted()
+            .collect(Collectors.toList());
+
+    assertTrue(
+        orphans.isEmpty(),
+        "These classes carry gravitino-docker-test but no @CoreBackend.* tag, 
so they run in no "
+            + "core test lane. Add @CoreBackend.H2/MySQL/PostgreSQL or 
@CoreBackend.All: "
+            + orphans);
+  }
+
+  /**
+   * Whether JUnit would run {@code c}: concrete, with at least one {@code 
@Test}-style method (all
+   * meta-annotated with {@link Testable}), own or inherited.
+   */
+  private static boolean declaresTests(Class<?> c) {
+    return !Modifier.isAbstract(c.getModifiers())
+        && !ReflectionSupport.findMethods(
+                c,
+                m -> AnnotationSupport.isAnnotated(m, Testable.class),
+                HierarchyTraversalMode.TOP_DOWN)
+            .isEmpty();
+  }
+
+  /**
+   * Own and inherited tags, plus the enclosing classes' tags for non-static 
{@code @Nested}
+   * classes, which JUnit applies to them. Static member classes are 
discovered as top-level classes
+   * and don't inherit their enclosing class's tags.
+   */
+  private static Set<String> effectiveTags(Class<?> c) {
+    Set<String> tags = new HashSet<>();
+    for (Class<?> k = c;
+        k != null;
+        k = Modifier.isStatic(k.getModifiers()) ? null : 
k.getEnclosingClass()) {
+      AnnotationSupport.findRepeatableAnnotations(k, Tag.class).forEach(t -> 
tags.add(t.value()));
+    }
+    return tags;
+  }
+
+  private static URL codeLocation(Class<?> c) {
+    return c.getProtectionDomain().getCodeSource() == null
+        ? null
+        : c.getProtectionDomain().getCodeSource().getLocation();
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
index 5c878525c2..41d08dff85 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackend.java
@@ -84,6 +84,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.TestInstance;
 import org.junit.jupiter.api.extension.ExtendWith;
 
[email protected]
 @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 9ddbfe51c4..abd5802a53 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.storage.relational.TestJDBCBackend;
 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 class TestJobMetaService extends TestJDBCBackend {
                             .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 0000000000..2d7f6ea617
--- /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/docs/how-to-test.md b/docs/how-to-test.md
index 373a510e06..2e56705488 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, <lane>)` 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.

Reply via email to