This is an automated email from the ASF dual-hosted git repository.
diqiu50 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 32d08effca [#13208] improvement(spark-connector): Add
SparkCatalogExtension SPI for external catalogs (#13209)
32d08effca is described below
commit 32d08effca0b47fde982865f45f2729cb579a7b2
Author: Yuhui <[email protected]>
AuthorDate: Wed Sep 16 22:36:14 2026 +0800
[#13208] improvement(spark-connector): Add SparkCatalogExtension SPI for
external catalogs (#13209)
### What changes were proposed in this pull request?
- Add `SparkCatalogExtension`, a `ServiceLoader` SPI that maps a
Gravitino catalog provider to a Spark catalog class.
- Add `SparkCatalogExtensions` to load and cache registered extensions,
matching providers case-insensitively and skipping extensions that fail
to link.
- `CatalogNameAdaptor.getCatalogName` returns the extension's catalog
class when one serves the provider, before falling back to the built-in
mapping.
### Why are the changes needed?
Catalog implementations shipped outside the connector jar can be
registered without changing the connector.
Fixed: #13208
### Does this PR introduce _any_ user-facing change?
New SPI
`org.apache.gravitino.spark.connector.plugin.SparkCatalogExtension`.
Existing behavior is unchanged when no extension is present.
### How was this patch tested?
Added `TestSparkCatalogExtensions` with a fake extension registered via
`META-INF/services`; existing `TestCatalogNameAdaptor` passes.
---
spark-connector/spark-common/build.gradle.kts | 5 +-
.../connector/plugin/GravitinoDriverPlugin.java | 6 +-
.../connector/plugin/SparkCatalogExtension.java | 48 +++++++++
.../connector/plugin/SparkCatalogExtensions.java | 116 +++++++++++++++++++++
.../connector/version/CatalogNameAdaptor.java | 5 +
.../plugin/BlankSparkCatalogExtension.java | 35 +++++++
.../plugin/BrokenSparkCatalogExtension.java | 35 +++++++
.../plugin/DuplicateSparkCatalogExtension.java | 35 +++++++
.../plugin/FakeSparkCatalogExtension.java | 36 +++++++
.../plugin/TestSparkCatalogExtensions.java | 73 +++++++++++++
.../plugin/ThrowingSparkCatalogExtension.java | 35 +++++++
...no.spark.connector.plugin.SparkCatalogExtension | 27 +++++
12 files changed, 454 insertions(+), 2 deletions(-)
diff --git a/spark-connector/spark-common/build.gradle.kts
b/spark-connector/spark-common/build.gradle.kts
index c7aceb9e16..06d18e2363 100644
--- a/spark-connector/spark-common/build.gradle.kts
+++ b/spark-connector/spark-common/build.gradle.kts
@@ -220,7 +220,8 @@ tasks.clean {
sourceSets {
named("test") {
resources {
- exclude("**/*")
+ // Keep only SPI registrations; the version modules supply their own
test configs and data.
+ exclude { !it.relativePath.pathString.startsWith("META-INF") }
}
}
}
@@ -228,6 +229,8 @@ sourceSets {
val testJar by tasks.registering(Jar::class) {
archiveClassifier.set("tests")
from(sourceSets["test"].output)
+ // The SPI fixture is only for this module's tests; keep it off the version
modules' classpath.
+ exclude("META-INF/services/**")
}
configurations {
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
index 59636ec0ef..ed3abc71d7 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
@@ -162,7 +162,11 @@ public class GravitinoDriverPlugin implements DriverPlugin
{
String catalogClassName = CatalogNameAdaptor.getCatalogName(provider);
if (StringUtils.isBlank(catalogClassName)) {
- LOG.warn("Skip registering {} because {} is not supported yet.",
catalogName, provider);
+ LOG.warn(
+ "Skip registering {} because no Spark catalog is bound to provider
{}: neither built into"
+ + " the connector nor supplied by a SparkCatalogExtension.",
+ catalogName,
+ provider);
return;
}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtension.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtension.java
new file mode 100644
index 0000000000..d81c7e90c3
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtension.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.spark.connector.plugin;
+
+import java.util.ServiceLoader;
+
+/**
+ * Service provider interface that lets a jar outside the connector supply the
Spark catalog
+ * implementation for a Gravitino catalog provider. Implementations are
discovered through {@link
+ * ServiceLoader} and take precedence over the catalogs the connector ships
itself. The {@code
+ * lakehouse-iceberg} and {@code lakehouse-paimon} providers stay behind their
opt-in flags: an
+ * extension for either is only consulted once the corresponding flag is
enabled. When several
+ * extensions declare the same provider, the first one discovered is used and
the others are ignored
+ * with a warning.
+ */
+public interface SparkCatalogExtension {
+
+ /**
+ * The Gravitino catalog provider this extension serves, for example {@code
jdbc-oracle}.
+ *
+ * @return the provider name, matched case-insensitively
+ */
+ String provider();
+
+ /**
+ * The fully qualified name of the Spark {@code TableCatalog} class
registered for the provider.
+ * Spark loads it when the session is created, so it must be on the Spark
class path.
+ *
+ * @return the Spark catalog class name
+ */
+ String catalogClassName();
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtensions.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtensions.java
new file mode 100644
index 0000000000..c76a862962
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/SparkCatalogExtensions.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.spark.connector.plugin;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Resolves Spark catalog classes contributed through {@link
SparkCatalogExtension}. */
+public class SparkCatalogExtensions {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SparkCatalogExtensions.class);
+
+ private static volatile Map<String, String> catalogClassNamesByProvider;
+
+ private SparkCatalogExtensions() {}
+
+ /**
+ * Looks up the Spark catalog class an extension registered for a provider.
+ *
+ * @param provider the Gravitino catalog provider
+ * @return the catalog class name, or null when no extension serves the
provider
+ */
+ @Nullable
+ public static String catalogClassName(String provider) {
+ return extensions().get(provider.toLowerCase(Locale.ROOT));
+ }
+
+ private static Map<String, String> extensions() {
+ Map<String, String> loaded = catalogClassNamesByProvider;
+ if (loaded == null) {
+ synchronized (SparkCatalogExtensions.class) {
+ loaded = catalogClassNamesByProvider;
+ if (loaded == null) {
+ loaded = load();
+ catalogClassNamesByProvider = loaded;
+ }
+ }
+ }
+ return loaded;
+ }
+
+ private static Map<String, String> load() {
+ Map<String, String> byProvider = new HashMap<>();
+ Iterator<SparkCatalogExtension> iterator =
+ ServiceLoader.load(SparkCatalogExtension.class,
extensionClassLoader()).iterator();
+ while (true) {
+ try {
+ if (!iterator.hasNext()) {
+ break;
+ }
+ SparkCatalogExtension extension = iterator.next();
+ String provider = extension.provider();
+ String catalogClassName = extension.catalogClassName();
+ if (StringUtils.isBlank(provider) ||
StringUtils.isBlank(catalogClassName)) {
+ LOG.error(
+ "Skip Spark catalog extension {}: provider and catalog class
name must not be blank,"
+ + " got provider={}, catalogClassName={}.",
+ extension.getClass().getName(),
+ provider,
+ catalogClassName);
+ continue;
+ }
+ String normalized = provider.toLowerCase(Locale.ROOT);
+ String previous = byProvider.putIfAbsent(normalized, catalogClassName);
+ if (previous != null) {
+ LOG.warn(
+ "Ignore Spark catalog extension {} for provider {}: already
served by {}.",
+ catalogClassName,
+ normalized,
+ previous);
+ }
+ } catch (ServiceConfigurationError | LinkageError | RuntimeException e) {
+ // ServiceLoader reports a missing provider class, a malformed
META-INF entry or a failing
+ // constructor as ServiceConfigurationError; an extension built
against another Spark
+ // version fails with a LinkageError once its methods run, and a
misconfigured one may
+ // throw from provider() or catalogClassName(). Skip that entry and
keep the rest.
+ LOG.error("Skip a Spark catalog extension that cannot be loaded.", e);
+ }
+ }
+ LOG.info("Discovered Spark catalog extensions: {}", byProvider);
+ return byProvider;
+ }
+
+ /**
+ * Prefers the context class loader so extension jars added through {@code
--jars} are visible
+ * when the connector itself sits on the driver class path.
+ */
+ private static ClassLoader extensionClassLoader() {
+ ClassLoader loader = Thread.currentThread().getContextClassLoader();
+ return loader != null ? loader :
SparkCatalogExtensions.class.getClassLoader();
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
index c5c4bbcc78..02aa3fcef1 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.spark.connector.version;
import com.google.common.collect.ImmutableMap;
import java.util.Locale;
import java.util.Map;
+import org.apache.gravitino.spark.connector.plugin.SparkCatalogExtensions;
import org.apache.spark.package$;
import org.apache.spark.util.VersionUtils$;
@@ -99,6 +100,10 @@ public class CatalogNameAdaptor {
}
public static String getCatalogName(String provider) {
+ String extensionCatalogName =
SparkCatalogExtensions.catalogClassName(provider);
+ if (extensionCatalogName != null) {
+ return extensionCatalogName;
+ }
int majorVersion = VersionUtils$.MODULE$.majorVersion(sparkVersion());
int minorVersion = VersionUtils$.MODULE$.minorVersion(sparkVersion());
return getCatalogName(provider, majorVersion, minorVersion);
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BlankSparkCatalogExtension.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BlankSparkCatalogExtension.java
new file mode 100644
index 0000000000..822acdd1af
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BlankSparkCatalogExtension.java
@@ -0,0 +1,35 @@
+/*
+ * 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.spark.connector.plugin;
+
+/** Returns a blank catalog class name and must be skipped. */
+public class BlankSparkCatalogExtension implements SparkCatalogExtension {
+
+ public static final String PROVIDER = "jdbc-blank";
+
+ @Override
+ public String provider() {
+ return PROVIDER;
+ }
+
+ @Override
+ public String catalogClassName() {
+ return " ";
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BrokenSparkCatalogExtension.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BrokenSparkCatalogExtension.java
new file mode 100644
index 0000000000..4f9a0d7d73
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/BrokenSparkCatalogExtension.java
@@ -0,0 +1,35 @@
+/*
+ * 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.spark.connector.plugin;
+
+/** Simulates an extension compiled against a class that is absent at runtime.
*/
+public class BrokenSparkCatalogExtension implements SparkCatalogExtension {
+
+ public static final String PROVIDER = "jdbc-broken";
+
+ @Override
+ public String provider() {
+ throw new NoClassDefFoundError("org/example/AbsentSparkClass");
+ }
+
+ @Override
+ public String catalogClassName() {
+ return "org.example.BrokenCatalog";
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/DuplicateSparkCatalogExtension.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/DuplicateSparkCatalogExtension.java
new file mode 100644
index 0000000000..3c67524079
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/DuplicateSparkCatalogExtension.java
@@ -0,0 +1,35 @@
+/*
+ * 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.spark.connector.plugin;
+
+/** Declares the same provider as {@link FakeSparkCatalogExtension} in another
case, and loses. */
+public class DuplicateSparkCatalogExtension implements SparkCatalogExtension {
+
+ public static final String CATALOG_CLASS_NAME =
"org.example.OtherTestDbCatalog";
+
+ @Override
+ public String provider() {
+ return FakeSparkCatalogExtension.PROVIDER.toUpperCase();
+ }
+
+ @Override
+ public String catalogClassName() {
+ return CATALOG_CLASS_NAME;
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/FakeSparkCatalogExtension.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/FakeSparkCatalogExtension.java
new file mode 100644
index 0000000000..e00248822a
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/FakeSparkCatalogExtension.java
@@ -0,0 +1,36 @@
+/*
+ * 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.spark.connector.plugin;
+
+/** Registered through META-INF/services in the test resources. */
+public class FakeSparkCatalogExtension implements SparkCatalogExtension {
+
+ public static final String PROVIDER = "jdbc-testdb";
+ public static final String CATALOG_CLASS_NAME = "org.example.TestDbCatalog";
+
+ @Override
+ public String provider() {
+ return PROVIDER;
+ }
+
+ @Override
+ public String catalogClassName() {
+ return CATALOG_CLASS_NAME;
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestSparkCatalogExtensions.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestSparkCatalogExtensions.java
new file mode 100644
index 0000000000..9c6644ec3f
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestSparkCatalogExtensions.java
@@ -0,0 +1,73 @@
+/*
+ * 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.spark.connector.plugin;
+
+import org.apache.gravitino.spark.connector.version.CatalogNameAdaptor;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestSparkCatalogExtensions {
+
+ @Test
+ void testResolvesRegisteredProvider() {
+ Assertions.assertEquals(
+ FakeSparkCatalogExtension.CATALOG_CLASS_NAME,
+
SparkCatalogExtensions.catalogClassName(FakeSparkCatalogExtension.PROVIDER));
+ Assertions.assertEquals(
+ FakeSparkCatalogExtension.CATALOG_CLASS_NAME,
+ SparkCatalogExtensions.catalogClassName("JDBC-TestDB"));
+ }
+
+ @Test
+ void testFirstDiscoveredExtensionWinsForDuplicateProvider() {
+ Assertions.assertEquals(
+ FakeSparkCatalogExtension.CATALOG_CLASS_NAME,
+
SparkCatalogExtensions.catalogClassName(FakeSparkCatalogExtension.PROVIDER));
+ }
+
+ @Test
+ void testBlankCatalogClassNameIsSkipped() {
+ Assertions.assertNull(
+
SparkCatalogExtensions.catalogClassName(BlankSparkCatalogExtension.PROVIDER));
+ }
+
+ @Test
+ void testLinkageFailureIsSkipped() {
+ Assertions.assertNull(
+
SparkCatalogExtensions.catalogClassName(BrokenSparkCatalogExtension.PROVIDER));
+ }
+
+ @Test
+ void testRuntimeFailureIsSkipped() {
+ Assertions.assertNull(
+
SparkCatalogExtensions.catalogClassName(ThrowingSparkCatalogExtension.PROVIDER));
+ }
+
+ @Test
+ void testUnknownProviderIsNotServed() {
+
Assertions.assertNull(SparkCatalogExtensions.catalogClassName("jdbc-unknown"));
+ }
+
+ @Test
+ void testExtensionTakesPrecedenceInCatalogNameAdaptor() {
+ Assertions.assertEquals(
+ FakeSparkCatalogExtension.CATALOG_CLASS_NAME,
+ CatalogNameAdaptor.getCatalogName(FakeSparkCatalogExtension.PROVIDER));
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/ThrowingSparkCatalogExtension.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/ThrowingSparkCatalogExtension.java
new file mode 100644
index 0000000000..1a59360710
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/ThrowingSparkCatalogExtension.java
@@ -0,0 +1,35 @@
+/*
+ * 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.spark.connector.plugin;
+
+/** Simulates an extension whose configuration lookup fails at runtime. */
+public class ThrowingSparkCatalogExtension implements SparkCatalogExtension {
+
+ public static final String PROVIDER = "jdbc-throwing";
+
+ @Override
+ public String provider() {
+ throw new IllegalStateException("required setting is missing");
+ }
+
+ @Override
+ public String catalogClassName() {
+ return "org.example.ThrowingCatalog";
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/resources/META-INF/services/org.apache.gravitino.spark.connector.plugin.SparkCatalogExtension
b/spark-connector/spark-common/src/test/resources/META-INF/services/org.apache.gravitino.spark.connector.plugin.SparkCatalogExtension
new file mode 100644
index 0000000000..5fbe03d2e4
--- /dev/null
+++
b/spark-connector/spark-common/src/test/resources/META-INF/services/org.apache.gravitino.spark.connector.plugin.SparkCatalogExtension
@@ -0,0 +1,27 @@
+#
+# 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.
+#
+
+# A missing class exercises the ServiceConfigurationError skip path and a
linkage failure the
+# LinkageError skip path; the fake below must still load.
+org.apache.gravitino.spark.connector.plugin.MissingSparkCatalogExtension
+org.apache.gravitino.spark.connector.plugin.BrokenSparkCatalogExtension
+org.apache.gravitino.spark.connector.plugin.ThrowingSparkCatalogExtension
+org.apache.gravitino.spark.connector.plugin.FakeSparkCatalogExtension
+org.apache.gravitino.spark.connector.plugin.DuplicateSparkCatalogExtension
+org.apache.gravitino.spark.connector.plugin.BlankSparkCatalogExtension